Skip to content

Hub state-driven micro-interactions — epic #499 child 2 (#516) - #520

Merged
richard-devbot merged 2 commits into
mainfrom
claude/micro-interactions-516
Jul 31, 2026
Merged

Hub state-driven micro-interactions — epic #499 child 2 (#516)#520
richard-devbot merged 2 commits into
mainfrom
claude/micro-interactions-516

Conversation

@richard-devbot

@richard-devbot richard-devbot commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Closes #516 · second implementation child of epic #499, on the #495 morph + #512 tokens.

What moves, and only when

Surface Trigger Mechanism
Status pills/chips a real state flip (the morph patches the class on a stable node) pure CSS transition at the fast tier
Feed rows a genuinely-new event arriving (keyed data-key identity — existing rows are reused, never re-animated) @starting-style entry (graceful no-op on engines without it)
KPI numbers the value actually changing animateCount rAF tick at the base tier
First load the no-snapshot-yet state quiet skeleton shimmer, replaced by the first render

The honest bits

  • Reduced motion: CSS surfaces are covered by the pinned global guard (Hub motion tokens + global reduced-motion policy (epic #499, child 1) #512); the JS count-up checks matchMedia itself and snaps instantly.
  • Hidden surfaces: found live during verification — an occluded pane throttles rAF to zero, which would have stranded stale numbers forever. animateCount now sets instantly when document.visibilityState !== 'visible' and carries a settle timeout that lands the exact final value even if rAF freezes mid-animation.
  • Test determinism: the mutation-style browser tests neutralize the live snapshot handler before injecting state — server truth arriving mid-assertion is correct system behavior (it must win), so the tests hold it back rather than race it.

TDD

Six source-contract pins verified failing first; five browser journeys on the shared #515 harness (pill wiring, keyed identity across quiet ticks AND arrivals, reduced-motion snap, motion-on settle, route-held skeleton lifecycle).

Verification

Core 1825/1825 · browser 19/19 (all suites on the shared harness) · lint 0 · typecheck 0 · validate 196 · security green · live-verified on the fixture-seeded hub (pill transitions at 0.16s, keyed rows, count-up settling on target with ticks held).

Zero-dep; the motion-package and Rive decisions remain open for later children.

Merging after green checks + reviewer bodies read, per the current working protocol.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Sidebar and KPI counters now smoothly animate to new values instead of updating instantly
    • Added skeleton loading placeholders to dashboard panels while data loads
  • Style

    • Introduced smooth transitions for status pills and feed item updates with animation keyframes
  • Tests

    • Added comprehensive browser and integration tests for dashboard interactions and animations

Zero-dep motion on the two foundations: the #495 morph patches attribute
flips on STABLE nodes (so a class change is an animatable property change)
and every duration routes through the #512 tokens.

- Status pills transition background/color/border at the fast tier — a
  task flipping PASS→FAIL visibly changes instead of teleporting; the
  morph's attribute patch IS the trigger, no JS.
- Feed rows carry keyed identity (data-key from event identity), so the
  morph reuses existing rows and inserts only genuinely-new events as new
  nodes; @starting-style animates the arrival (engines without it simply
  skip the entry animation). Pinned: existing rows keep identity across
  quiet ticks AND across a new event's arrival.
- KPI numbers count up through animateCount: only when the value actually
  changed, instant under prefers-reduced-motion (JS motion checks the
  media query itself), instant on hidden surfaces (throttled rAF must
  never strand a stale number — found live in an occluded pane during
  verification), a settle timeout lands the exact final value even if rAF
  freezes mid-animation, and a newer value cancels the in-flight count.
  Wired to the sidebar mini rollup and the command KPI grid.
- First-load placeholders are quiet skeleton shimmers in the command
  attention/feed panels — bound to the no-snapshot-yet state, replaced by
  the first render, frozen by the reduced-motion guard per the motion
  budget.

Browser-pinned on the shared #515 harness: pill transition wiring, keyed
feed identity across ticks and arrivals, reduced-motion snap, motion-on
settle to the exact target (live ticks held via a neutralized snapshot
handler so injected state can't race server truth), and skeletons via a
route-held first snapshot. 1825/0 core, 19/19 browser.

Closes #516. Part of epic #499.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@strix-security

strix-security Bot commented Jul 31, 2026

Copy link
Copy Markdown

Strix Security Review

No security issues found.

Updated for 2572f24.


Reviewed by Strix
Re-run review · Configure security review settings

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The dashboard now animates KPI changes, preserves keyed feed rows during updates, transitions state changes, and displays skeleton placeholders until initial data loads. Reduced-motion handling applies to JavaScript count-ups and CSS motion.

Changes

Dashboard micro-interactions

Layer / File(s) Summary
KPI count-up flow
src/observability/dashboard/ui/lib.js, src/observability/dashboard/ui/client.js, src/observability/dashboard/ui/pages/command-center.js
Adds animateCount with easing, cancellation, visibility checks, reduced-motion handling, and a settle guard. Sidebar and Command Center KPI values use animated rendering.
Keyed feed-row motion
src/observability/dashboard/ui/lib.js, src/observability/dashboard/ui/styles.js
Adds feed-row keys and CSS transitions for pills, feed rows, and newly inserted events.
Loading states and interaction validation
src/observability/dashboard/ui/pages/index.js, tests/browser/dashboard-516-micro.test.js, tests/hub-micro-interactions-516.test.js
Adds skeleton blocks for initial panel loading and tests count-up behavior, keyed rows, transitions, and skeleton replacement.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Snapshot
  participant DashboardPage
  participant animateCount
  participant KPIElement
  Snapshot->>DashboardPage: deliver updated KPI values
  DashboardPage->>animateCount: animate changed values
  animateCount->>KPIElement: update displayed count
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: richardsongunde

Poem

A rabbit counts the numbers bright,
Keys keep each feed row snug and right.
Pills now glide, new rows appear,
Skeletons wait till data’s here.
Reduced motion keeps things light.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the hub micro-interactions change and links it to issue #516.
Linked Issues check ✅ Passed The changes implement the linked issue objectives for transitions, keyed feed rows, KPI count-up, reduced motion, and skeleton loading states [#516].
Out of Scope Changes check ✅ Passed The changes and tests remain within the linked issue scope for zero-dependency, state-driven hub micro-interactions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/micro-interactions-516

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Dashboard micro-interactions: KPI count-up, keyed feed entries, skeleton first-load

✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Animate KPI numbers only when values change, respecting reduced-motion and hidden tabs.
• Key feed rows so only genuinely-new events animate on insertion via CSS starting styles.
• Add skeleton placeholders for first-load, plus source-contract and browser-journey tests.
Diagram

graph TD
  A["Dashboard renderers"] --> B["lib.js animateCount"] --> F(("KPI DOM"))
  A --> C["lib.js feedRowHtml"] --> G(("Feed row DOM"))
  D["styles.js motion CSS"] --> F
  D --> G
  E["pages/index.js shell"] --> H(("Skeleton DOM"))
  I["#516 tests"] --> A --> D
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Backend-provided event IDs for feed keys
  • ➕ Avoids composite-key collisions (same ts/type/summary/runId).
  • ➕ Improves stability if formatting/summary text changes.
  • ➖ Requires API/schema change and migration logic.
  • ➖ Less self-contained than deriving a key on the client.
2. Web Animations API for KPI numbers
  • ➕ More explicit lifecycle control than rAF + timeouts.
  • ➕ Can unify motion timing/easing with other animated properties.
  • ➖ Still needs reduced-motion + visibility handling.
  • ➖ More code/complexity than a small rAF helper for integers.
3. Adopt a dedicated motion library (future epic child)
  • ➕ Centralizes motion patterns and accessibility defaults.
  • ➕ Could standardize FLIP/list entry animations across surfaces.
  • ➖ Adds dependency surface area and bundle cost.
  • ➖ Overkill for the current limited micro-interaction scope.

Recommendation: The PR’s zero-dependency approach is appropriate for this scope: CSS transitions/@starting-style for structural UI changes and a small rAF helper for numeric-only motion, with explicit reduced-motion and visibility safeguards. The main follow-up to consider (if/when the API can support it) is switching feed row keys to a true server event id to eliminate composite-key collision risk.

Files changed (7) +244 / -12

Enhancement (5) +80 / -12
client.jsAnimate sidebar KPI mini-rollup counts +3/-3

Animate sidebar KPI mini-rollup counts

• Routes sidebar KPI updates (runs, pass count, agent count) through animateCount instead of setText. This makes numeric changes tick only when values actually change.

src/observability/dashboard/ui/client.js

lib.jsAdd animateCount helper and key feed rows +49/-1

Add animateCount helper and key feed rows

• Introduces animateCount with cancellation, reduced-motion handling, and a visibility guard to avoid rAF-throttling leaving stale numbers. Adds a settle timeout to ensure the final value lands even if animation freezes. Updates feedRowHtml to emit a data-key derived from event identity to support DOM reuse.

src/observability/dashboard/ui/lib.js

command-center.jsAnimate command-center KPI grid counts +6/-6

Animate command-center KPI grid counts

• Switches command-center KPIs (projects, runs, pass/progress/pending/evidence) to use animateCount. Keeps descriptive KPI subtitles as static text.

src/observability/dashboard/ui/pages/command-center.js

index.jsSeed skeleton placeholders for first load +2/-2

Seed skeleton placeholders for first load

• Adds skeleton-block placeholders into the command attention and feed panels’ initial markup. These placeholders are intended to be visible only before the first state snapshot renders real content.

src/observability/dashboard/ui/pages/index.js

styles.jsToken-driven transitions, feed entry animation, and skeleton shimmer +20/-0

Token-driven transitions, feed entry animation, and skeleton shimmer

• Adds fast-tier transitions to .pill so status flips animate via class changes on stable nodes. Adds opacity/transform transitions to .feed-row and a @starting-style block for entry animation on newly inserted nodes. Introduces skeleton-block shimmer styling and keyframes for the pre-snapshot UI state.

src/observability/dashboard/ui/styles.js

Tests (2) +164 / -0
dashboard-516-micro.test.jsBrowser journeys for pill transitions, keyed feeds, KPI count-up, and skeleton lifecycle +104/-0

Browser journeys for pill transitions, keyed feeds, KPI count-up, and skeleton lifecycle

• Adds Playwright-based tests validating token transition wiring, keyed feed row identity across quiet ticks and new arrivals, reduced-motion snapping for KPI count-up, motion-enabled settling, and skeleton placeholders appearing only pre-snapshot. Neutralizes live snapshot handling in tests that inject state to avoid correctness races.

tests/browser/dashboard-516-micro.test.js

hub-micro-interactions-516.test.jsSource-contract pins for zero-dep motion layer +60/-0

Source-contract pins for zero-dep motion layer

• Adds contract tests asserting presence of pill/feed transitions, @starting-style usage, data-keyed feed rows, animateCount reduced-motion/visibility/cancellation safeguards, and skeleton markup/styles. Uses regex matching over generated/bundled scripts and styles to keep these behaviors pinned.

tests/hub-micro-interactions-516.test.js

@qodo-code-review

qodo-code-review Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (5)

Context used
✅ Compliance rules (platform): 300 rules
✅ Skills: 17 invoked
  code-review-pr
  claude-api
  documentation-writing
  pptx
  docx
  performance-monitoring
  security-compliance
  cso
  plan-eng-review
  design-review
  prompt-engineering
  mcp-builder
  qa-testing
  code-patterns
  xlsx
  security-owasp
  testing-qa

Grey Divider


Remediation recommended

1. runId.slice(-14) magic length 📜 Skill insight ⚙ Maintainability
Description
The feed row rendering hardcodes the tail length 14 when truncating runId, which is a magic
number embedded in formatting logic. This violates the requirement to replace such literals with
named constants for clarity and consistency.
Code

src/observability/dashboard/ui/lib.js[331]

+  return '<div class="feed-row" data-key="' + esc(feedKey) + '"><div class="feed-icon ' + esc(level) + '">' + icon + '</div><div><div class="feed-summary">' + esc(item.summary || '') + '</div><div class="feed-meta">' + (item.runId ? '<span>' + esc(item.runId.slice(-14)) + '</span>' : '') + (item.projectRoot ? '<span>' + esc(shortName(item.projectRoot)) + '</span>' : '') + (item.type ? '<span>' + esc(item.type) + '</span>' : '') + '</div></div><div class="feed-ts">' + timeHtml(item.ts) + '</div></div>';
Relevance

●●● Strong

Repo has accepted replacing UI truncation/formatting magic numbers with named constants for clarity.

PR-#511

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400588 requires numeric literals used in formatting/calculations to be extracted
into named constants. The updated feedRowHtml return path contains item.runId.slice(-14) with no
named constant explaining why 14 characters are shown.

src/observability/dashboard/ui/lib.js[328-332]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`feedRowHtml()` truncates `runId` with `slice(-14)`, embedding `14` as a magic number. Replace it with a named constant (e.g., `RUN_ID_TAIL_CHARS`) to document intent.

## Issue Context
This line was modified in the PR (new keyed identity + re-rendered return string), so it is in-scope for compliance.

## Fix Focus Areas
- src/observability/dashboard/ui/lib.js[325-332]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. animateCount uses + 120 ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The settle timeout buffer 120 is a magic number in a timing calculation, reducing maintainability
and making future tuning/error-prone edits harder. The rule requires extracting such literals into
named constants.
Code

src/observability/dashboard/ui/lib.js[R150-152]

+  // Settle guard: if rAF freezes mid-animation (tab hidden), the exact final
+  // value still lands on time.
+  el.__rsCountSettle = setTimeout(finish, COUNT_UP_MS + 120);
Relevance

●●● Strong

Team previously accepted extracting numeric caps into named constants; same maintainability pattern
for time buffers.

PR-#511
PR-#509

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400588 requires replacing literal numeric values used in timeouts/calculations
with named constants. In animateCount, the settle guard adds an unexplained 120 ms buffer
directly in the timeout expression.

src/observability/dashboard/ui/lib.js[150-153]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`animateCount()` uses a magic number (`120`) as a settle-time buffer in `setTimeout(finish, COUNT_UP_MS + 120)`. This should be replaced with a clearly named constant to document intent and simplify tuning.

## Issue Context
The PR already introduces `COUNT_UP_MS` as a named duration constant, but the additional buffer remains a literal.

## Fix Focus Areas
- src/observability/dashboard/ui/lib.js[110-153]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Unstable feed row keys 🐞 Bug ≡ Correctness
Description
feedRowHtml() includes item.summary in the data-key, so rows whose summary text changes across
snapshots are treated as different keyed nodes and get replaced/re-animated. This breaks identity
preservation for synthesized feed entries like tool_burst where ts/type/runId stay constant but the
displayed count (and summary) changes.
Code

src/observability/dashboard/ui/lib.js[R328-331]

+  // #516: keyed identity — the morph reuses existing rows and inserts only
+  // genuinely-new events as new nodes (which @starting-style animates in).
+  var feedKey = [item.ts, item.type, item.runId, item.summary].filter(Boolean).join('|');
+  return '<div class="feed-row" data-key="' + esc(feedKey) + '"><div class="feed-icon ' + esc(level) + '">' + icon + '</div><div><div class="feed-summary">' + esc(item.summary || '') + '</div><div class="feed-meta">' + (item.runId ? '<span>' + esc(item.runId.slice(-14)) + '</span>' : '') + (item.projectRoot ? '<span>' + esc(shortName(item.projectRoot)) + '</span>' : '') + (item.type ? '<span>' + esc(item.type) + '</span>' : '') + '</div></div><div class="feed-ts">' + timeHtml(item.ts) + '</div></div>';
Relevance

●●● Strong

Keyed morph identity is a pinned, enforced contract; unstable keys break reuse/animation behavior.

PR-#504

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new key includes item.summary, but the state layer synthesizes tool_burst entries whose
summary includes a variable count; this guarantees the key will change even when the logical feed
item (minute bucket) is the same. Since morphChildren uses data-key to claim and reuse existing
DOM nodes, a changing key forces replacement rather than reuse.

src/observability/dashboard/ui/lib.js[325-332]
src/observability/dashboard/state/feed.js[14-40]
src/observability/dashboard/ui/lib.js[249-294]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`feedRowHtml()` builds `data-key` from `[item.ts, item.type, item.runId, item.summary]`. Because `summary` is presentation text (and can change while the underlying feed item identity is the same), keyed morphing will replace existing rows instead of reusing them.

## Issue Context
The activity feed includes synthesized `tool_burst` items where `ts` is pinned to the minute bucket and `summary` embeds a count that can increase as more tool calls arrive that minute. With `summary` in the key, the same logical row will get a different key on the next snapshot, so the morph can’t match/reuse it.

## Fix Focus Areas
- src/observability/dashboard/ui/lib.js[325-332]
- src/observability/dashboard/state/feed.js[14-40]
- src/observability/dashboard/ui/lib.js[249-294]

## Suggested change
- Remove `item.summary` from the key.
- Prefer a key composed only of stable identity fields (e.g., `ts|type|runId|projectRoot`), and use a null/undefined filter (not `Boolean`) so legitimate falsy values like `0` aren’t dropped.
- If collisions are still possible, extend the state model to provide a dedicated stable per-event identifier (e.g., a sequence number when reading `events.jsonl`) and key by that instead.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. skeleton-shimmer animates background-position 📜 Skill insight ➹ Performance
Description
The new skeleton shimmer animation for .skeleton-block animates background-position, which
violates the compliance requirement that CSS animations/transitions only animate transform and/or
opacity. This introduces a disallowed animation property and can cause unnecessary paint work
during loading states.
Code

src/observability/dashboard/ui/styles.js[R1017-1025]

+.skeleton-block {
+  height: 14px;
+  border-radius: 6px;
+  background: linear-gradient(90deg, var(--soft) 25%, var(--line) 50%, var(--soft) 75%);
+  background-size: 200% 100%;
+  animation: skeleton-shimmer 1.2s var(--motion-ease) infinite;
+  margin: 10px 0;
+}
+@keyframes skeleton-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
Relevance

●● Moderate

Motion-property compliance seems disputed; similar “painty” transition enforcement was rejected, but
this adds new animation.

PR-#514

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance IDs 1400011 and 1399699 restrict CSS animations to only transform and opacity. The
cited skeleton shimmer implementation defines keyframes that animate background-position, and
.skeleton-block applies that animation, making the loading UI non-compliant because
background-position is neither transform nor opacity.

src/observability/dashboard/ui/styles.js[1017-1025]
Skill: performance-monitoring
Skill: design-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The skeleton placeholder shimmer currently animates `background-position` via `@keyframes`, but compliance rules require animations/transitions to be limited to `transform` and/or `opacity`.

## Issue Context
Skeleton placeholders were added for the "no snapshot yet" first-load state in #516, and the `.skeleton-block` shimmer is used as a first-load placeholder animation. To stay compliant while preserving a shimmer-like effect, prefer patterns such as using a pseudo-element with a gradient and animating its `transform: translateX(...)` across the block (or switching to an opacity pulse animation).

## Fix Focus Areas
- src/observability/dashboard/ui/styles.js[1017-1025]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. skeleton-block uses 10px margin 📜 Skill insight ⚙ Maintainability
Description
The new .skeleton-block introduces margin: 10px 0, which does not follow a consistent 4px/8px
spacing scale. This can erode design-system consistency over time.
Code

src/observability/dashboard/ui/styles.js[1023]

+  margin: 10px 0;
Relevance

●● Moderate

No clear spacing-scale precedent found in this area; could be seen as subjective design-system nit.

PR-#514

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400413 requires spacing values (e.g., margin) to follow a consistent 4px/8px
scale; 10px does not conform to that scale.

src/observability/dashboard/ui/styles.js[1023-1023]
Skill: design-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new spacing value `margin: 10px 0` was introduced, which violates the project's consistent 4px/8px spacing scale requirement.

## Issue Context
This margin is part of the new skeleton placeholder styling added in #516.

## Fix Focus Areas
- src/observability/dashboard/ui/styles.js[1023-1023]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Useless assert.ok(true) assertion 📜 Skill insight ▣ Testability
Description
The new browser test includes assert.ok(true, ...), which does not verify any behavior and can
mask regressions by always passing. Assertions should validate specific outputs or state changes
rather than mere existence/truthiness placeholders.
Code

tests/browser/dashboard-516-micro.test.js[R88-90]

+  await page.waitForFunction(() => document.getElementById('side-runs').textContent === '37', null, { timeout: 3000 });
+  assert.ok(true, 'the count settles on the exact target');
+});
Relevance

●● Moderate

Test assertion-strengthening feedback has been rejected before; this is more extreme (no-op) but
acceptance uncertain.

PR-#503

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1399609 disallows assertions that only check existence/truthiness instead of actual
behavior. The added assert.ok(true, ...) is a no-op assertion that always passes.

tests/browser/dashboard-516-micro.test.js[88-90]
Skill: qa-testing

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A test assertion always passes (`assert.ok(true, ...)`) and does not check behavior.

## Issue Context
The test already waits for the DOM to reach the expected value; the final assertion should validate the observed value (e.g., read `#side-runs` and assert it equals `37`) or be removed in favor of a meaningful assertion.

## Fix Focus Areas
- tests/browser/dashboard-516-micro.test.js[88-90]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

7. pill transitions non-transform properties 📜 Skill insight ➹ Performance
Description
The .pill style applies a transition that animates background, color, and border-color,
violating the requirement that CSS animations/transitions only animate transform and/or opacity.
Animating paint-heavy properties can degrade performance and is disallowed by the micro-interactions
checklist.
Code

src/observability/dashboard/ui/styles.js[R955-958]

.pill {
+  /* #516: the morph patches a status flip on the SAME node, so this
+     transition IS the state-change animation — no JS involved. */
+  transition: background var(--motion-fast) var(--motion-ease), color var(--motion-fast) var(--motion-ease), border-color var(--motion-fast) var(--motion-ease);
Relevance

● Weak

Close precedent: request to remove non-transform/opacity transitions in dashboard CSS was explicitly
rejected.

PR-#514

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance IDs 1400011 and 1399699 restrict CSS animations/transitions to transform and
opacity only; the cited .pill rule explicitly defines a transition that targets background,
color, and border-color, demonstrating that the change is animating non-compliant paint
properties.

src/observability/dashboard/ui/styles.js[955-958]
src/observability/dashboard/ui/styles.js[956-958]
Skill: performance-monitoring
Skill: design-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`.pill` uses `transition` on `background`, `color`, and `border-color`, but compliance requires CSS animations/transitions to be limited to `transform` and `opacity` only.

## Issue Context
This change was introduced for #516 micro-interactions and adds state-driven visual changes where the pill “state flip” is implemented via a CSS transition. To comply, the visual effect should be re-expressed using `opacity`/`transform` (for example, cross-fading between two layers) or made instantaneous instead of transitioned.

## Fix Focus Areas
- src/observability/dashboard/ui/styles.js[955-958]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +88 to +90
await page.waitForFunction(() => document.getElementById('side-runs').textContent === '37', null, { timeout: 3000 });
assert.ok(true, 'the count settles on the exact target');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Useless assert.ok(true) assertion 📜 Skill insight ▣ Testability

The new browser test includes assert.ok(true, ...), which does not verify any behavior and can
mask regressions by always passing. Assertions should validate specific outputs or state changes
rather than mere existence/truthiness placeholders.
Agent Prompt
## Issue description
A test assertion always passes (`assert.ok(true, ...)`) and does not check behavior.

## Issue Context
The test already waits for the DOM to reach the expected value; the final assertion should validate the observed value (e.g., read `#side-runs` and assert it equals `37`) or be removed in favor of a meaningful assertion.

## Fix Focus Areas
- tests/browser/dashboard-516-micro.test.js[88-90]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +1017 to +1025
.skeleton-block {
height: 14px;
border-radius: 6px;
background: linear-gradient(90deg, var(--soft) 25%, var(--line) 50%, var(--soft) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.2s var(--motion-ease) infinite;
margin: 10px 0;
}
@keyframes skeleton-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

3. skeleton-shimmer animates background-position 📜 Skill insight ➹ Performance

The new skeleton shimmer animation for .skeleton-block animates background-position, which
violates the compliance requirement that CSS animations/transitions only animate transform and/or
opacity. This introduces a disallowed animation property and can cause unnecessary paint work
during loading states.
Agent Prompt
## Issue description
The skeleton placeholder shimmer currently animates `background-position` via `@keyframes`, but compliance rules require animations/transitions to be limited to `transform` and/or `opacity`.

## Issue Context
Skeleton placeholders were added for the "no snapshot yet" first-load state in #516, and the `.skeleton-block` shimmer is used as a first-load placeholder animation. To stay compliant while preserving a shimmer-like effect, prefer patterns such as using a pseudo-element with a gradient and animating its `transform: translateX(...)` across the block (or switching to an opacity pulse animation).

## Fix Focus Areas
- src/observability/dashboard/ui/styles.js[1017-1025]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

background: linear-gradient(90deg, var(--soft) 25%, var(--line) 50%, var(--soft) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.2s var(--motion-ease) infinite;
margin: 10px 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

4. skeleton-block uses 10px margin 📜 Skill insight ⚙ Maintainability

The new .skeleton-block introduces margin: 10px 0, which does not follow a consistent 4px/8px
spacing scale. This can erode design-system consistency over time.
Agent Prompt
## Issue description
A new spacing value `margin: 10px 0` was introduced, which violates the project's consistent 4px/8px spacing scale requirement.

## Issue Context
This margin is part of the new skeleton placeholder styling added in #516.

## Fix Focus Areas
- src/observability/dashboard/ui/styles.js[1023-1023]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/observability/dashboard/ui/lib.js Outdated
// #516: keyed identity — the morph reuses existing rows and inserts only
// genuinely-new events as new nodes (which @starting-style animates in).
var feedKey = [item.ts, item.type, item.runId, item.summary].filter(Boolean).join('|');
return '<div class="feed-row" data-key="' + esc(feedKey) + '"><div class="feed-icon ' + esc(level) + '">' + icon + '</div><div><div class="feed-summary">' + esc(item.summary || '') + '</div><div class="feed-meta">' + (item.runId ? '<span>' + esc(item.runId.slice(-14)) + '</span>' : '') + (item.projectRoot ? '<span>' + esc(shortName(item.projectRoot)) + '</span>' : '') + (item.type ? '<span>' + esc(item.type) + '</span>' : '') + '</div></div><div class="feed-ts">' + timeHtml(item.ts) + '</div></div>';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

6. runid.slice(-14) magic length 📜 Skill insight ⚙ Maintainability

The feed row rendering hardcodes the tail length 14 when truncating runId, which is a magic
number embedded in formatting logic. This violates the requirement to replace such literals with
named constants for clarity and consistency.
Agent Prompt
## Issue description
`feedRowHtml()` truncates `runId` with `slice(-14)`, embedding `14` as a magic number. Replace it with a named constant (e.g., `RUN_ID_TAIL_CHARS`) to document intent.

## Issue Context
This line was modified in the PR (new keyed identity + re-rendered return string), so it is in-scope for compliance.

## Fix Focus Areas
- src/observability/dashboard/ui/lib.js[325-332]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +328 to +331
// #516: keyed identity — the morph reuses existing rows and inserts only
// genuinely-new events as new nodes (which @starting-style animates in).
var feedKey = [item.ts, item.type, item.runId, item.summary].filter(Boolean).join('|');
return '<div class="feed-row" data-key="' + esc(feedKey) + '"><div class="feed-icon ' + esc(level) + '">' + icon + '</div><div><div class="feed-summary">' + esc(item.summary || '') + '</div><div class="feed-meta">' + (item.runId ? '<span>' + esc(item.runId.slice(-14)) + '</span>' : '') + (item.projectRoot ? '<span>' + esc(shortName(item.projectRoot)) + '</span>' : '') + (item.type ? '<span>' + esc(item.type) + '</span>' : '') + '</div></div><div class="feed-ts">' + timeHtml(item.ts) + '</div></div>';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

7. Unstable feed row keys 🐞 Bug ≡ Correctness

feedRowHtml() includes item.summary in the data-key, so rows whose summary text changes across
snapshots are treated as different keyed nodes and get replaced/re-animated. This breaks identity
preservation for synthesized feed entries like tool_burst where ts/type/runId stay constant but the
displayed count (and summary) changes.
Agent Prompt
## Issue description
`feedRowHtml()` builds `data-key` from `[item.ts, item.type, item.runId, item.summary]`. Because `summary` is presentation text (and can change while the underlying feed item identity is the same), keyed morphing will replace existing rows instead of reusing them.

## Issue Context
The activity feed includes synthesized `tool_burst` items where `ts` is pinned to the minute bucket and `summary` embeds a count that can increase as more tool calls arrive that minute. With `summary` in the key, the same logical row will get a different key on the next snapshot, so the morph can’t match/reuse it.

## Fix Focus Areas
- src/observability/dashboard/ui/lib.js[325-332]
- src/observability/dashboard/state/feed.js[14-40]
- src/observability/dashboard/ui/lib.js[249-294]

## Suggested change
- Remove `item.summary` from the key.
- Prefer a key composed only of stable identity fields (e.g., `ts|type|runId|projectRoot`), and use a null/undefined filter (not `Boolean`) so legitimate falsy values like `0` aren’t dropped.
- If collisions are still possible, extend the state model to provide a dedicated stable per-event identifier (e.g., a sequence number when reading `events.jsonl`) and key by that instead.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Qodo (convention, #490/#509/#511 precedent): the settle guard's 120ms
slack is now COUNT_UP_SETTLE_BUFFER_MS with its rationale.

Refs #516 (PR #520 review follow-up).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@richard-devbot

Copy link
Copy Markdown
Owner Author

Qodo's single finding addressed: the settle buffer is now COUNT_UP_SETTLE_BUFFER_MS (one late rAF frame + timer jitter) per the naming convention. Tests green, lint clean. — Claude Main

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/observability/dashboard/ui/lib.js (1)

115-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reduced-motion flag is captured once and never updates.

PREFERS_REDUCED_MOTION reads matchMedia('(prefers-reduced-motion: reduce)').matches one time when the script loads. If the user's OS-level reduced-motion preference changes while the dashboard tab stays open, animateCount keeps using the stale value, so KPI counters keep ticking through a full 220ms count-up animation instead of snapping instantly.

This differs from the CSS-driven motion in styles.js, where @media (prefers-reduced-motion: reduce) re-evaluates live. The JS-driven and CSS-driven motion contracts drift out of sync when the preference changes at runtime.

Add a change listener on the media query and update the flag when it fires.

♻️ Proposed fix
-var PREFERS_REDUCED_MOTION = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+var PREFERS_REDUCED_MOTION = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+if (typeof matchMedia === 'function') {
+  var reducedMotionQuery = matchMedia('(prefers-reduced-motion: reduce)');
+  var onReducedMotionChange = function(event) { PREFERS_REDUCED_MOTION = event.matches; };
+  if (typeof reducedMotionQuery.addEventListener === 'function') reducedMotionQuery.addEventListener('change', onReducedMotionChange);
+  else if (typeof reducedMotionQuery.addListener === 'function') reducedMotionQuery.addListener(onReducedMotionChange);
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/observability/dashboard/ui/lib.js` around lines 115 - 134, Update the
reduced-motion handling near PREFERS_REDUCED_MOTION by retaining the media-query
object and registering a change listener that refreshes the flag from the
event’s matches value. Keep animateCount using the updated flag so runtime
preference changes make counters snap or animate consistently with the current
setting.
tests/browser/dashboard-516-micro.test.js (1)

20-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Quiet-tick assertion doesn't exercise the keyed-morph path it names.

This test calls window.applyState(window.STATE, {}) with the identical STATE reference. Since renderCommand is a pure function of state, the regenerated #command-feed HTML string is byte-identical to the previous one, so morphHTML's el.__rsHtml === html cache check short-circuits before any DOM mutation occurs. The markerSurvived assertion therefore passes even without data-key wiring, because the DOM node is never touched at all.

Only the rowKey assertion in this test actually verifies the new keyed-identity feature. To validate that keyed matching (not just the unrelated HTML cache) preserves node identity, force a genuine content change elsewhere in the payload (so the feed HTML string differs) while keeping the marked row's key stable, then assert the marked node survives the re-render.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/browser/dashboard-516-micro.test.js` around lines 20 - 39, Update the
test to force a genuine state change that modifies the command-feed HTML
elsewhere (so the morphHTML cache short-circuit is bypassed) while keeping the
marked row's data-key attribute stable, then verify the markerSurvived assertion
validates that keyed matching (not just the HTML cache) preserves the DOM node
identity across the re-render. Change the window.applyState call to pass a
modified state object instead of the identical window.STATE reference, ensuring
the feed HTML string differs before the morphHTML operation occurs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/browser/dashboard-516-micro.test.js`:
- Around line 92-104: Update the test body in suite.browserTest('skeletons show
before the first snapshot and never after (`#516`)') to explicitly intercept the
/ws connection, wait for the WebSocket handshake, and hold the initial snapshot
message until after the pre-snapshot skeleton assertion. Do not rely on the
/api/state route to block WebSocket delivery; release the WebSocket message
before continuing with the existing post-snapshot assertions.

---

Nitpick comments:
In `@src/observability/dashboard/ui/lib.js`:
- Around line 115-134: Update the reduced-motion handling near
PREFERS_REDUCED_MOTION by retaining the media-query object and registering a
change listener that refreshes the flag from the event’s matches value. Keep
animateCount using the updated flag so runtime preference changes make counters
snap or animate consistently with the current setting.

In `@tests/browser/dashboard-516-micro.test.js`:
- Around line 20-39: Update the test to force a genuine state change that
modifies the command-feed HTML elsewhere (so the morphHTML cache short-circuit
is bypassed) while keeping the marked row's data-key attribute stable, then
verify the markerSurvived assertion validates that keyed matching (not just the
HTML cache) preserves the DOM node identity across the re-render. Change the
window.applyState call to pass a modified state object instead of the identical
window.STATE reference, ensuring the feed HTML string differs before the
morphHTML operation occurs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a04b7d57-4d3f-4eef-9f8f-dba332e4ca8f

📥 Commits

Reviewing files that changed from the base of the PR and between b39e11a and 05ae12a.

📒 Files selected for processing (7)
  • src/observability/dashboard/ui/client.js
  • src/observability/dashboard/ui/lib.js
  • src/observability/dashboard/ui/pages/command-center.js
  • src/observability/dashboard/ui/pages/index.js
  • src/observability/dashboard/ui/styles.js
  • tests/browser/dashboard-516-micro.test.js
  • tests/hub-micro-interactions-516.test.js

Comment on lines +92 to +104
suite.browserTest('skeletons show before the first snapshot and never after (#516)', { seed: seedRichProject, tmpPrefix: 'rstack-browser-516-' }, async ({ page, server }) => {
// Hold the state request so the pre-snapshot shell is observable.
let releaseState;
const gate = new Promise((resolveGate) => { releaseState = resolveGate; });
await page.route('**/api/state**', async (route) => { await gate; await route.continue(); });
await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' });
const before = await page.evaluate(() => document.querySelectorAll('#command-feed .skeleton-block').length);
assert.ok(before > 0, 'the feed panel shows skeleton placeholders before data');
releaseState();
await page.waitForFunction(() => window.STATE != null, null, { timeout: 10_000 });
const after = await page.evaluate(() => document.querySelectorAll('#page-command .skeleton-block').length);
assert.equal(after, 0, 'skeletons never survive the first snapshot');
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether the dashboard server pushes an initial snapshot over WebSocket on connect.
rg -nP -C 6 '\bws\.send\b|\bwss?\.on\(.?connection.?' src/observability/dashboard --type=js

Repository: richard-devbot/SDLC-rstack

Length of output: 164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)dashboard|connectWS|ws|websocket|server\.|client\.js' || true

echo "== relevant references =="
rg -n -C 4 'connectWS|fetchState|handleGlobalSnapshot|api/state|STATE|websocket|ws|server\.port|server\.baseUrl' . --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' || true

Repository: richard-devbot/SDLC-rstack

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)dashboard|connectWS|ws|websocket|server\.|client\.js' || true

echo "== relevant references =="
rg -n -C 4 'connectWS|fetchState|handleGlobalSnapshot|api/state|STATE|websocket|ws|server\.port|server\.baseUrl' . --glob '!node_modules/**' --Glob '!dist/**' --Glob '!build/**' || true

Repository: richard-devbot/SDLC-rstack

Length of output: 9796


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
fd -i 'dashboard|client\.js|server\.' . -t f

echo "== test file section =="
sed -n '80,110p' tests/browser/dashboard-516-micro.test.js 2>/dev/null || true

echo "== search exact send/connection patterns =="
rg -n -C 4 'ws\.send|server\.ws|websocket|connection\s*=|\.upgrade|onupgrade|on\("connection|on'\''connection|ws\.createServer|wss\.createServer' . --glob '!node_modules/**' || true

Repository: richard-devbot/SDLC-rstack

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== dashboard server websocket section =="
sed -n '1338,1395p' src/observability/dashboard/server.js

echo "== dashboard client websocket and setup section =="
sed -n '1,130p' src/observability/dashboard/ui/client.js

echo "== handleGlobalSnapshot definitions in dashboard UI =="
rg -n -C 8 'function handleGlobalSnapshot|handleGlobalSnapshot|fetchState|applyState' src/observability/dashboard/ui src/observability/dashboard --glob '!**/*.css' || true

echo "== read harness around dashboard server helpers =="
sed -n '1,220p' tests/browser/helpers/harness.js

Repository: richard-devbot/SDLC-rstack

Length of output: 50382


Wait for the WebSocket handshake before asserting the pre-snapshot view.

The dashboard server sends a full snapshot immediately after accepting a WebSocket connection, and connectWS() is started after the REST fetchState() call. page.route('**/api/state**', ...) does not hold that WebSocket message, so the initial "before" assertion can run after handleGlobalSnapshot has already populated STATE. Open a /ws, wait for the connection, and block that WebSocket message until after the snapshot assertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/browser/dashboard-516-micro.test.js` around lines 92 - 104, Update the
test body in suite.browserTest('skeletons show before the first snapshot and
never after (`#516`)') to explicitly intercept the /ws connection, wait for the
WebSocket handshake, and hold the initial snapshot message until after the
pre-snapshot skeleton assertion. Do not rely on the /api/state route to block
WebSocket delivery; release the WebSocket message before continuing with the
existing post-snapshot assertions.

@richard-devbot
richard-devbot merged commit 1c0ee29 into main Jul 31, 2026
9 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.

Hub state-driven micro-interactions (epic #499, child 2)

2 participants