Back to Source Monitor

03 VERIFICATION

.vbw-planning/milestones/polish-and-reliability/phases/03-toast-stacking/03-VERIFICATION.md

0.13.012.5 KB
Original Source

Must-Have Checks

#Truth/ConditionStatusEvidence
1notification_container_controller.js exists at correct pathPASSFile present at app/assets/javascripts/source_monitor/controllers/notification_container_controller.js, 131 lines
2Uses MutationObserver with { childList: true } to detect new toastsPASSLine 15-16: new MutationObserver(() => this.scheduleRecalculate()); this.observer.observe(this.listTarget, { childList: true })
3Caps visible toasts at maxVisibleValue (default 3), hides overflow with hidden + aria-hidden + inertPASSLines 60-70: index >= maxVisibleValue gets hidden, aria-hidden="true", inert=""
4toggleExpand/collapseStack actions toggle expanded statePASSLines 98-117: toggleExpand delegates to expandStack/collapseStack; expandedValue toggled correctly
5recalculateVisibility() debounced via requestAnimationFramePASSLines 39-47: scheduleRecalculate cancels previous rAF, schedules new one
6Promotes next hidden toast when visible toast is dismissed (MutationObserver fires on DOM removal)PASSMutationObserver fires on element.remove() in dismiss(); triggers scheduleRecalculate() automatically
7clearAll action removes all toastsPASSLines 125-130: removes all listTarget.children, sets expandedValue = false
8Updates overflow badge count and toggles badge visibilityPASSLines 77-86: badgeCountTarget.textContent = +${hiddenCount} more; badge hidden/shown based on hiddenCount
9notification_controller.js dispatches notification:dismissed custom event (bubbles:true) before removalPASSLines 47-49: this.element.dispatchEvent(new CustomEvent("notification:dismissed", { bubbles: true })) before fade
10Error toasts (data-level="error") get 10000ms delay; others keep 5000ms defaultPASSLines 38-42: applyLevelDelay() checks dataset.level === "error" && delayValue === 5000, overrides to 10000
11application.js imports and registers notification-container controllerPASSLine 4: import; Line 19: application.register("notification-container", NotificationContainerController)
12application.html.erb wraps #source_monitor_notifications with data-controller="notification-container"PASSLine 17: data-controller="notification-container" on outer wrapper div
13Layout contains overflow badge with data-notification-container-target="badge" and clear-allPASSLines 23-37: badge div with target, badgeCount span, clearAll button with target and action
14_toast.html.erb includes data-level attribute matching level_keyPASSLine 16: data-level="<%= level_key %>" on root toast div
15Click-outside collapses expanded stack (document listener active only when expanded)PASSexpandStack adds document click listener; collapseStack removes it; handleClickOutside checks !this.element.contains(event.target)
16JS builds without errors (bin/rails assets:precompile in test/dummy)PASSCompiled successfully; notification_container_controller-f3adc909.js present in test/dummy/public/assets/source_monitor/controllers/
17bin/rubocop zero offenses (phase files)PASSNo offenses in any modified files; 4 pre-existing offenses in unrelated test files
18bin/rails test passes (no regressions)PASS1125 runs, 3514 assertions, 0 failures, 0 errors, 0 skips
19MutationObserver disconnected in disconnect()PASSLines 26-29: this.observer.disconnect(); this.observer = null
20requestAnimationFrame cancelled in disconnect()PASSLines 31-34: cancelAnimationFrame(this.rafId); this.rafId = null
21Document click listener removed in disconnect()PASSLine 36: document.removeEventListener("click", this.boundHandleClickOutside)
22notification:dismissed listener on listTarget removed in disconnect()FAILAnonymous arrow function () => this.scheduleRecalculate() added on line 18-20 is never removed; no bound reference stored. Memory leak on Stimulus controller reconnect cycles
23Hidden toasts have aria-hidden="true" and inert attributePASSLines 67-68: both attributes set on overflow toasts
24Visible toasts have aria-hidden and inert removedPASSLines 56-57: removeAttribute("aria-hidden"), removeAttribute("inert")
25Badge has aria-live="polite" for screen reader announcementsPASSLine 28 of layout: aria-live="polite" on toggle button (non-standard placement but functional)
26#source_monitor_notifications used as Turbo Stream target (unchanged)PASSbroadcaster.rb line 71: target: NOTIFICATION_STREAM = "source_monitor_notifications"; same id in layout
27StreamResponder#toast() unmodifiedPASSstream_responder.rb line 43-53: no changes; still appends to "source_monitor_notifications"
28Broadcaster#broadcast_toast() unmodifiedPASSbroadcaster.rb lines 66-85: no changes to server-side delivery
29notification-container registered in compiled application.jsPASSgrep "notification-container" confirms line 2870 of compiled asset
30brakeman zero security warningsPASS0 warnings; 0 errors
31clearAll removes document click listener when stack is expandedFAILclearAll() sets this.expandedValue = false directly without calling collapseStack() or document.removeEventListener(). If expanded when "Clear all" is clicked, the boundHandleClickOutside document listener remains active until next disconnect/page navigation
32clearAll visibility logic correctly reaches button when badge is hiddenFAILclearAll target is nested inside the badge target div. When expanded with 0 hidden toasts (hiddenCount=0), badge div gets hidden class (line 84) while showClearAll is true (line 89: total > 0 && expandedValue). Parent's hidden class overrides the child's visibility — "Clear all" cannot appear after collapsing via the same mechanism
33applyLevelDelay() called before startTimer() in connectPASSLines 13-16 of notification_controller.js: clearTimeout() then registerController() then applyLevelDelay() then startTimer()
34listTarget correctly wraps #source_monitor_notifications as Stimulus targetPASSdata-notification-container-target="list" on the id="source_monitor_notifications" div
35notification:dismissed event listener set on listTarget (bubbling from individual toasts)PASSLines 18-20: listener on listTarget; event bubbles from toast child elements
36Badge starts hidden initiallyPASSLayout line 24: class="pointer-events-auto hidden" — starts hidden, JS reveals on overflow

