fix(#1173): replace #liveDot with packet-driven brand-logo node-pulse#1177
fix(#1173): replace #liveDot with packet-driven brand-logo node-pulse#1177Kpa-clawbot merged 10 commits intomasterfrom
Conversation
Adds test-logo-pulse-1173-e2e.js covering: (a) #liveDot removed from DOM (b) .logo-node-a/.logo-node-b classes on inner circles in both SVGs (c) window.__corescopeLogo.pulse() hook toggles .logo-pulse-active (d) direction alternates A/B/A/B across 4 pings (e) rate cap (15/sec → ≤16 toggles for 100-pulse burst) (f) prefers-reduced-motion: emits .logo-pulse-blip, not chained .logo-pulse-active (g) setConnected(false) → .logo-disconnected on both .brand-logo and .brand-mark-only (h) computed fill of pulse circles resolves to themed --logo-accent tokens Wired into Playwright matrix in deploy.yml (mirrors PR #1168 pattern, commit 5442652). CI is expected to FAIL on assertion (hook missing + classes missing) — this is the RED commit. Implementation lands in the next commit.
…eDot Implements the locked spec from issue #1173: - Adds .logo-node-a / .logo-node-b classes to inner circles in both .brand-logo and .brand-mark-only SVGs (so the mobile mark animates too). - Removes the legacy #liveDot element + its WS-event class toggles + its pulse-ring CSS keyframes. (Kept .live-dot{display:none} as a defensive no-op for any stale customizer markup.) - Adds Logo IIFE in public/app.js: rate-gated pulse() (66ms gap → 15/sec cap), alternating A/B direction flip, ≤200ms total chained ping (80ms each half + rAF stitch), prefers-reduced-motion blip fallback, setConnected() toggle for .logo-disconnected. - Wires WS handlers in connectWS() to call Logo.setConnected() on open/close and Logo.pulse() on every message. - Exposes window.__corescopeLogo as the deterministic test seam (used by test-logo-pulse-1173-e2e.js). - All animation tweaks filter:brightness() + transform:scale() only; fills stay on var(--logo-accent) / var(--logo-accent-hi) so the customizer + theme switch keep working with no JS reload. - Disconnected state applies filter:grayscale(0.6) opacity(0.7) on the SVG; clears in-flight pulse classes to avoid stuck state. Animation curve: ease-out (issue open-question 1, default). Rate cap: 15/sec (issue open-question 2, default). grep -rn liveDot public/ → 0 hits.
…ot #liveDot Pre-existing test 'Nodes page has WebSocket auto-update' asserted the existence of #liveDot and its 'connected' class as a WS-connectivity proxy. PR #1177 removed #liveDot in favor of the brand-logo packet-pulse animation; this test now asserts the new surface — .brand-logo presence + window.__corescopeLogo.setConnected seam + best-effort .logo-disconnected class absence.
… check The rebrand E2E previously required .live-dot in .nav-brand. PR #1173 removed that element in favor of the brand-logo packet-pulse animation. Replace the assertion with: (a) legacy .live-dot is GONE, and (b) the new window.__corescopeLogo seam (setConnected + pulse) exists.
…gend bullets The PR's style.css turned the legacy navbar live-dot class into a global no-op (`display:none !important`). But `live.js` reuses the `.live-dot` class for the packet-types and node-roles legend bullets on the live page (styled by `live.css` with normal precedence). The `!important` rule in the SPA-wide stylesheet wins the cascade and silently hides every legend bullet on /#/live. The navbar element is gone from index.html in this PR — the global rule served no purpose anyway. Remove it; replace with a comment explaining why we don't ship a no-op shell.
… seam Two zombie-pulse vectors fixed: 1. **rAF + setTimeout race after setConnected(false).** The chained pulse (source half → setTimeout(80) → rAF → destination half) had no cancellation. If WS dropped between halves, the destination toggle still fired on a logo that was already in the .logo-disconnected sustained-desaturate state, briefly fighting the visual indicator. Fix: bump a generation counter on every setConnected() call; both the setTimeout and rAF callbacks bail when generation drifts OR connected is false. 2. **pulse() while disconnected.** WS.onmessage in app.js writes Logo.pulse(e) before parsing — but a stray onmessage during close/reconnect shouldn't pulse the logo. Guard pulse() with a leading `if (!connected) return false;` (still increments dropped). Also freeze the api object exposed at window.__corescopeLogo. The brief calls for a read-only seam from outside; previously consumers could overwrite .pulse / .setConnected. Object.freeze prevents replacement; the getter properties for lastDirection/stats keep introspection live.
…layout assertion The PR removed step 3's '.live-dot is right of brand-logo' geometry assertion when it killed the live-dot element. That dropped coverage for SVG layout regressions: if the .brand-logo SVG renders with zero height (e.g. a CSS regression on `.nav-brand img`), the previous test would have caught it via the dot's position; now nothing did. Add back an equivalent layout assertion on .brand-logo itself: - visible (display/visibility/opacity) - non-zero box (≥60×16 — matches the .brand-mark-only width=60 and the navbar's natural ~32px line) Also add a small `assert(cond, msg)` helper for explicit assertion shape (the file uses `fail()` exclusively elsewhere — keeping that for non-condition error reports).
Kpa-clawbot
left a comment
There was a problem hiding this comment.
Independent review — APPROVED, zero must-fix
Reviewed via gh pr diff only (no checkout). Verified against origin/fix/issue-1173-logo-pulse.
Correctness — Logo IIFE
- Rate gate:
lastPingTsis updated only after the gap check passes; drops incrementstats.dropped. Burst-resistant. - Alternation:
aToB = (flip === 0)captured beforeflip ^= 1. Strict A,B,A,B sequence. - Generation counter: bumped in every
setConnected()call; checked in both the source-half timer AND insiderequestAnimationFramebefore destination toggle. Zombie pulse cannot fight.logo-disconnected. connectedre-check after rAF closes the second race window. Solid.try { window.__corescopeLogo = api }guards SSR/jsdom;Object.freezekeeps the seam read-only — correct call.
#liveDot removal — clean
grep -rn 'liveDot\|live-dot' public/ test*.jsagainst the head ref:- Production: zero
#liveDotreferences. Remaining.live-dothits are inlive.jslegend bullets and.vcr-live-dot— pre-existing, unrelated, intended (CSS comment instyle.csscalls this out explicitly so a global no-op rule isn't shipped). test-pull-to-reconnect*.jsstill callmakeEl('liveDot')in their vm sandbox stubs. Now dead (app.js no longer touchesgetElementById('liveDot')) but harmless — not coverage-weakening.
- Production: zero
CSS
logo-pulse-stepkeyframes mutate onlyfilter+transform.logo-pulse-blip-stepmutates onlyopacity..logo-disconnectedisfilter: grayscale(0.6) opacity(0.7). No hardcoded colors anywhere; circle fills resolve via--logo-accent/--logo-accent-hitokens (test (h) asserts both fills are non-default).transform-box: fill-box; transform-origin: center;is the right idiom for scaling SVG<circle>about its own center.- Both light + dark themes inherit via the existing
--logo-accent*tokens (no theme-specific overrides in this PR — correct, not in scope).
Both SVGs patched
.brand-logo and .brand-mark-only each carry class="logo-node-a" (cx=540) and class="logo-node-b" (cx=660) on the inner circles. E2E step (b) asserts both.
Reduced-motion gated in BOTH layers
- JS:
reducedMotion()short-circuits topulseBlip()(destination opacity blip only). - CSS:
@media (prefers-reduced-motion: reduce)neutralizes.logo-pulse-activeanimation: nonedefensively. - E2E (f) runs in a
reducedMotion: 'reduce'context and asserts blip-only path.
Orphan-test patches
test-e2e-playwright.js(Nodes WS test): replaces#liveDotlookup with.brand-logo+window.__corescopeLogo.setConnectedseam assertions. Coverage strengthened (now asserts the public seam exists).test-logo-rebrand-e2e.jsstep 3: replaces dot-right-of-logo geometry with brand-logo visibility + min-size (≥60×16) + seam check. Equivalent regression coverage for SVG layout, plus the seam contract — accepted.- No coverage weakened.
PR body
- "Red commit: PENDING (will update)" header is stale (the actual red commit
1fa2794is on the branch ahead of greenba644a3), but accurate red→green ordering is in the commit log. Cosmetic, not blocking. - All listed behaviors match the diff. No quietly deferred items detected.
Verdict
APPROVED. Ship it.
Kpa-clawbot
left a comment
There was a problem hiding this comment.
Carmack Review
REQUEST CHANGES — 2 must-fix
The animation is well-scoped: pure CSS keyframes, class-toggle-only, generation-cancellation on disconnect, rate-gate computed off a single time source. The chained-pulse half/rAF/setTimeout structure is correct, and the reduced-motion path really is cheaper (no scale + no rAF chain). Style invalidation is localized to circle.logo-node-{a,b} — the SVG paths don't get dragged through recalc.
Two real performance issues, both in the code that runs while the user has the tab in another window — i.e., the case nobody tests but the app spends most of its life in.
M1 — Hidden-tab pulse storm (public/app.js pulse / pulseChained)
WS messages keep arriving when the tab is hidden. pulse() keeps running synchronously off ws.onmessage, the rate-gate happily passes 15/sec, and each pass schedules a setTimeout(80) → rAF → setTimeout(80) chain.
On a hidden tab Chrome:
- clamps
setTimeoutto ~1 Hz, and - does not invoke
rAFcallbacks at all until the tab becomes visible.
So the chain's middle stage never fires while hidden. Each call captures a closure with gen, srcSel, dstSel, and survives in the pending-rAF queue. Steady state at 15/sec × hidden seconds:
- 60 s hidden → ~900 pending callbacks
- 1 h hidden → ~54 000 pending callbacks
Two costs: GC-rooted closures + object literals while hidden, and a stutter when the tab returns and they all unwind at once (the gen check will discard most of them, but only after they run).
The rate-gate isn't the right gate for this — the rate-gate's purpose is "render <16 frames/sec," and a hidden tab is rendering zero. Gate on document.hidden first.
function pulse(_msg) {
if (!connected) { stats.dropped++; return false; }
if (typeof document !== 'undefined' && document.hidden) { stats.dropped++; return false; }
const now = ...Place it before lastPingTs is updated so the first ping after returning from a hidden tab fires immediately rather than being gated by a stale 66 ms-ago timestamp captured an hour earlier.
M2 — matchMedia allocated per pulse (public/app.js reducedMotion)
function reducedMotion() {
try { return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
catch (_) { return false; }
}Called inside pulse() after the rate-gate, so up to 15/sec for the lifetime of the tab. window.matchMedia(query) returns a fresh MediaQueryList per call — a real allocation, not a cached lookup. At 15/sec for an hour that's ~54 000 throwaway MQLs. Reduced-motion preference is a user setting; it does not change between WS frames.
Cache once at module load:
const reducedMotionMql = (typeof window !== 'undefined' && window.matchMedia)
? window.matchMedia('(prefers-reduced-motion: reduce)')
: null;
function reducedMotion() { return !!(reducedMotionMql && reducedMotionMql.matches); }Zero allocations on the post-gate path, same observable behavior (MQL .matches updates live as the OS preference changes — that was already the case here).
Out of scope (noting, not blocking)
Array.prototype.slice.call(qsa(...))in$all— modernNodeListhas.forEachdirectly; the slice is a per-call array allocation. 4 calls per ping × 15/sec is ~60/sec — measurable on a profiler but not the cliff.will-change: filter, transformpermanently on the four circles promotes a GPU layer per circle for the entire app lifetime. The circles are 13px radius so the layer cost is small; mention it in case a future change makes the SVG more complex.- The
setTimeouthalf-1 +rAFhalf-2 asymmetry is intentional (the rAF gives a pixel-aligned start to half-2) — fine.
Once M1 + M2 land, this is a clean, allocation-discipline-correct hot path.
Adds two failing E2E steps before fixing Carmack's must-fix items:
1. hidden tab: pulse() must return false (without bumping lastPingTs) and
toggle no .logo-pulse-active / .logo-pulse-blip class. Currently fails
because Logo.pulse() schedules the chained pulse regardless of
document.hidden — wasting rAF/setTimeout work on background tabs.
2. matchMedia: window.matchMedia('(prefers-reduced-motion: reduce)') is
currently invoked on EVERY pulse() call. Test wraps window.matchMedia
to count invocations, fires 100 pulses, and asserts the counter does
NOT increment per pulse. The MQL must be cached at module load.
Per AGENTS.md: red commit MUST run to assertion failure (not import
error). Verified locally: 8 passed, 2 failed — assertion shape.
Carmack expert review (PR #1177) flagged two must-fix items: 1. Hidden-tab pulse storm: Logo.pulse() previously scheduled the chained-pulse halves (rAF + setTimeout) on every WS message, even when document.hidden. Background tabs throttle timers but we still queued the work; on focus it fired in a clump. Now pulse() early- returns false when document.hidden — BEFORE updating lastPingTs and BEFORE the rAF/setTimeout chain — and a visibilitychange listener bumps generation + clears pulse classes when the tab hides, so any half already in flight bails before painting. 2. matchMedia per pulse: reducedMotion() called window.matchMedia on every pulse() invocation (~15Hz steady-state), allocating an MQL and re-parsing the query string each time. Now cached once at module load into _reducedMotionMQL; reducedMotion() reads .matches on the singleton. The CSS @media rule still handles render-time switching, so no change-listener subscription is needed. E2E: 10/10 pass (was 8/10 on the RED commit).
|
Carmack must-fix items addressed. ✅ 1. Hidden-tab pulse stormFile:
Test:
✅ 2. matchMedia per pulseFile:
Test: Commits
Preflight
HEAD: |
app.js:1352 unconditionally redirects '#/' to '#/home' on load. The logo-theme E2E waits on location.hash === '#/' after navigation, which the redirect immediately overrides. Pre-existing timing bug surfaced by PR #1173's WS handler additions slightly shifting the event loop. Accept either '#/' or '#/home' so the wait resolves on the first frame the navigation lands. Pure test fix; production behavior unchanged.
PR #1177 patched 3 of 5 'location.hash === #/home' wait sites to also accept '#/' (transient state during app.js redirect). The other 2 sites on lines 135 + 268 still race the redirect and time out under heavy CI load. Apply the same tolerance everywhere.
PR #1177 patched 3 of 5 'location.hash === #/home' wait sites to also accept '#/' (transient state during app.js redirect). The other 2 sites on lines 135 + 268 still race the redirect and time out under heavy CI load. Apply the same tolerance everywhere.
Red commit: PENDING (will update)
Fixes #1173.
Replaces the
#liveDotWebSocket-connected indicator with a packet-driven node-pulse animation on the brand logo's two inner circles.Behavior (locked per issue spec)
ease-out(default per open-question 1)..logo-disconnectedappliesfilter: grayscale(0.6) opacity(0.7).prefers-reduced-motion: reduce: single-step.logo-pulse-blipon destination only.Implementation
public/app.jsconnectWS()(ws.onmessagetriggersLogo.pulse();ws.onopen/ws.onclosetoggleLogo.setConnected()).Logois a small IIFE inapp.jsthat exposeswindow.__corescopeLogofor E2E injection..logo-pulse-active/.logo-pulse-blip/.logo-disconnected. Colors come exclusively from--logo-accent/--logo-accent-hitokens..logo-node-a,.logo-node-b) attached to inner circles in both.brand-logoand.brand-mark-onlySVGs so the mobile mark animates too.#liveDotremoval proofE2E
test-logo-pulse-1173-e2e.js:54and follows..github/workflows/deploy.yml(mirrors PR fix(#1056): row-detail slide-over panel at narrow widths (AC #4) #1168 pattern from commit5442652).window.__corescopeLogo.pulse({ synthetic: true }); matches the existing harness style (no new WS-mock pattern invented).Red→green discipline preserved: the test commit lands first and CI fails on assertion; the implementation commit follows.