Skip to content

Data-viz: event-density strip + workspace swimlane (#546) — epic #500 completes - #547

Merged
richard-devbot merged 6 commits into
mainfrom
claude/dataviz-500
Aug 2, 2026
Merged

Data-viz: event-density strip + workspace swimlane (#546) — epic #500 completes#547
richard-devbot merged 6 commits into
mainfrom
claude/dataviz-500

Conversation

@richard-devbot

@richard-devbot richard-devbot commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Closes #546. The final two children of epic #500the epic completes at 5/5 with this.

Correcting my own claim first

The ledger said these two were "thin until runs carry more telemetry." Measured: wrong about the telemetry, right only about the fixtures. Real runs write dense events.jsonl through the observe hooks on every harness; the canonical fixtures carried 5–6 events. So this PR's first commit is fixtureDenseRun (~50 events with real shape: ten tool bursts, a failed attempt, a guardrail block with an approved-then-consumed override, a passing retry, an in-progress stage) — the #533 lesson applied before building instead of after.

That fixture promptly earned its keep twice (below).

1. Event-density strip (Live Feed)

A time-bucketed activity strip above the stream; clicking a bucket filters the list to that window, clicking again clears.

  • One source of truth by construction — buckets are computed from the same served feed array the list renders. Pinned: every served item lands in exactly one bucket, and filtering by a bucket's window yields exactly its announced count.
  • A quiet minute is a real zero — empty buckets render as gaps, and gaps are not clickable: you cannot select silence.
  • Buckets are buttons (keyboard + focus free, the SDLC stage-health subway strip on Command Center (epic #500, child 2) #526 lesson), each named for a screen reader; selection is client-local, never persisted, and a snapshot that reshapes the feed under an active filter drops the filter rather than showing a stale-window empty list.

2. Run Workspace swimlane (stages × time)

One lane per stage the timeline actually names, spans from task_started → task_validated pairs, amber markers where a guardrail or gate fired. Reads the same #496 section the event list renders; the source label survives verbatim. Stated deviation: the epic said the swimlane replaces the flat list — it renders above the list instead, because the list is the only place event-level detail is readable, and deleting detail to add a summary reduces information. One-line change if the list should truly go.

Three real bugs found while building — one of them governance-grade

The events-fallback timeline drops the validation verdict. The browser suite's first run against the dense fixture showed 07-code's FAILED first attempt painted green. Two faults stacked: the #496 fallback mapping in state/run-workspace.js copied ts/type/task_id/stage_id and silently discarded status; and the swimlane model defaulted a missing verdict to PASS. The projection now carries the verdict, and a verdict-less validation closes as UNKNOWN (grey) — a green bar must mean an observed PASS and nothing weaker. Regression pinned at both layers; mutation-checked (2 unit failures + 1 browser failure with both halves reverted).

An abandoned attempt ran underneath its own retry. Found by screenshot, invisible to every numeric assertion: the model extended every open span to the timeline end, so 07-code's abandoned second attempt stretched 8 hours, its dashes running through the retry's green bar. A superseded open span now ends at its successor's start; only a trailing open span reaches the last observed moment. Mutation-checked.

var(--accent) was never defined. The screenshot of the filtered strip showed the selected bucket vanishing — an undefined custom property invalidates the whole declaration, so the selection background computed to transparent. Four consumers, no definition (including three #537-era focus rings, silently unset — their tests asserted focus, not ring visibility). A new styles-wide guard pins the class: every fallback-less var() reference must resolve. Its first run found four more pre-existing ones--mono (two font-family rules inheriting the body font) and the Evidence Center trio --blue-soft/--danger/--success (pressed filter lost its tint, verdict borders fell to currentColor). All defined against the existing palette; var(--x, fallback) exempt.

Verification

  • 1994/1994 core (+21), 67/67 browser (59 → 67), lint 0 errors, typecheck clean, validate 196 agents, security baseline OK, whitespace clean.
  • Geometry measured, not class-counted: bar heights/widths, spans inside tracks, 390px overflow, the selected fill visibly painted and different from its neighbours (sampled after the transition settles — the Motion: Proof Rail transitions, approval gate lifecycle, scroll reveal (#499 remaining zero-dep children) #541 mid-interpolation trap, hit again and named in the comment).
  • Every fix mutation-checked; the token guard fails by name on the next undefined token.
  • Bisected commits: fixture → strip → swimlane → verdict fix → token fix.
  • Screenshots read by a human at every step — both the swimlane overlap and the vanishing selection were invisible to green tests.

Not built

Drag-range selection (click-to-select shipped; recorded on #546 for later if click proves insufficient). Any new server projection.

Summary by CodeRabbit

  • New Features

    • Added an event-density visualization to the live feed for filtering activity by time window.
    • Added a swimlane timeline showing stages, task attempts, retries, validations, approvals, and guardrail events.
    • Preserved validation statuses in workspace timeline data.
    • Improved timeline and density visualizations for accessibility and mobile layouts.
  • Bug Fixes

    • Corrected preservation of failed validation verdicts in timeline views.

richardsongunde and others added 5 commits August 2, 2026 09:25
The #533 lesson applied BEFORE building instead of after. The ledger claimed
the two remaining #500 charts were "thin until runs carry more telemetry" —
measured, that was wrong about the telemetry and right only about the
fixtures: real runs write dense events.jsonl through the observe hooks on
every harness (tool_call bursts are already a first-class feed concept), but
every canonical fixture carried 5-6 events across 3 hours. Sparse enough
that a chart could pass its tests having never drawn anything meaningful.

fixtureDenseRun writes ~50 events with real shape: ten >=3/min tool bursts,
a failed attempt, a guardrail block with an approved-then-consumed override,
a retry that passes, and a stage still in progress — the open-ended span the
swimlane must render honestly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A time-bucketed activity strip above the stream; selecting a bucket filters
the visible list to that window.

One source of truth by construction: buckets are computed from the SAME
served feed array the list renders — never a second read of raw events, or
the strip and the list disagree the first time a cap or a burst rule
changes. An assertion pins that every served item lands in exactly one
bucket and that filtering by a bucket window yields exactly its count (the
final bucket closes on the right, since the newest item defines the span end
and was counted into it).

A quiet minute is a REAL zero — events.jsonl is complete for the window it
covers — so empty buckets render as gaps, and gaps are not clickable: you
cannot select silence. Occupied buckets are BUTTONS, not rects, so keyboard
and focus come free (the #526 axe lesson), each named for a screen reader.

The selection is a client-local viewing aid, never persisted; a snapshot
that reshapes the feed under an active filter drops the filter rather than
showing a stale-window empty list. Unparseable timestamps are dropped, never
coerced — Date.parse NaN treated as 0 would file items under 1970.

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

One lane per stage the timeline actually names (first-seen order, never the
canonical 15 with twelve empty rows), spans from task_started ->
task_validated pairs, amber markers where a guardrail or gate fired.

Honest geometry throughout. An attempt that never validated stays OPEN —
dashed edge, reaching the last observed moment of the timeline, never an
invented completion; the abandoned middle attempt of a retried task renders
exactly that way. The axis is linear because a run that sat blocked for a
day IS mostly gap; compressing it would hide the thing a manager looks for.
A validated event with no matching start draws nothing — there is no span,
and inventing one would claim a start nobody observed.

Reads the SAME #496 section the event list renders (persisted or
events-fallback, capped, source-labeled), so the two views cannot disagree.
The source label survives verbatim — it is a truth label.

Stated deviation from the epic text (recorded on #546): the child says the
swimlane replaces the flat list. The list is the only place event-level
detail is readable, and deleting detail to add a summary reduces
information — so the swimlane renders above the list, which stays.

Lane ids are external data: a null-prototype map, so a task named __proto__
cannot poison the lookup. Labels escaped in every position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#546)

Found by the new browser suite on its first run against fixtureDenseRun:
the swimlane painted 07-code's FAILED first attempt green.

Two faults stacked. The #496 events-fallback mapping in the projection
copied ts/type/task_id/stage_id and silently discarded `status` — the one
field that says how an attempt ended. And the swimlane model then defaulted
a missing verdict to PASS, which turned "the projection lost a field" into
"every failed attempt reads as a success".

The projection now carries the verdict, and the model treats a verdict-less
validation as UNKNOWN — grey, neither claimed nor denied — because a green
bar must mean an observed PASS and nothing weaker.

Mutation-checked with both halves reverted together: two unit failures and
the browser geometry journey. Regression pinned at both layers — the
projection test asserts the verdict survives, the model test asserts
UNKNOWN never renders as pass.

This is what the dense fixture exists for: the sparse fixtures could never
have exposed it, because their timeline never reached the fallback with a
FAIL in it under a browser assertion on span colour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#546)

The screenshot of the filtered strip showed the SELECTED bucket's bar
vanishing instead of turning accent-coloured. An undefined custom property
does not fall back: it invalidates the whole declaration at computed-value
time, so background: var(--accent) computed to transparent — and the one
bucket the reader chose became the one bucket they could not see.

var(--accent) had four consumers and no definition: this strip's selection,
plus three focus-visible outlines from the #537 batch that were silently
unset (their tests asserted focus, not ring visibility, so they passed).

A new styles-wide guard pins the class: every fallback-less var() reference
must be defined somewhere the scan can see (the stylesheet or a page module
writing inline custom properties). Its first run found FOUR more, all
pre-existing: --mono (two font-family rules silently inheriting the body
font) and the Evidence Center trio --blue-soft/--danger/--success (pressed
filter lost its tint; verdict borders fell to currentColor). All defined
against the existing palette. var(--x, fallback) is exempt — a fallback is
the author handling absence.

The browser test now also asserts the selected fill is painted and reads
differently from an unselected bar — sampled after the background
transition settles, because reading at the click returns the
mid-interpolation base colour (the #541 sampling lesson, hit again).

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

strix-security Bot commented Aug 2, 2026

Copy link
Copy Markdown

Strix is installed on this repository, but we couldn't run this PR security review because this workspace's trial has ended. Add a card to resume code reviews here.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@richard-devbot, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 318d439f-47e9-4f42-bd86-6de50353fa20

📥 Commits

Reviewing files that changed from the base of the PR and between 1827d33 and 5da8024.

📒 Files selected for processing (5)
  • src/observability/dashboard/ui/pages/live-feed.js
  • src/observability/dashboard/ui/pages/run-workspace.js
  • tests/browser/dashboard-546-dataviz.test.js
  • tests/helpers/dashboard-fixtures.js
  • tests/hub-dataviz-546.test.js
📝 Walkthrough

Walkthrough

The dashboard adds an event-density strip to Live Feed and a swimlane timeline to Run Workspace. It preserves validation statuses in event-derived timelines and adds dense-run fixtures, browser coverage, unit tests, styling, filtering, markers, and accessibility checks.

Changes

Dashboard data visualization

Layer / File(s) Summary
Live Feed event-density filtering
src/observability/dashboard/ui/pages/index.js, src/observability/dashboard/ui/pages/live-feed.js, src/observability/dashboard/ui/styles.js, tests/helpers/dashboard-fixtures.js, tests/browser/dashboard-546-dataviz.test.js, tests/hub-dataviz-546.test.js
The Live Feed computes 24 timestamp buckets from served events, renders accessible bars and gaps, supports selection toggling, filters the event list, and reports filtered counts. Tests cover dense and sparse runs, geometry, accessibility, and selection behavior.
Run Workspace swimlane timeline
src/observability/dashboard/state/run-workspace.js, src/observability/dashboard/ui/pages/run-workspace.js, src/observability/dashboard/ui/styles.js, tests/hub-dataviz-546.test.js, tests/run-workspace-timeline-496.test.js
The workspace preserves validation status values and renders stage lanes, retry-bounded spans, open and unknown states, guardrail and approval markers, axis labels, and the existing event-detail list.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: richardsongunde

Poem

A rabbit maps the feed with care,
Twenty-four bright bars appear.
Swimlanes trace each task’s run,
Failed, approved, open, and done.
Events keep their truth in view. 🐇

🚥 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 event-density strip and workspace swimlane, which are the primary changes in the pull request.
Linked Issues check ✅ Passed The changes implement the linked issue objectives for the dense fixture, accessible feed filtering, swimlane visualization, validation status preservation, and required verification.
Out of Scope Changes check ✅ Passed The changes remain within scope and do not add server projections, dark theme work, or drag-range selection.
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/dataviz-500

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

Add Live Feed event-density strip and Run Workspace swimlane; fix verdict + CSS tokens

✨ Enhancement 🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Add an event-density strip above Live Feed, with bucket-click filtering.
• Add a stages×time swimlane timeline above the Run Workspace event list.
• Fix events-fallback to preserve validation verdict; harden visuals with dense fixtures and browser
 tests.
Diagram

graph TD
  U(["User"]) --> LF["Live Feed page"] --> DS["Density strip (buckets)"] --> FL["Filtered feed list"]
  U --> RW["Run Workspace: Timeline tab"] --> SL["Swimlane (stages×time)"] --> EL["Timeline event list"]
  RW --> TL["Timeline projection (readableTimeline)"]
  DS --> ST["Dashboard styles / tokens"]
  SL --> ST
  T[("fixtureDenseRun")] --> TS["#546 tests"] --> LF
  TS --> RW

  subgraph Legend
    direction LR
    _u(["User"]) ~~~ _p["UI module"] ~~~ _d[("Fixture / test data")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Server-side bucketization + window filtering
  • ➕ Keeps the client simpler and avoids recomputing buckets each snapshot
  • ➕ Enables consistent bucketing across clients and potential API reuse
  • ➖ Creates a second source of truth unless the server also serves the exact list items being rendered
  • ➖ Adds new server surface area and versioning/compat concerns (explicitly avoided by this PR)
2. Render charts via SVG/canvas (mini chart components)
  • ➕ More control over layout/labeling and potentially smoother visuals
  • ➕ Could support richer interactions (hover tooltips, selection ranges)
  • ➖ Higher implementation complexity and more bespoke accessibility work
  • ➖ Harder to keep in lockstep with list semantics (risk of strip/list disagreement)
3. Replace the timeline list entirely with the swimlane
  • ➕ Cleaner UI and less vertical space usage for long timelines
  • ➖ Loses event-level readability and debuggability without adding a second detail drilldown
  • ➖ Increases risk of regressions for users relying on the textual list

Recommendation: The PR’s approach is the best fit for the stated constraints: compute both visualizations from the exact arrays already rendered (served feed for Live Feed; existing #496 timeline section for Run Workspace), which prevents divergence and avoids new server endpoints. The explicit guardrails (drop invalid timestamps, don’t allow selecting empty buckets, don’t default missing verdicts to PASS, and clear filters on reshaped snapshots) are the right tradeoffs for correctness and user trust.

Files changed (9) +865 / -4

Enhancement (4) +244 / -4
index.jsAdd Live Feed density strip mount point +1/-1

Add Live Feed density strip mount point

• Adds a dedicated #live-feed-density container above the live feed list so the density strip can render within the panel layout.

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

live-feed.jsImplement event-density bucketing + bucket-click filtering +94/-2

Implement event-density bucketing + bucket-click filtering

• Adds a client-side density model that buckets served feed items into a fixed number of time buckets and renders occupied buckets as accessible buttons. Selecting a bucket filters the visible list to that time window, drops selection if a new snapshot invalidates it, and ensures the bucket/list counts agree by construction.

src/observability/dashboard/ui/pages/live-feed.js

run-workspace.jsAdd stages×time swimlane above timeline event list +101/-1

Add stages×time swimlane above timeline event list

• Introduces a swimlane model that pairs task_started/task_validated into spans per lane, bounds superseded open attempts at retry start, and marks guardrail/gate events. Renders the swimlane above the existing timeline list and treats missing verdicts as UNKNOWN rather than default PASS.

src/observability/dashboard/ui/pages/run-workspace.js

styles.jsDefine missing CSS tokens and style density strip + swimlane +48/-0

Define missing CSS tokens and style density strip + swimlane

• Defines previously-referenced but undefined CSS custom properties (notably --accent, plus --mono and other pre-existing tokens) to prevent computed-style invalidation. Adds styling for the density strip (button bars + focus/selected states) and the swimlane (tracks, spans, markers, axis) while avoiding keyframe animations.

src/observability/dashboard/ui/styles.js

Bug fix (1) +5 / -0
run-workspace.jsPreserve task_validated status in events-fallback timeline mapping +5/-0

Preserve task_validated status in events-fallback timeline mapping

• Extends the readableTimeline events→timeline fallback mapping to include the validation verdict (status). This fixes downstream swimlane rendering where missing verdicts previously caused failed attempts to be treated as passing.

src/observability/dashboard/state/run-workspace.js

Tests (4) +616 / -0
dashboard-546-dataviz.test.jsBrowser suite coverage for density strip + swimlane geometry and a11y +247/-0

Browser suite coverage for density strip + swimlane geometry and a11y

• Adds real-DOM browser tests seeded by fixtureDenseRun to validate visible geometry (bar heights, span widths, no overflow), correct filtering behavior, and that selection paint is non-transparent. Also runs axe-core when available to block serious/critical accessibility issues.

tests/browser/dashboard-546-dataviz.test.js

dashboard-fixtures.jsAdd fixtureDenseRun with realistic event telemetry density +70/-0

Add fixtureDenseRun with realistic event telemetry density

• Introduces a new dense run fixture (~50 events) with tool-call bursts, failed/abandoned/passing attempts, guardrail/gate events, approvals, and an in-progress stage. Provides realistic event distributions so viz tests can’t pass vacuously on sparse fixtures.

tests/helpers/dashboard-fixtures.js

hub-dataviz-546.test.jsUnit tests for density bucket invariants, swimlane pairing, and CSS token completeness +291/-0

Unit tests for density bucket invariants, swimlane pairing, and CSS token completeness

• Adds unit-level tests that pin strip/list agreement (exact bucket totals and window filtering), silence-as-gaps semantics, and timestamp handling. Validates swimlane lane derivation, span pairing, superseded-open bounding, UNKNOWN verdict behavior, marker creation, HTML escaping, and a guard ensuring all referenced CSS vars are defined.

tests/hub-dataviz-546.test.js

run-workspace-timeline-496.test.jsRegression test ensuring events fallback carries validation verdict +8/-0

Regression test ensuring events fallback carries validation verdict

• Adds a focused test that verifies the events→timeline fallback projection includes task_validated.status so downstream consumers can distinguish PASS vs FAIL.

tests/run-workspace-timeline-496.test.js

@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: 3

🧹 Nitpick comments (5)
src/observability/dashboard/ui/styles.js (3)

1188-1191: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Distinguish the selected bucket by more than colour.

Line 1190 paints the selected fill --accent (#1570ef). Line 1188 paints the hovered fill --blue (#1d4ed8). The two colours are nearly the same hue and lightness, so a hovered unselected bar reads like the selected bar. The selected fill also differs from the base fill #b9c4d8 by colour alone, at a contrast ratio near 2.2:1, which is below the 3:1 that WCAG 1.4.11 asks for a non-text state indicator.

aria-pressed already carries the state for assistive technology. Add a visual cue that does not depend on colour.

♻️ Proposed cue for the selected bucket
 .density-bucket:hover .density-fill { background: var(--blue); }
 .density-bucket:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
 .density-bucket[aria-pressed="true"] .density-fill { background: var(--accent); }
+/* Selection must not rest on hue alone: the hover colour is a neighbouring
+   blue, so the pressed bucket also carries a mark the eye reads as state. */
+.density-bucket[aria-pressed="true"] { box-shadow: inset 0 2px 0 var(--ink); }
🤖 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/styles.js` around lines 1188 - 1191, Update
the .density-bucket[aria-pressed="true"] .density-fill rule to add a non-color
visual cue for selected buckets, such as a distinct pattern or border, while
retaining the existing accent color. Ensure the cue remains distinguishable from
both hovered and unselected fills and does not rely on color alone.

1186-1187: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Check the bucket tap target on a phone.

Line 1187 sets .density-bucket to flex: 1 1 0; min-width: 4px. The model builds 24 buckets. Inside a 390px panel each bucket is about 12px wide, and a bucket next to gaps can reach the 4px minimum. WCAG 2.5.8 asks for a 24x24 CSS pixel target. The strip is 44px tall, so only the width falls short.

The .path-copy rule at lines 1141-1142 solves the same problem with an expanded ::after hit area, but that approach does not transfer here: the buckets sit 2px apart, so expanded hit areas would overlap and steal each other's taps. Reducing the bucket count on a narrow viewport is the honest fix.

The browser suite at tests/browser/dashboard-546-dataviz.test.js lines 135-146 measures overflow at 390px but does not measure bucket width.

🤖 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/styles.js` around lines 1186 - 1187, Update
the responsive density-strip styles around .density-bucket to reduce the
rendered bucket count on narrow phone viewports so each remaining bucket
maintains a 24px minimum tap width, rather than expanding overlapping hit areas.
Preserve the existing 24-bucket layout at wider widths and ensure the strip
remains within the 390px panel without overflow; extend the browser coverage to
assert the bucket width at the narrow viewport.

1207-1209: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Tie the open-span gradient to the accent token.

Line 1209 hardcodes rgba(21,112,239,.5) and rgba(21,112,239,.2). That colour is --accent (#1570ef) written as literal channels. This PR added --accent to stop exactly this drift, so the literal will fall out of step the next time the accent changes.

rgba() cannot take a hex custom property, so add a channel token next to --accent.

♻️ Proposed token for the accent channels

Add next to the --accent definition:

   --accent: `#1570ef`;
+  /* Channel form of --accent, for the rgba() ramps that cannot read a hex. */
+  --accent-rgb: 21, 112, 239;

Then read it in the gradient:

-.swimlane-span.open { background: repeating-linear-gradient(90deg, rgba(21,112,239,.5) 0 6px, rgba(21,112,239,.2) 6px 12px); border-right: 2px dashed var(--blue); }
+.swimlane-span.open { background: repeating-linear-gradient(90deg, rgba(var(--accent-rgb),.5) 0 6px, rgba(var(--accent-rgb),.2) 6px 12px); border-right: 2px dashed var(--blue); }
🤖 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/styles.js` around lines 1207 - 1209, Add an
accent RGB-channel custom property alongside the existing --accent definition,
then update the .swimlane-span.open repeating-linear-gradient to use that
channel token for both rgba stops instead of hardcoded 21,112,239 values;
preserve the existing opacities and gradient behavior.
tests/browser/dashboard-546-dataviz.test.js (2)

216-224: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

This test does not exercise the sparse-run browser journey it names.

The body calls window.timelineSwimlaneModel with a hand-written array on line 221. That is the same input and the same assertion as the unit test at tests/hub-dataviz-546.test.js lines 219-224. It runs in a browser, but it never renders anything.

Two consequences:

  • The custom seed on line 224 writes fixtureBlockedRun, and nothing in the body reads that run. The seed is dead setup.
  • The named journey — open the sparse run's workspace, see no swimlane, see no page error — stays unverified. A swimlane that throws while rendering a sparse run would not be caught here.

Navigate to the blocked run's timeline tab and assert that .swimlane is absent while the event list still renders.

As per coding guidelines: "Browser behavior must be verified with real-browser regression tests for critical journeys, including accessibility, responsive behavior, no-data honesty, navigation interaction, and page-error detection."

🤖 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-546-dataviz.test.js` around lines 216 - 224, Update
the browser test around openLiveFeed to exercise the seeded fixtureBlockedRun
journey instead of directly calling window.timelineSwimlaneModel. Navigate to
the blocked run’s timeline tab, assert that the event list renders, assert that
.swimlane is absent, and retain page-error detection; keep only seed setup
required for this journey and remove the redundant hand-written model assertion.

Source: Coding guidelines


125-132: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Do not depend on a synchronous render inside page.evaluate.

Line 128 calls button.click() and line 129 reads the row count in the same expression. This passes only while feedDensityTogglerenderLiveFeedsetHTML stays synchronous. If setHTML ever defers the write to requestAnimationFrame or a microtask batch, line 129 reads the pre-click rows. The assertion on line 131 would then compare the bucket count against the unfiltered list and could pass or fail for the wrong reason.

The sibling test at lines 87-92 already uses the safer shape: page.click, then a separate page.evaluate.

💚 Proposed restructure of the agreement test
   await openLiveFeed(page, server);
-  const agreement = await page.evaluate(() => {
-    const button = document.querySelector('`#live-feed-density` .density-bucket');
-    const claimed = Number((button.getAttribute('aria-label').match(/(\d+) event/) || [])[1]);
-    button.click();
-    return { claimed, listed: document.querySelectorAll('`#live-feed-list` .feed-row').length };
-  });
-  assert.equal(agreement.listed, agreement.claimed,
+  const claimed = await page.evaluate(() => {
+    const button = document.querySelector('`#live-feed-density` .density-bucket');
+    return Number((button.getAttribute('aria-label').match(/(\d+) event/) || [])[1]);
+  });
+  await page.click('`#live-feed-density` .density-bucket');
+  await page.waitForFunction(
+    () => document.querySelectorAll('`#live-feed-density` [aria-pressed="true"]').length === 1,
+    null, { timeout: STATE_LOAD_TIMEOUT_MS });
+  const listed = await page.evaluate(
+    () => document.querySelectorAll('`#live-feed-list` .feed-row').length);
+  assert.equal(listed, claimed,
     'the bucket announces N events and the filtered list shows exactly N — one source of truth');
🤖 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-546-dataviz.test.js` around lines 125 - 132,
Restructure the agreement check so the click and row-count read are separate
browser actions: use page.click on the density bucket first, then use
page.evaluate to read the filtered row count and retain the previously captured
claimed count. Follow the established pattern in the sibling test around the
existing agreement test, ensuring the assertion observes the post-render state
rather than relying on synchronous updates inside one evaluate callback.
🤖 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 `@src/observability/dashboard/ui/pages/live-feed.js`:
- Around line 153-154: Update the density bucket label construction near `when`
to use the same local-time formatting as `timeHtml(item.ts)` instead of slicing
`toISOString()`. Keep the bucket’s ISO value separate if it is used elsewhere,
and preserve the existing count and singular/plural label formatting.
- Around line 183-192: Update the empty-state selection in the live-feed
rendering flow after the FEED_DENSITY_SELECTED filtering block so an empty
selected window uses copy indicating no events match the selected window, rather
than “No events yet.” Preserve the existing “No events yet” message when no
density window is selected or the feed itself is empty, and keep the filtered
count unchanged.

In `@tests/hub-dataviz-546.test.js`:
- Around line 122-134: Add regression coverage in the existing feed-density
window test suite that selects the final bucket from feedDensityModel, calls
feedDensityWindow, and verifies the half-open window includes the item at the
span end and matches that bucket’s count. Ensure the test fails if the +1ms
extension in feedDensityWindow is removed, while preserving the existing
first-bucket assertions.

---

Nitpick comments:
In `@src/observability/dashboard/ui/styles.js`:
- Around line 1188-1191: Update the .density-bucket[aria-pressed="true"]
.density-fill rule to add a non-color visual cue for selected buckets, such as a
distinct pattern or border, while retaining the existing accent color. Ensure
the cue remains distinguishable from both hovered and unselected fills and does
not rely on color alone.
- Around line 1186-1187: Update the responsive density-strip styles around
.density-bucket to reduce the rendered bucket count on narrow phone viewports so
each remaining bucket maintains a 24px minimum tap width, rather than expanding
overlapping hit areas. Preserve the existing 24-bucket layout at wider widths
and ensure the strip remains within the 390px panel without overflow; extend the
browser coverage to assert the bucket width at the narrow viewport.
- Around line 1207-1209: Add an accent RGB-channel custom property alongside the
existing --accent definition, then update the .swimlane-span.open
repeating-linear-gradient to use that channel token for both rgba stops instead
of hardcoded 21,112,239 values; preserve the existing opacities and gradient
behavior.

In `@tests/browser/dashboard-546-dataviz.test.js`:
- Around line 216-224: Update the browser test around openLiveFeed to exercise
the seeded fixtureBlockedRun journey instead of directly calling
window.timelineSwimlaneModel. Navigate to the blocked run’s timeline tab, assert
that the event list renders, assert that .swimlane is absent, and retain
page-error detection; keep only seed setup required for this journey and remove
the redundant hand-written model assertion.
- Around line 125-132: Restructure the agreement check so the click and
row-count read are separate browser actions: use page.click on the density
bucket first, then use page.evaluate to read the filtered row count and retain
the previously captured claimed count. Follow the established pattern in the
sibling test around the existing agreement test, ensuring the assertion observes
the post-render state rather than relying on synchronous updates inside one
evaluate callback.
🪄 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: a528af8b-d942-4e74-ab3d-776491c8f41a

📥 Commits

Reviewing files that changed from the base of the PR and between d548ddf and 1827d33.

📒 Files selected for processing (9)
  • src/observability/dashboard/state/run-workspace.js
  • src/observability/dashboard/ui/pages/index.js
  • src/observability/dashboard/ui/pages/live-feed.js
  • src/observability/dashboard/ui/pages/run-workspace.js
  • src/observability/dashboard/ui/styles.js
  • tests/browser/dashboard-546-dataviz.test.js
  • tests/helpers/dashboard-fixtures.js
  • tests/hub-dataviz-546.test.js
  • tests/run-workspace-timeline-496.test.js

Comment thread src/observability/dashboard/ui/pages/live-feed.js Outdated
Comment thread src/observability/dashboard/ui/pages/live-feed.js Outdated
Comment on lines +122 to +134
test('the filter window is the selected bucket, half-open (#546)', () => {
const api = feedRenderer();
const model = api.feedDensityModel(feedItems(), 12);
const occupied = model.buckets.findIndex((bucket) => bucket.count > 0);
const window = api.feedDensityWindow(model, occupied);
assert.ok(window.startMs < window.endMs);
const inWindow = feedItems().filter((item) => {
const ms = Date.parse(item.ts);
return ms >= window.startMs && ms < window.endMs;
});
assert.equal(inWindow.length, model.buckets[occupied].count,
'filtering by the window yields exactly the bucket count — strip and list agree');
});

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cover the final bucket, where the window closes on the right.

This test picks the first occupied bucket, which is index 0. The special rule in feedDensityWindow applies only to the last bucket: line 143 of src/observability/dashboard/ui/pages/live-feed.js extends endMs by 1 ms so the item at exactly the span end stays in the window. The bucket-assignment clamp on line 129 counted that item into the last bucket, so the two must agree.

No test here selects the last bucket. Delete the + 1 and every assertion in this file still passes, while the strip announces N events and the list shows N-1.

As per coding guidelines: "Every new fix must include regression coverage, and fixes—especially review findings—should be mutation-tested by reverting the fix and proving the test fails."

💚 Proposed regression test for the closing bucket
+test('the last bucket closes on the right so the newest event is not dropped (`#546`)', () => {
+  const api = feedRenderer();
+  const items = feedItems();
+  const model = api.feedDensityModel(items, 12);
+  const last = model.buckets.length - 1;
+  const window = api.feedDensityWindow(model, last);
+  const inWindow = items.filter((item) => {
+    const ms = Date.parse(item.ts);
+    return ms >= window.startMs && ms < window.endMs;
+  });
+  assert.equal(inWindow.length, model.buckets[last].count,
+    'the newest item was counted into the last bucket, so the filter must include it too');
+});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('the filter window is the selected bucket, half-open (#546)', () => {
const api = feedRenderer();
const model = api.feedDensityModel(feedItems(), 12);
const occupied = model.buckets.findIndex((bucket) => bucket.count > 0);
const window = api.feedDensityWindow(model, occupied);
assert.ok(window.startMs < window.endMs);
const inWindow = feedItems().filter((item) => {
const ms = Date.parse(item.ts);
return ms >= window.startMs && ms < window.endMs;
});
assert.equal(inWindow.length, model.buckets[occupied].count,
'filtering by the window yields exactly the bucket count — strip and list agree');
});
test('the filter window is the selected bucket, half-open (`#546`)', () => {
const api = feedRenderer();
const model = api.feedDensityModel(feedItems(), 12);
const occupied = model.buckets.findIndex((bucket) => bucket.count > 0);
const window = api.feedDensityWindow(model, occupied);
assert.ok(window.startMs < window.endMs);
const inWindow = feedItems().filter((item) => {
const ms = Date.parse(item.ts);
return ms >= window.startMs && ms < window.endMs;
});
assert.equal(inWindow.length, model.buckets[occupied].count,
'filtering by the window yields exactly the bucket count — strip and list agree');
});
test('the last bucket closes on the right so the newest event is not dropped (`#546`)', () => {
const api = feedRenderer();
const items = feedItems();
const model = api.feedDensityModel(items, 12);
const last = model.buckets.length - 1;
const window = api.feedDensityWindow(model, last);
const inWindow = items.filter((item) => {
const ms = Date.parse(item.ts);
return ms >= window.startMs && ms < window.endMs;
});
assert.equal(inWindow.length, model.buckets[last].count,
'the newest item was counted into the last bucket, so the filter must include it too');
});
🤖 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/hub-dataviz-546.test.js` around lines 122 - 134, Add regression
coverage in the existing feed-density window test suite that selects the final
bucket from feedDensityModel, calls feedDensityWindow, and verifies the
half-open window includes the item at the span end and matches that bucket’s
count. Ensure the test fails if the +1ms extension in feedDensityWindow is
removed, while preserving the existing first-bucket assertions.

Source: Coding guidelines

@qodo-code-review

qodo-code-review Bot commented Aug 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (2)

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. Fixture budget clobbered ✓ Resolved 🐞 Bug ☼ Reliability
Description
fixtureDenseRun() always calls projectShell(root) with default options, which rewrites
.rstack/budget.json and can silently override an earlier fixture’s intentional budget (e.g.,
fixtureSpendRun’s $10 cap) when fixtures are composed in one temp project. This makes composed
browser/unit suites assert against the wrong budget configuration and can cause misleading pass/fail
behavior.
Code

tests/helpers/dashboard-fixtures.js[R352-355]

+export async function fixtureDenseRun(root) {
+  await projectShell(root);
+  const runId = 'run-fx-dense';
+  const runDir = await runShell(root, runId, { status: 'IN_PROGRESS', goal: 'Dense fixture — realistic event telemetry' });
Relevance

●●● Strong

Budget clobber in composed fixtures matches prior accepted fix to prevent projectShell overwriting
runBudgetUsd.

PR-#534

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
projectShell() writes the project budget file; fixtureSpendRun explicitly sets a non-default
runBudgetUsd, while the newly added fixtureDenseRun calls projectShell() with defaults, which would
overwrite that budget in composed seeds.

tests/helpers/dashboard-fixtures.js[29-36]
tests/helpers/dashboard-fixtures.js[304-306]
tests/helpers/dashboard-fixtures.js[352-355]
PR-#534

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

## Issue description
`fixtureDenseRun()` unconditionally calls `projectShell(root)` using the default budget configuration. `projectShell()` writes `.rstack/budget.json`, so when `fixtureDenseRun()` is composed after a fixture that intentionally sets a different budget (e.g. `fixtureSpendRun(..., { runBudgetUsd: 10 })`), the budget is silently reset and downstream budget-meter rendering/tests can be incorrect.

## Issue Context
This repo already hit the fixture-composition overwrite problem before (see past PR knowledge). The dense fixture is likely to be reused in composed seeds as more dashboard-viz tests are added.

## Fix Focus Areas
- tests/helpers/dashboard-fixtures.js[29-36]
- tests/helpers/dashboard-fixtures.js[304-306]
- tests/helpers/dashboard-fixtures.js[352-356]

## Suggested fix
- Update `fixtureDenseRun(root, options = {})` to accept an option that avoids overwriting project-level state when composing (e.g. `{ initProject: true }` defaulting to true, or `{ skipProjectShell: false }`).
- When composing fixtures in tests, call `projectShell()` once in the composed seed and invoke fixtures with `initProject: false` (or equivalent), OR forward options through (`projectShell(root, options)`) so callers can keep the earlier budget.
- Add/adjust one small composition test (optional) proving a spend-budget fixture followed by the dense fixture preserves the intended budget.

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


2. timelineSwimlaneModel() exceeds 50 lines ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
timelineSwimlaneModel() is 58 lines long, exceeding the 50-line limit and making the logic harder
to review and maintain. This violates the function length compliance requirement.
Code

src/observability/dashboard/ui/pages/run-workspace.js[R96-153]

+function timelineSwimlaneModel(items) {
+  // Lane ids are external data (task ids) — a null-prototype map so a task
+  // named __proto__ cannot poison the lookup (the #495-era lesson).
+  var byLane = Object.create(null);
+  var order = [];
+  var minMs = Infinity;
+  var maxMs = -Infinity;
+  (items || []).forEach(function(item) {
+    var ms = Date.parse(item && item.ts);
+    if (!isFinite(ms)) return;
+    if (ms < minMs) minMs = ms;
+    if (ms > maxMs) maxMs = ms;
+    var laneId = item.stage_id || item.task_id;
+    if (!laneId) return; // run-level events belong to no lane; the list below still shows them
+    var lane = byLane[laneId];
+    if (!lane) {
+      lane = byLane[laneId] = { id: laneId, spans: [], markers: [] };
+      order.push(lane);
+    }
+    if (item.type === 'task_started') {
+      // A new attempt supersedes an unvalidated predecessor on the same
+      // lane: the abandoned span ends HERE, at the moment of the retry. It
+      // stays "open" (it never validated), but extending it further would
+      // claim it kept running underneath its own successor — the screenshot
+      // showed exactly that, dashes running through the retry's green bar.
+      for (var prior = lane.spans.length - 1; prior >= 0; prior--) {
+        if (lane.spans[prior].open) { lane.spans[prior].endMs = ms; break; }
+      }
+      lane.spans.push({ startMs: ms, endMs: null, status: 'open', open: true });
+    } else if (item.type === 'task_validated') {
+      // Close the most recent open attempt. A validated with no started is
+      // an orphan — there is no span to draw and inventing one would claim
+      // a start nobody observed.
+      for (var i = lane.spans.length - 1; i >= 0; i--) {
+        if (lane.spans[i].open) {
+          lane.spans[i].endMs = ms;
+          lane.spans[i].open = false;
+          // No verdict on the event is UNKNOWN, never a default PASS — a
+          // green bar must mean an observed PASS and nothing weaker.
+          lane.spans[i].status = item.status ? String(item.status).toUpperCase() : 'UNKNOWN';
+          break;
+        }
+      }
+    } else if (item.type === 'guardrail_triggered' || item.type === 'approval_gate_blocked') {
+      lane.markers.push({ ms: ms, kind: item.type });
+    }
+  });
+  if (!order.length || !isFinite(minMs)) return null;
+  var hasContent = order.some(function(lane) { return lane.spans.length || lane.markers.length; });
+  if (!hasContent) return null;
+  // Only a TRAILING open attempt — one with no successor — reaches the last
+  // observed moment of the whole timeline. Superseded opens were already
+  // bounded at their retry's start above. Never an invented completion.
+  order.forEach(function(lane) {
+    lane.spans.forEach(function(span) { if (span.open && span.endMs === null) span.endMs = maxMs; });
+  });
+  return { lanes: order, startMs: minMs, endMs: maxMs > minMs ? maxMs : minMs + 1 };
+}
Relevance

●● Moderate

No close precedent on strict 50-line function limit; team accepts similar run-workspace.js
maintainability tweaks.

PR-#539

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1399985 forbids functions over 50 lines. The added timelineSwimlaneModel() spans
lines 96-153 (58 lines), which is over the limit.

src/observability/dashboard/ui/pages/run-workspace.js[96-153]
Skill: testing-qa

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

## Issue description
`timelineSwimlaneModel()` exceeds the 50-line maximum allowed per compliance.

## Issue Context
The function currently contains multiple responsibilities (lane creation, span start/close logic, marker handling, and finalization of open spans) in a single block.

## Fix Focus Areas
- src/observability/dashboard/ui/pages/run-workspace.js[96-153]

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


3. Density buckets under 44px 📜 Skill insight ☑ Accessibility
Description
The new event-density strip renders each bucket as a clickable button, but the CSS allows bucket
widths far below 44px (e.g., min-width: 4px), which violates the minimum touch target size
requirement. On mobile and even many desktop widths, these buttons will be difficult to tap
accurately.
Code

src/observability/dashboard/ui/styles.js[R1186-1188]

+.density-strip { display: flex; align-items: flex-end; gap: 2px; height: 44px; padding: 6px 2px 4px; margin-bottom: 10px; border-bottom: 1px solid var(--line); }
+.density-bucket { flex: 1 1 0; min-width: 4px; height: 100%; display: flex; align-items: flex-end; padding: 0; border: 0; background: none; cursor: pointer; border-radius: 3px 3px 0 0; }
+.density-bucket:hover .density-fill { background: var(--blue); }
Relevance

●● Moderate

No historical 44px touch-target enforcement found; could be waived for dense-strip visualization
despite a11y intent.

PR-#436

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1399597 requires interactive elements to be at least 44px in both dimensions. The
new .density-bucket buttons are allowed to be as small as min-width: 4px, so they can fall well
below the 44px minimum width when many buckets are shown.

src/observability/dashboard/ui/styles.js[1186-1188]
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 density-strip buckets are interactive `<button>` elements but are styled to be much smaller than the required 44px minimum touch target size.

## Issue Context
`.density-bucket` currently uses `flex: 1 1 0` with `min-width: 4px`, so when there are many buckets (e.g., 24) the computed width per button will often be far under 44px.

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

## Suggested implementation direction
- Make the density strip responsive so each bucket button is at least 44px wide (e.g., compute bucket count based on available width, or reduce bucket count on small viewports).
- Alternatively, allow horizontal scrolling with `min-width: 44px` per bucket (but ensure this aligns with the product’s no-overflow requirements/tests).

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



Informational

4. Nonstandard spacing values added 📜 Skill insight ⚙ Maintainability
Description
New CSS introduces spacing values that are not on a consistent 4px/8px scale (e.g., gap: 7px,
gap: 10px, margin-bottom: 10px, padding: 6px 2px 4px). This violates the spacing-scale
requirement and can lead to inconsistent layout rhythm across the UI.
Code

src/observability/dashboard/ui/styles.js[R1186-1201]

+.density-strip { display: flex; align-items: flex-end; gap: 2px; height: 44px; padding: 6px 2px 4px; margin-bottom: 10px; border-bottom: 1px solid var(--line); }
+.density-bucket { flex: 1 1 0; min-width: 4px; height: 100%; display: flex; align-items: flex-end; padding: 0; border: 0; background: none; cursor: pointer; border-radius: 3px 3px 0 0; }
+.density-bucket:hover .density-fill { background: var(--blue); }
+.density-bucket:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+.density-bucket[aria-pressed="true"] .density-fill { background: var(--accent); }
+.density-fill { width: 100%; border-radius: 3px 3px 0 0; background: #b9c4d8; transition: background var(--motion-fast) var(--motion-ease), height var(--motion-base) var(--motion-ease); }
+.density-gap { flex: 1 1 0; min-width: 4px; }
+
+/* ── #546: workspace swimlane — stages x time ──────────────────────────────
+   Spans are percentage-positioned within their track; min-width keeps a
+   short attempt visible on a long axis without lying about its length by
+   more than half a percent. */
+.swimlane { display: grid; gap: 7px; padding: 12px 12px 8px; border: 1px solid var(--line); border-radius: 10px; background: var(--soft); margin-bottom: 12px; }
+.swimlane-row { display: grid; grid-template-columns: minmax(72px, 148px) minmax(0, 1fr); gap: 10px; align-items: center; }
+.swimlane-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.swimlane-track { position: relative; height: 14px; border-radius: 7px; background: var(--panel); border: 1px solid var(--line); }
Relevance

● Weak

Team repeatedly rejected enforcing 4px/8px spacing-scale alignment for new CSS spacing values.

PR-#539
PR-#531
PR-#520

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400413 requires spacing values (padding/margin/gap) to follow a consistent 4px or
8px base scale. The added rules include off-scale spacing values such as gap: 7px, gap: 10px,
margin-bottom: 10px, and padding: 6px 2px 4px.

src/observability/dashboard/ui/styles.js[1186-1201]
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
New styles introduce spacing values not aligned to the project's 4px/8px spacing scale.

## Issue Context
The added `#546` density strip and swimlane rules include several `gap`/`padding`/`margin` values (e.g., 7px, 10px, 6px, 2px) that are off-scale.

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

ⓘ 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 +1186 to +1188
.density-strip { display: flex; align-items: flex-end; gap: 2px; height: 44px; padding: 6px 2px 4px; margin-bottom: 10px; border-bottom: 1px solid var(--line); }
.density-bucket { flex: 1 1 0; min-width: 4px; height: 100%; display: flex; align-items: flex-end; padding: 0; border: 0; background: none; cursor: pointer; border-radius: 3px 3px 0 0; }
.density-bucket:hover .density-fill { background: var(--blue); }

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. Density buckets under 44px 📜 Skill insight ☑ Accessibility

The new event-density strip renders each bucket as a clickable button, but the CSS allows bucket
widths far below 44px (e.g., min-width: 4px), which violates the minimum touch target size
requirement. On mobile and even many desktop widths, these buttons will be difficult to tap
accurately.
Agent Prompt
## Issue description
The density-strip buckets are interactive `<button>` elements but are styled to be much smaller than the required 44px minimum touch target size.

## Issue Context
`.density-bucket` currently uses `flex: 1 1 0` with `min-width: 4px`, so when there are many buckets (e.g., 24) the computed width per button will often be far under 44px.

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

## Suggested implementation direction
- Make the density strip responsive so each bucket button is at least 44px wide (e.g., compute bucket count based on available width, or reduce bucket count on small viewports).
- Alternatively, allow horizontal scrolling with `min-width: 44px` per bucket (but ensure this aligns with the product’s no-overflow requirements/tests).

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

Comment thread src/observability/dashboard/ui/pages/run-workspace.js
Comment thread tests/helpers/dashboard-fixtures.js
CodeRabbit, Major — no test covered the final bucket, and it named the
surviving mutation exactly: delete the +1 in feedDensityWindow and every
assertion still passed while the strip announced N events and the list
showed N-1. My half-open test had picked the FIRST occupied bucket; the
right-closed rule only exists for the LAST. New test selects the last
bucket and asserts the item at the exact span end is inside the window —
mutation-checked with precisely the deletion CodeRabbit described.

CodeRabbit — bucket labels were UTC (an ISO slice) while feed rows render
local time, so a non-UTC reader saw a bucket labelled 09:15 holding rows
that read 14:45. Labels now go through the same Intl local-time path,
asserted by equality with the formatter so the test holds in any timezone.

CodeRabbit — an empty FILTERED list fell back to "No events yet", flatly
contradicting the count line ("0 of 41 events"). A filtered empty now says
what is true: the stream holds N events, the filter hides them, click the
highlighted bucket to clear.

Qodo — 24 fixed buckets at a 390px viewport rendered ~12px slivers, below
any usable touch target. The bucket count now adapts to the measured strip
width (viewport-estimated when the page is hidden at render time), keeping
every button at the WCAG 2.5.8 24px minimum across the full 44px strip
height; fewer, wider buckets carry the same story. Browser test measures
button widths at 390px.

Qodo — timelineSwimlaneModel exceeded the 50-line rule; the per-event
application is extracted into swimlaneApplyEvent (33 + 24 lines), tests
unchanged and green as the behaviour proof.

Qodo — fixtureDenseRun clobbered a composed budget, the exact class it
caught on #533: projectShell rewrites budget.json, so dense after spend
replaced the deliberate $10 cap with the $25 default. Dense now passes
keepExistingBudget (it does not care about the budget, so it must not vote
on it); regression test pins the composed cap.

1998/1998 core, 67/67 browser, lint 0 errors, typecheck clean.

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

Copy link
Copy Markdown
Owner Author

All six findings fixed in 5da8024 — and CodeRabbit's Major deserves a callout

CodeRabbit: the final bucket had no coverage (Major)

Correct, and the review named the surviving mutation exactly: delete the + 1 and every assertion still passes while the strip announces N events and the filtered list shows N−1. My half-open test picked the first occupied bucket; the right-closed rule only exists for the last. I wrote the special case and then tested around it.

New test selects the last bucket and asserts the item at the exact span end is inside the window. Mutation-checked with precisely the deletion you described: fails without the +1, passes with it.

CodeRabbit: UTC bucket labels beside local-time rows

Real — my own screenshots even show it (rows reading 11:00 AM GMT+5:30 under buckets that would have said 05:30). Labels now go through the same Intl local-time path the rows use. The test asserts label-equals-formatter-output, so it holds in whatever timezone CI runs in.

CodeRabbit: an empty filtered window claimed "No events yet"

Real honesty bug — the copy contradicted the count line ("0 of 41 events") on the same panel. A filtered empty now states what is true: the stream holds N events, the filter hides them, click the highlighted bucket to clear.

Qodo: ~12px buckets at 390px

Real, and the right fix turned out to be better data-viz, not bigger CSS: the bucket count now adapts to the measured strip width (viewport-estimated when the page is hidden at render time), so a phone gets ~12 wide buckets instead of 24 slivers. Every button keeps the WCAG 2.5.8 24px minimum across the full 44px strip height — stated as 2.5.8 AA deliberately rather than claiming the 44px AAA figure, which horizontally-packed data buckets cannot honestly meet. Browser test measures the widths at 390px.

Qodo: timelineSwimlaneModel over 50 lines

Split — the per-event application is now swimlaneApplyEvent (33 + 24 lines). Tests unchanged and green as the behaviour proof.

Qodo: fixtureDenseRun clobbered a composed budget

The exact class you caught on #533, reintroduced by me in the very fixture built because of that lesson. projectShell gains keepExistingBudget; dense passes it, because a fixture that does not care about the budget must not vote on it. Regression test pins the composed $10 cap. The default stays last-wins — dashboard-533-spend.test.js deliberately relies on it and its EXPECTED_CAP guard exists for exactly that.


Verification: 1998/1998 core (+4), 67/67 browser, lint 0 errors, typecheck clean, security baseline OK.

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.

Data-viz: event-density timeline + Run Workspace swimlane (#500 final children)

2 participants