Skip to content

fix(#1173): replace #liveDot with packet-driven brand-logo node-pulse#1177

Merged
Kpa-clawbot merged 10 commits intomasterfrom
fix/issue-1173-logo-pulse
May 9, 2026
Merged

fix(#1173): replace #liveDot with packet-driven brand-logo node-pulse#1177
Kpa-clawbot merged 10 commits intomasterfrom
fix/issue-1173-logo-pulse

Conversation

@Kpa-clawbot
Copy link
Copy Markdown
Owner

Red commit: PENDING (will update)

Fixes #1173.

Replaces the #liveDot WebSocket-connected indicator with a packet-driven node-pulse animation on the brand logo's two inner circles.

Behavior (locked per issue spec)

  • Animation curve: ease-out (default per open-question 1).
  • Rate cap: 15/sec (66ms gap; default per open-question 2). Excess triggers are dropped, never queued.
  • Direction: alternates A→B / B→A across messages (aesthetic, not semantic).
  • Idle ≥10s: logo at full brightness, no animation.
  • Disconnected: .logo-disconnected applies filter: grayscale(0.6) opacity(0.7).
  • prefers-reduced-motion: reduce: single-step .logo-pulse-blip on destination only.

Implementation

  • WS handler hook lives in public/app.js connectWS() (ws.onmessage triggers Logo.pulse(); ws.onopen/ws.onclose toggle Logo.setConnected()).
  • Logo is a small IIFE in app.js that exposes window.__corescopeLogo for E2E injection.
  • All animation is pure CSS; JS only toggles .logo-pulse-active / .logo-pulse-blip / .logo-disconnected. Colors come exclusively from --logo-accent / --logo-accent-hi tokens.
  • Two new classes (.logo-node-a, .logo-node-b) attached to inner circles in both .brand-logo and .brand-mark-only SVGs so the mobile mark animates too.

#liveDot removal proof

$ grep -rn liveDot public/
(no output)

E2E

  • E2E assertion added: test-logo-pulse-1173-e2e.js:54 and follows.
  • Wired into the Playwright matrix in .github/workflows/deploy.yml (mirrors PR fix(#1056): row-detail slide-over panel at narrow widths (AC #4) #1168 pattern from commit 5442652).
  • Test injects synthetic pings via 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.

Kpa-clawbot added 2 commits May 8, 2026 21:46
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.
@Kpa-clawbot Kpa-clawbot marked this pull request as ready for review May 8, 2026 21:51
corescope-bot added 5 commits May 8, 2026 22:31
…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).
Copy link
Copy Markdown
Owner Author

@Kpa-clawbot Kpa-clawbot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: lastPingTs is updated only after the gap check passes; drops increment stats.dropped. Burst-resistant.
  • Alternation: aToB = (flip === 0) captured before flip ^= 1. Strict A,B,A,B sequence.
  • Generation counter: bumped in every setConnected() call; checked in both the source-half timer AND inside requestAnimationFrame before destination toggle. Zombie pulse cannot fight .logo-disconnected.
  • connected re-check after rAF closes the second race window. Solid.
  • try { window.__corescopeLogo = api } guards SSR/jsdom; Object.freeze keeps the seam read-only — correct call.

#liveDot removal — clean

  • grep -rn 'liveDot\|live-dot' public/ test*.js against the head ref:
    • Production: zero #liveDot references. Remaining .live-dot hits are in live.js legend bullets and .vcr-live-dot — pre-existing, unrelated, intended (CSS comment in style.css calls this out explicitly so a global no-op rule isn't shipped).
    • test-pull-to-reconnect*.js still call makeEl('liveDot') in their vm sandbox stubs. Now dead (app.js no longer touches getElementById('liveDot')) but harmless — not coverage-weakening.

CSS

  • logo-pulse-step keyframes mutate only filter + transform. logo-pulse-blip-step mutates only opacity. .logo-disconnected is filter: grayscale(0.6) opacity(0.7). No hardcoded colors anywhere; circle fills resolve via --logo-accent / --logo-accent-hi tokens (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 to pulseBlip() (destination opacity blip only).
  • CSS: @media (prefers-reduced-motion: reduce) neutralizes .logo-pulse-active animation: none defensively.
  • E2E (f) runs in a reducedMotion: 'reduce' context and asserts blip-only path.

Orphan-test patches

  • test-e2e-playwright.js (Nodes WS test): replaces #liveDot lookup with .brand-logo + window.__corescopeLogo.setConnected seam assertions. Coverage strengthened (now asserts the public seam exists).
  • test-logo-rebrand-e2e.js step 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 1fa2794 is on the branch ahead of green ba644a3), 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.

Copy link
Copy Markdown
Owner Author

@Kpa-clawbot Kpa-clawbot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)rAFsetTimeout(80) chain.

On a hidden tab Chrome:

  • clamps setTimeout to ~1 Hz, and
  • does not invoke rAF callbacks 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 — modern NodeList has .forEach directly; 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, transform permanently 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 setTimeout half-1 + rAF half-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.

corescope-bot added 2 commits May 9, 2026 00:38
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).
@Kpa-clawbot
Copy link
Copy Markdown
Owner Author

Carmack must-fix items addressed.

✅ 1. Hidden-tab pulse storm

File: public/app.js:539-547 (early return in pulse()) + public/app.js:584-599 (visibilitychange listener)

pulse() now returns false BEFORE updating lastPingTs and BEFORE the rAF/setTimeout chain when document.hidden === true. A visibilitychange listener bumps the generation counter and calls clearAll() when the tab hides — any half already mid-flight bails before painting (the existing generation guard inside pulseChained() was already there per your note).

Test: test-logo-pulse-1173-e2e.js — new step hidden tab: pulse() returns false and toggles no classes mocks document.hidden = true, fires a synthetic pulse, and asserts:

  • pulse() returns false
  • neither .logo-pulse-active nor .logo-pulse-blip toggles on either circle
  • stats.triggered did not increment

✅ 2. matchMedia per pulse

File: public/app.js:484-491 (module-load cache) + public/app.js:497-499 (read singleton)

_reducedMotionMQL is now allocated once at module load via window.matchMedia('(prefers-reduced-motion: reduce)'). reducedMotion() just reads .matches on the cached MQL. No addEventListener('change', …) subscription — the existing CSS @media rule already handles render-time switching, so we just cache and read.

Test: test-logo-pulse-1173-e2e.js — new step matchMedia: cached singleton — 100 pulses do not call window.matchMedia per pulse wraps window.matchMedia via page.addInitScript (counter installed BEFORE app load), fires 100 pulses, and asserts the counter delta is 0 during the burst.

Commits

  • RED: 2c0daaftest(#1177): RED — hidden-tab gate + matchMedia caching (verified locally: 8 passed, 2 failed, assertion-shaped)
  • GREEN: 56d48b9fix(#1177): GREEN — hidden-tab pulse gate + cached matchMedia MQL (10/10 pass)

Preflight

bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master → clean (all 7 hard gates + 3 warnings pass).

HEAD: 56d48b9

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.
@Kpa-clawbot Kpa-clawbot merged commit 05876b3 into master May 9, 2026
6 checks passed
@Kpa-clawbot Kpa-clawbot deleted the fix/issue-1173-logo-pulse branch May 9, 2026 03:25
Kpa-clawbot pushed a commit that referenced this pull request May 10, 2026
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.
Kpa-clawbot pushed a commit that referenced this pull request May 10, 2026
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.
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.

feat(logo): replace WS connected indicator with packet-driven logo node-pulse animation

1 participant