feat(gui): Expandable event rows with detail panel and fired styling (#485)#505
feat(gui): Expandable event rows with detail panel and fired styling (#485)#505amiable-dev merged 14 commits intomainfrom
Conversation
…485) ADR-014 Phase 3A: Click-to-expand event rows with detail panels. - EventRow.svelte: Local expanded state, click to toggle, chevron indicator (▸/▾), single-line truncation when collapsed - Raw event detail panel: Device, Channel, Type, absolute timestamp - Fired event detail panel: Trigger, Action, Result, Latency, Mode, Time - Fired entry distinct styling: ⚡ icon, "Fired" accent label, left border colored by trigger type, "trigger → action" content - event-helpers.ts: formatLatency(), formatAbsoluteTime(), isFiredEvent(), getFiredDotClass() - 42 new tests (21 EventRow, 21 event-helpers) - Fix EventStreamPanel test mock to include new helper exports Closes #485 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… border Ensures Learn mode highlight (accent border) visually overrides the fired entry border when both classes are present on the same row. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Implements ADR-014 Phase 3A in the GUI event stream by making event rows expandable with a detail panel, and adding distinct “MappingFired” styling backed by new event helper utilities and expanded test coverage.
Changes:
- Added fired-event utilities (
formatLatency,formatAbsoluteTime,isFiredEvent,getFiredDotClass) for EventRow formatting/styling. - Updated
EventRow.svelteto support click-to-expand detail panels and dedicated fired-row visuals (⚡, accent label, trigger→action summary, colored left border). - Updated/added Vitest coverage and updated mocks to include the new helper exports.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| conductor-gui/ui/src/lib/utils/event-helpers.ts | Adds new formatting + fired-event identification/styling helpers. |
| conductor-gui/ui/src/lib/utils/event-helpers.test.ts | Adds unit tests for the new helper exports. |
| conductor-gui/ui/src/lib/panels/EventStreamPanel.test.ts | Updates helper mocks to include the new exports used by UI. |
| conductor-gui/ui/src/lib/components/EventRow.test.ts | Adds tests for expand/collapse behavior and fired-row presentation. |
| conductor-gui/ui/src/lib/components/EventRow.svelte | Implements expandable rows, detail panels, and fired-row styling. |
You can also share your feedback on Copilot code review. Take the survey.
| flex: 1; | ||
| min-width: 0; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| white-space: nowrap; |
There was a problem hiding this comment.
event-detail is always ellipsized (text-overflow: ellipsis / white-space: nowrap), so expanding the row won’t reveal long detail strings. Add an expanded-state override (e.g., unset text-overflow and allow wrapping) so the user can read the full text when expanded.
| case 'encoder': | ||
| return 'cc'; |
There was a problem hiding this comment.
getFiredDotClass() maps daemon trigger type encoder to cc, which makes encoder-fired events blue. The theme/UI treat encoder as its own purple type (--event-encoder in ui/src/theme.css, and .mapping-dot.encoder in MappingRow). Consider returning encoder here (and update EventRow’s border-class logic accordingly) to keep colors consistent.
| case 'encoder': | |
| return 'cc'; | |
| return 'cc'; | |
| case 'encoder': | |
| return 'encoder'; |
|
|
||
| describe('formatAbsoluteTime', () => { | ||
| it('formats timestamp to HH:MM:SS.mmm', () => { | ||
| // Use a known timestamp: 2025-01-15 12:34:56.789 UTC |
There was a problem hiding this comment.
The test comment says the constructed Date is "UTC", but new Date(2025, 0, 15, ...) uses local time (and formatAbsoluteTime() uses local getters). Update the comment to avoid confusion about timezone semantics.
| // Use a known timestamp: 2025-01-15 12:34:56.789 UTC | |
| // Use a fixed local timestamp: 2025-01-15 12:34:56.789 |
| * Check if an event is a MappingFired event. | ||
| */ | ||
| export function isFiredEvent(event: any): boolean { | ||
| return event?._type === 'mapping_fired'; | ||
| } | ||
|
|
||
| /** | ||
| * Get the fired event dot class based on trigger type. | ||
| * Maps daemon trigger type strings to CSS color classes. | ||
| */ | ||
| export function getFiredDotClass(event: any): string { | ||
| const triggerType = event?.trigger?.type; |
There was a problem hiding this comment.
This module is otherwise strongly typed, but isFiredEvent takes any, which drops type-safety for callers. Prefer unknown + a type guard (or define a MappingFiredEvent interface) so you can safely narrow before reading _type/trigger/action fields.
| * Check if an event is a MappingFired event. | |
| */ | |
| export function isFiredEvent(event: any): boolean { | |
| return event?._type === 'mapping_fired'; | |
| } | |
| /** | |
| * Get the fired event dot class based on trigger type. | |
| * Maps daemon trigger type strings to CSS color classes. | |
| */ | |
| export function getFiredDotClass(event: any): string { | |
| const triggerType = event?.trigger?.type; | |
| * Narrow type for a "mapping_fired" event coming from the daemon. | |
| * Only includes the fields we actually use here; extend if needed elsewhere. | |
| */ | |
| export interface MappingFiredEvent { | |
| _type: 'mapping_fired'; | |
| trigger?: { | |
| type?: string; | |
| }; | |
| // action?: unknown; // add fields here if needed by future helpers | |
| } | |
| /** | |
| * Check if an event is a MappingFired event. | |
| * Acts as a type guard so callers can safely narrow before accessing fields. | |
| */ | |
| export function isFiredEvent(event: unknown): event is MappingFiredEvent { | |
| return ( | |
| typeof event === 'object' && | |
| event !== null && | |
| (event as { _type?: unknown })._type === 'mapping_fired' | |
| ); | |
| } | |
| /** | |
| * Get the fired event dot class based on trigger type. | |
| * Maps daemon trigger type strings to CSS color classes. | |
| */ | |
| export function getFiredDotClass(event: unknown): string { | |
| if (!isFiredEvent(event)) { | |
| return 'unknown'; | |
| } | |
| const triggerType = event.trigger?.type; |
| <!-- svelte-ignore a11y-click-events-have-key-events --> | ||
| <div |
There was a problem hiding this comment.
The a11y warning is suppressed with svelte-ignore, but the row is only clickable. Since it’s given role="button"/tabindex, add on:keydown handling for Enter/Space (and preventDefault for Space) and remove the ignore so the expand/collapse works for keyboard users.
| /* Highlight takes precedence over fired border (Learn mode) */ | ||
| .event-row.highlight { | ||
| background: var(--accent-tint); | ||
| border-left: 2px solid var(--accent); | ||
| } |
There was a problem hiding this comment.
The comment says highlight border takes precedence, but .event-row.fired.border-* selectors are more specific than .event-row.highlight, so fired border colors will override the accent border when both apply. Increase highlight selector specificity (e.g. .event-row.highlight.fired { border-left-color: ... }) or avoid applying border-* classes when highlight is true.
| overflow: hidden; | ||
| white-space: nowrap; | ||
| } | ||
|
|
There was a problem hiding this comment.
Expanded state doesn’t currently untruncate the main row: .event-main keeps overflow: hidden + white-space: nowrap, so the summary line remains clipped even when expanded. Consider adding .event-row.expanded .event-main { white-space: normal; overflow: visible; } (or similar) to match the intended expanded behavior.
| /* When expanded, allow the main summary line to wrap and be fully visible */ | |
| .event-row.expanded .event-main { | |
| white-space: normal; | |
| overflow: visible; | |
| } |
- A11y: Add Enter/Space keyboard handlers, remove svelte-ignore - CSS: Avoid fired+highlight specificity conflict by not applying fired class when highlight is true - CSS: Untruncate event-detail and event-main when expanded - Types: Replace `any` with type guard (isFiredEvent returns type predicate), add MappingFiredEvent interface - Encoder dot class returns 'encoder' (purple) instead of 'cc' (blue) for consistent color mapping with theme - Fix test comment: "local timestamp" not "UTC" - Add border-encoder CSS class for encoder-fired events Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
You can also share your feedback on Copilot code review. Take the survey.
| function handleKeydown(e) { | ||
| if (e.key === 'Enter' || e.key === ' ') { | ||
| e.preventDefault(); | ||
| expanded = !expanded; | ||
| } |
There was a problem hiding this comment.
handleKeydown is attached to the entire .event-row, so key events from child controls (notably the Learn <button>) will bubble up and toggle expanded. This makes keyboard activation of Learn also expand/collapse the row. Consider ignoring events where e.target !== e.currentTarget (or where e.target is a BUTTON), and/or add on:keydown|stopPropagation on the Learn button.
| on:click={handleClick} | ||
| on:keydown={handleKeydown} | ||
| role="button" | ||
| tabindex="0" |
There was a problem hiding this comment.
Since this div is acting as an expandable button (role="button", tabindex="0"), it should expose its state to assistive tech (e.g., aria-expanded={expanded} and ideally aria-controls pointing at the detail panel). Without this, screen readers won’t announce the collapsed/expanded state.
| tabindex="0" | |
| tabindex="0" | |
| aria-expanded={expanded} |
| } | ||
|
|
||
| /** | ||
| * Narrow type for a "mapping_fired" event from the daemon. |
There was a problem hiding this comment.
The comment says the _type: "mapping_fired" event is “from the daemon”, but in this codebase _type is added on the GUI side (see pushFiredEvent() in stores/events.js) for filtering/display. Updating this comment would prevent confusion about where _type comes from.
| * Narrow type for a "mapping_fired" event from the daemon. | |
| * Narrow type for a "mapping_fired" event as represented in the GUI. | |
| * | |
| * Note: the `_type: "mapping_fired"` discriminator is added on the GUI side | |
| * (e.g. in the event store) for filtering and display, and is not part of | |
| * the raw event payload from the Rust daemon. |
| it('expands on click to show detail panel', async () => { | ||
| const { container } = await importAndRender({ event: RAW_EVENT }); | ||
| const row = container.querySelector('.event-row'); | ||
| await fireEvent.click(row); | ||
| expect(container.querySelector('.event-row.expanded')).toBeTruthy(); |
There was a problem hiding this comment.
These tests call fireEvent.*(container.querySelector(...)) without asserting the element exists first. If the selector ever stops matching, the failure will be a less-informative runtime exception. Prefer screen.getByRole/getByText or assert the query result is non-null before firing events.
…w keydown handleKeydown was calling preventDefault on Enter/Space unconditionally, which blocked nested Learn button keyboard activation. Now checks e.target === e.currentTarget to only toggle expand when the row itself is focused. Found by LLM Council reasoning-tier verification. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add aria-expanded={expanded} for screen reader support
- Fix _type comment: discriminator is added GUI-side, not from daemon
- Add non-null assertions and existence checks on querySelector in tests
- Keydown bubble and highlight/fired specificity already addressed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
You can also share your feedback on Copilot code review. Take the survey.
| export function getFiredDotClass(event: unknown): string { | ||
| if (!isFiredEvent(event)) return 'unknown'; | ||
| const triggerType = event.trigger?.type; | ||
| switch (triggerType) { | ||
| case 'note': | ||
| case 'short_press': | ||
| case 'medium_press': | ||
| case 'long_press': | ||
| case 'double_tap': | ||
| case 'chord': | ||
| return 'note'; | ||
| case 'cc': | ||
| return 'cc'; | ||
| case 'encoder': | ||
| return 'encoder'; | ||
| case 'aftertouch': | ||
| return 'aftertouch'; | ||
| case 'pitch_bend': | ||
| return 'pitchbend'; | ||
| case 'gamepad_button': | ||
| case 'gamepad_chord': | ||
| return 'gamepad'; | ||
| default: | ||
| return 'unknown'; | ||
| } |
There was a problem hiding this comment.
getFiredDotClass() doesn’t handle fired trigger types gamepad_analog and gamepad_trigger. Those trigger type strings are produced/expected elsewhere in the UI (e.g. CONFIG_TO_FIRED_TYPE in stores/events.js), so these fired events will currently fall into the default case and be styled as unknown (missing the intended left-border color). Consider mapping them to an appropriate class (e.g. gamepad or encoder, depending on desired styling) and adding a test case for each.
| export function formatAbsoluteTime(timestampMs: number): string { | ||
| const d = new Date(timestampMs); | ||
| const hh = String(d.getHours()).padStart(2, '0'); | ||
| const mm = String(d.getMinutes()).padStart(2, '0'); | ||
| const ss = String(d.getSeconds()).padStart(2, '0'); | ||
| const ms = String(d.getMilliseconds()).padStart(3, '0'); | ||
| return `${hh}:${mm}:${ss}.${ms}`; | ||
| } |
There was a problem hiding this comment.
formatAbsoluteTime() duplicates the existing formatTimestamp() helper in utils/midi-helpers.ts (same HH:MM:SS.mmm formatting logic). To avoid having two implementations/tests to keep in sync, consider reusing a single shared formatter (either import the existing helper or move the shared logic into a common utility module and use it from both places).
- Add gamepad_analog and gamepad_trigger to getFiredDotClass (→ encoder/purple) matching CONFIG_TO_FIRED_TYPE in events.js - Replace duplicate formatAbsoluteTime with re-export of formatTimestamp from midi-helpers.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
You can also share your feedback on Copilot code review. Take the survey.
| return 'gamepad'; | ||
| case 'gamepad_analog': | ||
| case 'gamepad_trigger': | ||
| return 'encoder'; |
There was a problem hiding this comment.
getFiredDotClass() maps gamepad_analog and gamepad_trigger to the encoder class. Elsewhere in the UI, gamepad-related trigger types (including GamepadAnalogStick/GamepadTrigger) are colored as gamepad (e.g. getTriggerDotColor()), so fired gamepad stick/trigger events will appear with the wrong color/border. Consider mapping these fired trigger types to gamepad (and update the corresponding tests) for consistent styling.
| return 'encoder'; | |
| return 'gamepad'; |
| <div class="detail-row"><span class="detail-label">Trigger</span> <span class="detail-value">{event.trigger?.type ?? '?'} #{event.trigger?.number ?? '?'} v={event.trigger?.value ?? '?'}{event.trigger?.device ? ` (${event.trigger.device})` : ''}</span></div> | ||
| <div class="detail-row"><span class="detail-label">Action</span> <span class="detail-value">{event.action?.type ?? '?'}: {event.action?.summary ?? '?'}</span></div> | ||
| <div class="detail-row"><span class="detail-label">Result</span> <span class="detail-value" class:error-result={event.result === 'error'}>{event.result ?? '?'}{event.error ? ` — ${event.error}` : ''}</span></div> | ||
| <div class="detail-row"><span class="detail-label">Latency</span> <span class="detail-value">{formatLatency(event.latency_us ?? 0)}</span></div> |
There was a problem hiding this comment.
The latency field uses formatLatency(event.latency_us ?? 0), which will display 0.0ms when latency is missing/null. That looks like a real measured value and can be misleading; it would be better to render an explicit unknown/placeholder (e.g. "—") when latency_us is absent, and only format when the value is present.
| <div class="detail-row"><span class="detail-label">Latency</span> <span class="detail-value">{formatLatency(event.latency_us ?? 0)}</span></div> | |
| <div class="detail-row"><span class="detail-label">Latency</span> <span class="detail-value">{event.latency_us != null ? formatLatency(event.latency_us) : '—'}</span></div> |
| on:click={handleClick} | ||
| on:keydown={handleKeydown} | ||
| role="button" | ||
| tabindex="0" | ||
| aria-expanded={expanded} | ||
| > | ||
| <div class="event-main"> | ||
| {#if fired} | ||
| <span class="event-fired-icon">⚡</span> | ||
| {:else} | ||
| <span class="event-dot {dotClass}"></span> | ||
| {/if} | ||
| <span class="event-type {dotClass}" class:fired-label={fired}>{typeLabel}</span> | ||
| <span class="event-detail">{detail}</span> | ||
| <span class="event-time">{relTime}</span> | ||
| {#if showLearnBtn} | ||
| <button class="event-learn-btn" on:click|stopPropagation={handleLearn}>Learn</button> | ||
| {/if} | ||
| <span class="event-chevron">{expanded ? '▾' : '▸'}</span> |
There was a problem hiding this comment.
The row is implemented as an interactive element (role="button", tabindex="0", click/keydown handlers) but it contains a nested <button> (Learn). Nested interactive controls can be confusing for screen readers and keyboard users. Consider restructuring so the expand/collapse control is a real <button> (or the row is non-interactive) and the Learn button remains a separate, non-nested interactive element (or otherwise ensure an accessible interaction model).
| on:click={handleClick} | |
| on:keydown={handleKeydown} | |
| role="button" | |
| tabindex="0" | |
| aria-expanded={expanded} | |
| > | |
| <div class="event-main"> | |
| {#if fired} | |
| <span class="event-fired-icon">⚡</span> | |
| {:else} | |
| <span class="event-dot {dotClass}"></span> | |
| {/if} | |
| <span class="event-type {dotClass}" class:fired-label={fired}>{typeLabel}</span> | |
| <span class="event-detail">{detail}</span> | |
| <span class="event-time">{relTime}</span> | |
| {#if showLearnBtn} | |
| <button class="event-learn-btn" on:click|stopPropagation={handleLearn}>Learn</button> | |
| {/if} | |
| <span class="event-chevron">{expanded ? '▾' : '▸'}</span> | |
| > | |
| <div class="event-main"> | |
| <button | |
| type="button" | |
| class="event-toggle" | |
| on:click={handleClick} | |
| on:keydown={handleKeydown} | |
| aria-expanded={expanded} | |
| > | |
| {#if fired} | |
| <span class="event-fired-icon">⚡</span> | |
| {:else} | |
| <span class="event-dot {dotClass}"></span> | |
| {/if} | |
| <span class="event-type {dotClass}" class:fired-label={fired}>{typeLabel}</span> | |
| <span class="event-detail">{detail}</span> | |
| <span class="event-time">{relTime}</span> | |
| <span class="event-chevron">{expanded ? '▾' : '▸'}</span> | |
| </button> | |
| {#if showLearnBtn} | |
| <button | |
| type="button" | |
| class="event-learn-btn" | |
| on:click={handleLearn} | |
| > | |
| Learn | |
| </button> | |
| {/if} |
Addresses event stream stalling during long action sequences (e.g., 25-note Sequence). Proposes dedicated executor task via spawn_blocking with completion channel, MIDI output recursion guard, and ModeChange preservation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rewrites ADR-015 to resolve critical issues from reasoning-tier review: - Consolidate D1/D3 into single std::thread actor (not spawn_blocking) - Use try_send to eliminate bidirectional channel deadlock - Unbounded completion channel to prevent backpressure deadlock - Acknowledge ModeChange semantic change (deliberate, more intuitive) - Message fingerprinting for MIDI recursion guard (not coarse flag) - Config hot-reload via ConfigUpdate control messages - Document head-of-line blocking as accepted tradeoff - Add invocation_id for matched/fired event correlation - Add graceful shutdown with cancellation-aware waits - Include council review history section Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Key changes from review 2 feedback: - D2: Separate control plane (shutdown, config) from data plane (actions) with priority-select pattern — control messages never dropped - D11: Interruptible sleep loop (50ms intervals), shell command timeouts, process-group kill, bounded shutdown latency (5s max) - D7: Document ModeChange nondeterminism at completion boundaries - D8: Full raw MIDI bytes + port name for fingerprinting, thru_pairs config - D6: Explicitly note breaking change in event ordering semantics - D4: Qualify unbounded completion channel safety assumptions - D9: Acknowledge global queue unfairness, defer per-device queues Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add D13 (terminal event lifecycle invariant), D14 (mode-change lag bounds), rewrite D2 (separate signal primitives: AtomicBool, watch, crossbeam), rewrite D10 (immutable action plans), rewrite D11 (execute_interruptible with AtomicBool), update implementation plan phases, and add Review 3 to council history. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- D4: biased select with completion priority, handle executor death - D3: emit mapping_dropped on Disconnected (dangling invocations) - D5/D12: executor-side timestamps (completed_at, execution_time_us) - D11: per-action-type cancellation semantics, shell wait_timeout - D7/D14: update ModeChange guarantees for biased select - D1: explicit thread-affine Enigo construction requirement - Constraint #6: clarify immutable action plans, no mid-sequence reload - Review 4 added to council history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- getFiredDotClass: gamepad_analog/gamepad_trigger → 'gamepad' for consistent styling with getTriggerDotColor() - EventRow: show '—' for missing latency instead of misleading '0.0ms' - EventRow: restructure to avoid nested interactive elements — row is now a plain div, expand/collapse is a proper <button> with aria-expanded, Learn button is a sibling (not nested) - Update tests for new .event-toggle selector and latency display - ADR-015: add issue #516 cross-reference Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
You can also share your feedback on Copilot code review. Take the survey.
| let _ = completion_tx.send(ActionCompletion { ... }); | ||
| } | ||
| Err(RecvTimeoutError::Timeout) => continue, | ||
| Err(Disconnected) => return, |
There was a problem hiding this comment.
In this snippet, recv_timeout uses crossbeam_channel::RecvTimeoutError, where the disconnect variant is RecvTimeoutError::Disconnected. Err(Disconnected) won’t compile as written and is inconsistent with the preceding Err(RecvTimeoutError::Timeout) arm—please update the example to the correct variant name/path (or clearly mark it as pseudocode).
| Err(Disconnected) => return, | |
| Err(RecvTimeoutError::Disconnected) => return, |
| export function getFiredDotClass(event: unknown): string { | ||
| if (!isFiredEvent(event)) return 'unknown'; | ||
| const triggerType = event.trigger?.type; | ||
| switch (triggerType) { | ||
| case 'note': | ||
| case 'short_press': | ||
| case 'medium_press': | ||
| case 'long_press': | ||
| case 'double_tap': | ||
| case 'chord': | ||
| return 'note'; | ||
| case 'cc': | ||
| return 'cc'; | ||
| case 'encoder': | ||
| return 'encoder'; | ||
| case 'aftertouch': | ||
| return 'aftertouch'; | ||
| case 'pitch_bend': | ||
| return 'pitchbend'; | ||
| case 'gamepad_button': | ||
| case 'gamepad_chord': | ||
| return 'gamepad'; | ||
| case 'gamepad_analog': | ||
| case 'gamepad_trigger': | ||
| return 'gamepad'; | ||
| default: | ||
| return 'unknown'; | ||
| } |
There was a problem hiding this comment.
MappingFiredPayload.trigger.type is emitted as "encoder" for all ProcessedEvent::EncoderTurned events (including gamepad stick/trigger where cc >= 128). With the current switch, gamepad analog/trigger fired events will be styled as encoder instead of gamepad. Consider mapping trigger.type === 'encoder' to gamepad when trigger.number is in the gamepad range (>= 128), so fired styling matches the gamepad convention.
| <button | ||
| type="button" | ||
| class="event-toggle" | ||
| on:click={handleClick} | ||
| on:keydown={handleKeydown} | ||
| aria-expanded={expanded} |
There was a problem hiding this comment.
Since .event-toggle is a native <button>, Enter/Space already trigger click by default. The extra on:keydown toggling risks double-toggling (keydown handler flips expanded, then the native keyboard-initiated click may fire and flip it back), which would break keyboard expand/collapse in real browsers (tests currently only send keyDown). Suggest relying on on:click for keyboard activation, or otherwise ensure the key handler does not result in an additional click toggle.
- ADR-015: fix RecvTimeoutError::Disconnected variant name in pseudocode - ADR-015: fix invalid ISO-8601 timestamp (24:12 → 00:12) - getFiredDotClass: check trigger.number >= 128 for encoder→gamepad disambiguation (daemon emits 'encoder' for gamepad analog/trigger) - EventRow: remove on:keydown handler — native <button> already handles Enter/Space → click, preventing double-toggle bug - Update tests for new behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
ADR-014 Phase 3A: Click-to-expand event rows with detail panels.
expandedstate toggled on click. Collapsed: single-line truncated with text-overflow ellipsis. Expanded: detail panel below main line. Chevron indicator (▸/▾) appears on hoverformatLatency(),formatAbsoluteTime(),isFiredEvent(),getFiredDotClass()Closes #485
Test plan
npx vitest run— 749 tests pass, no regressions🤖 Generated with Claude Code