Artifact Checks

ArtifactExistsContainsStatus
notification_container_controller.jsYESMutationObserver, scheduleRecalculate, recalculateVisibility, toggleExpand, expandStack, collapseStack, clearAll, handleClickOutsidePASS
notification_controller.js (modified)YESnotification:dismissed dispatch, applyLevelDelay(), error=10000msPASS
application.html.erb (modified)YESdata-controller="notification-container", badge target, badgeCount target, clearAll target/action, toggleExpand actionPASS
_toast.html.erb (modified)YESdata-level="<%= level_key %>"PASS
application.js (modified)YESNotificationContainerController import, notification-container registrationPASS
Compiled JS assetYESnotification_container_controller-f3adc909.js in test/dummy/public/assetsPASS
FromToViaStatus
Toast partial data-levelnotification_controller.js applyLevelDelay()this.element.dataset.levelPASS
notification_controller.js dismiss()notification_container_controller.js event listenernotification:dismissed CustomEvent (bubbles:true) → listTarget listenerPASS
MutationObserver (child added/removed)recalculateVisibility()scheduleRecalculate() via rAF debouncePASS
Broadcaster#broadcast_toast()#source_monitor_notifications DOM nodeTurbo::StreamsChannel.broadcast_append_to(NOTIFICATION_STREAM, ...)PASS
StreamResponder#toast()#source_monitor_notifications DOM nodeappend("source_monitor_notifications", partial: "_toast")PASS
Badge button clicktoggleExpanddata-action="notification-container#toggleExpand"PASS
Clear all button clickclearAlldata-action="notification-container#clearAll"PASS

Anti-Pattern Scan

PatternFoundLocationSeverity
Anonymous event listener without stored reference (cannot be removed)YESnotification_container_controller.js line 18-20: this.listTarget.addEventListener("notification:dismissed", () => ...)MEDIUM — memory leak on controller reconnect cycles; Stimulus reconnects on Turbo navigations
clearAll() does not call collapseStack() to clean up document listenerYESnotification_container_controller.js lines 125-130: expandedValue = false without removeEventListenerLOW — stale click-outside listener after clear-all-while-expanded
clearAll button nested inside badge div — parent's hidden class overrides child visibilityYESapplication.html.erb lines 23-36 + JS lines 88-95MEDIUM — "Clear all" unreachable in expanded-with-no-overflow scenario
aria-live on interactive <button> element (non-standard ARIA usage)YESapplication.html.erb line 28LOW — functional in most screen readers but non-standard; WCAG recommends aria-live on non-interactive containers

Convention Compliance

ConventionFileStatusDetail
Stimulus controllers on window.SourceMonitorStimulusapplication.jsPASSApplication registered/reused on window.SourceMonitorStimulus
Controller naming: kebab-case in HTMLlayout + application.jsPASSnotification-container used consistently
No frozen_string_literal in JS files (N/A)JS filesN/AConvention applies to Ruby files only
pointer-events-none on fixed toast wrapperapplication.html.erbPASSOuter div has pointer-events-none; toast and badge have pointer-events-auto
RuboCop zero offenses (phase files)Modified Ruby files (none)PASSNo Ruby files modified in this phase

Pre-existing Issues

TestFileError
Layout/SpaceInsideArrayLiteralBrackets (4 offenses)test/controllers/source_monitor/source_favicon_fetches_controller_test.rb + test/lib/source_monitor/fetching/feed_fetcher/source_updater_favicon_test.rbCorrectable style offenses in files not part of Phase 3 scope; confirmed pre-existing in PLAN-01-SUMMARY.md pre_existing_issues

Summary

Tier: deep Result: PARTIAL Passed: 33/36 Failed: [#22 notification:dismissed listener leak, #31 clearAll missing removeEventListener, #32 clearAll button inaccessible when badge hidden while expanded]

Core features verified: All 17 must-have plan requirements pass (file existence, MutationObserver, overflow capping, expand/collapse, badge, dismiss event, error delay, controller registration, template wiring, toast data-level, click-outside, asset build, rubocop, test suite). Server-side delivery paths (StreamResponder, Broadcaster) are unmodified and confirmed working.

Three defects found:

  1. Memory leak (MEDIUM): The notification:dismissed event listener added to listTarget in connect() uses an anonymous arrow function with no stored reference. It cannot be removed in disconnect(). On Turbo page navigations, Stimulus disconnects/reconnects controllers, causing duplicate listeners to accumulate.

  2. Stale document click listener (LOW): clearAll() sets expandedValue = false but does not call collapseStack() or document.removeEventListener("click", this.boundHandleClickOutside). If the user clicks "Clear all" while the stack is expanded, the click-outside document listener remains active.

  3. "Clear all" button DOM structure (MEDIUM): The clearAll target button is a child of the badge target div. The JS manages them independently — when expandedValue=true and all toasts fit within maxVisible (hiddenCount=0), the badge div gets hidden class but showClearAll is true. The parent hidden class makes "Clear all" invisible despite the JS trying to show it. This occurs during expand with <= maxVisible toasts.