Back to Source Monitor

.Context Dev

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

0.13.028.8 KB
Original Source

Phase 03 Context

Goal

Not available

Codebase Map Available

Codebase mapping exists in .vbw-planning/codebase/. Key files:

  • ARCHITECTURE.md
  • CONCERNS.md
  • PATTERNS.md
  • DEPENDENCIES.md
  • STRUCTURE.md
  • CONVENTIONS.md
  • TESTING.md
  • STACK.md

Read CONVENTIONS.md, PATTERNS.md, STRUCTURE.md, and DEPENDENCIES.md first to bootstrap codebase understanding.

Changed Files (Delta)

  • .gitignore
  • .vbw-planning/config.json
  • .vbw-planning/discovery.json
  • .vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md
  • .vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md
  • .vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md
  • .vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md
  • .vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md
  • .vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md
  • .vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md
  • .vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md
  • .vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md
  • .vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md
  • .vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md
  • .vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md
  • .vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md
  • .vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md
  • .vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md
  • .vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md
  • .vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md
  • .vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md
  • .vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md
  • .vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md
  • .vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md
  • .vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md
  • .vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md
  • .vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md
  • .vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md
  • .vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md
  • .vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md
  • .vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md
  • .vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md
  • .vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md
  • .vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md
  • .vbw-planning/milestones/default/ROADMAP.md
  • .vbw-planning/milestones/default/STATE.md
  • .vbw-planning/phases/01-aia-certificate-resolution/.context-dev.md
  • .vbw-planning/phases/01-aia-certificate-resolution/PLAN-01-SUMMARY.md
  • .vbw-planning/phases/01-aia-certificate-resolution/PLAN-01.md
  • .vbw-planning/phases/01-aia-certificate-resolution/PLAN-02-SUMMARY.md
  • .vbw-planning/phases/01-aia-certificate-resolution/PLAN-02.md
  • .vbw-planning/phases/01-aia-certificate-resolution/PLAN-03-SUMMARY.md
  • .vbw-planning/phases/01-aia-certificate-resolution/PLAN-03.md
  • .vbw-planning/phases/02-test-performance/.context-dev.md
  • .vbw-planning/phases/02-test-performance/.context-lead.md
  • .vbw-planning/phases/02-test-performance/.context-qa.md
  • .vbw-planning/phases/02-test-performance/02-RESEARCH.md
  • .vbw-planning/phases/02-test-performance/02-VERIFICATION.md
  • .vbw-planning/phases/02-test-performance/PLAN-01-SUMMARY.md
  • .vbw-planning/phases/02-test-performance/PLAN-01.md
  • .vbw-planning/phases/02-test-performance/PLAN-02-SUMMARY.md
  • .vbw-planning/phases/02-test-performance/PLAN-02.md
  • .vbw-planning/phases/02-test-performance/PLAN-03-SUMMARY.md
  • .vbw-planning/phases/02-test-performance/PLAN-03.md
  • .vbw-planning/phases/02-test-performance/PLAN-04-SUMMARY.md
  • .vbw-planning/phases/02-test-performance/PLAN-04.md
  • .vbw-planning/ROADMAP.md
  • .vbw-planning/STATE.md
  • app/assets/builds/source_monitor/application.css
  • test/dummy/Gemfile.lock

Code Slices

.gitignore (32 lines)

/.bundle/
/doc/
/log/*.log
/pkg/
/tmp/
/node_modules/
/coverage/
/test/dummy/db/*.sqlite3
/test/dummy/db/*.sqlite3-*
/test/dummy/log/*.log*
/test/dummy/storage/
/test/dummy/tmp/
/test/tmp/
/test/lib/tmp/install_generator/config/routes.rb
/app/assets/builds/*
!/app/assets/builds/.keep
!/app/assets/builds/source_monitor/
.vbw-planning/.cost-ledger.json
.vbw-planning/.notification-log.jsonl
.vbw-planning/.session-log.jsonl
.vbw-planning/.hook-errors.log
.vbw-planning/.claude-md-migrated
.vbw-planning/.watchdog-pid
.vbw-planning/.watchdog.log
.vbw-planning/.agent-pids
.vbw-planning/.agent-panes
.vbw-planning/.active-agent
.vbw-planning/.active-agent-count
.vbw-planning/.todo-flat-migrated
/codebase_analysis.md
*.gem
.vbw-worktrees/

.vbw-planning/config.json (44 lines)

{
  "effort": "thorough",
  "autonomy": "standard",
  "auto_commit": true,
  "planning_tracking": "manual",
  "auto_push": "never",
  "verification_tier": "standard",
  "skill_suggestions": true,
  "auto_install_skills": false,
  "discovery_questions": true,
  "context_compiler": true,
  "visual_format": "unicode",
  "max_tasks_per_plan": 5,
  "prefer_teams": "always",
  "branch_per_milestone": false,
  "plain_summary": true,
  "active_profile": "default",
  "custom_profiles": {},
  "model_profile": "quality",
  "model_overrides": {},
  "agent_max_turns": {
    "scout": 15,
    "qa": 25,
    "architect": 30,
    "debugger": 80,
    "lead": 50,
    "dev": 75
  },
  "qa_skip_agents": [
    "docs"
  ],
  "worktree_isolation": "on",
  "token_budgets": false,
  "two_phase_completion": false,
  "metrics": false,
  "smart_routing": false,
  "validation_gates": false,
  "snapshot_resume": false,
  "lease_locks": false,
  "event_recovery": false,
  "monorepo_routing": false,
  "rolling_summary": false,
  "compaction_trigger": 130000
}

.vbw-planning/discovery.json (93 lines, first 30 shown)

{
  "answered": [
    {
      "question": "What matters most in the conventions cleanup?",
      "answer": "All of the above: Model conventions, Controller patterns, Dead code removal",
      "category": "scope",
      "phase": "4",
      "date": "2026-02-10"
    },
    {
      "question": "How should we handle convention violations that would change public API behavior?",
      "answer": "Fix everything -- rename/restructure even if it changes method signatures or route patterns",
      "category": "api-policy",
      "phase": "4",
      "date": "2026-02-10"
    },
    {
      "question": "Favicon discovery strategy?",
      "answer": "Multi-strategy cascade: /favicon.ico -> HTML parsing (full GET, Nokogiri, prefer largest) -> Google Favicon API. Skip DuckDuckGo.",
      "area": "favicon-discovery",
      "phase": "02",
      "date": "2026-02-20"
    },
    {
      "question": "How to handle downloaded favicons before storage?",
      "answer": "Store raw original via Active Storage, define two variants: 32x32 (standard) and 64x64 (retina). SVGs stored as-is AND rasterized to PNG.",
      "area": "image-processing",
      "phase": "02",
      "date": "2026-02-20"
    },

.vbw-planning/ROADMAP.md (72 lines, first 30 shown)

# Roadmap

## Milestone: polish-and-reliability

### Phases

1. [x] **Backend Fixes** -- Fix browser User-Agent default, health check status transitions, and smarter scrape rate limiting
2. [x] **Favicon Support** -- Automatically save source favicons via Active Storage with background fetch job
3. [ ] **Toast Stacking** -- Cap visible toast notifications with hover-to-expand for bulk operation UX

### Phase Details

#### Phase 1: Backend Fixes

**Goal:** Fix three independent backend issues: bot-blocked feeds due to User-Agent, health check not updating status, and overly aggressive scrape limiting.

**Requirements:**
- REQ-UA-01: Change default User-Agent from "SourceMonitor/VERSION" to a browser-like string
- REQ-HC-01: After a successful manual health check on a declining/critical/warning source, trigger SourceHealthMonitor re-evaluation or directly transition status to "improving"
- REQ-SL-01: Refine max_in_flight_per_source to only count actively-running scrape jobs (not queued ones)

**Success Criteria:**
- [ ] Default UA string resembles a real browser (e.g., Mozilla/5.0 compatible)
- [ ] Successful manual health check on a declining source transitions it to improving
- [ ] Scrape limit counts only actively-running jobs, queued items don't count toward the cap
- [ ] All existing tests pass, new tests cover changed behavior
- [ ] RuboCop zero offenses, Brakeman zero warnings

#### Phase 2: Favicon Support

.vbw-planning/STATE.md (30 lines)

# State

## Current Position

- **Milestone:** polish-and-reliability
- **Phase:** 3 -- Toast Stacking
- **Status:** Planned
- **Progress:** 67%
- **Plans:** 1 (0/1 complete)

## Decisions

| Decision | Date | Context |
|----------|------|---------|
| Active Storage for favicons | 2026-02-20 | has_one_attached with guard, consistent with ItemContent pattern |
| Smarter scrape limit | 2026-02-20 | Count only running jobs, not queued; keeps safety but removes false bottleneck |
| Browser-like default UA | 2026-02-20 | Simple global fix for bot-blocked feeds like Uber |
| Health check triggers status update | 2026-02-20 | Successful manual health check should transition declining -> improving |
| Toast cap + hover expand | 2026-02-20 | Max 3 visible, +N more badge, hover to see all |

## Todos

## Metrics

- **Started:** 2026-02-20
- **Phases:** 3
- **Tests at start:** 1033

## Blockers
None

app/assets/builds/source_monitor/application.css (2106 lines, first 30 shown)

*, ::before, ::after {
  --tw-border-spacing-x: 0;
  --tw-border-spacing-y: 0;
  --tw-translate-x: 0;
  --tw-translate-y: 0;
  --tw-rotate: 0;
  --tw-skew-x: 0;
  --tw-skew-y: 0;
  --tw-scale-x: 1;
  --tw-scale-y: 1;
  --tw-pan-x:  ;
  --tw-pan-y:  ;
  --tw-pinch-zoom:  ;
  --tw-scroll-snap-strictness: proximity;
  --tw-gradient-from-position:  ;
  --tw-gradient-via-position:  ;
  --tw-gradient-to-position:  ;
  --tw-ordinal:  ;
  --tw-slashed-zero:  ;
  --tw-numeric-figure:  ;
  --tw-numeric-spacing:  ;
  --tw-numeric-fraction:  ;
  --tw-ring-inset:  ;
  --tw-ring-offset-width: 0px;
  --tw-ring-offset-color: #fff;
  --tw-ring-color: rgb(59 130 246 / 0.5);
  --tw-ring-offset-shadow: 0 0 #0000;
  --tw-ring-shadow: 0 0 #0000;
  --tw-shadow: 0 0 #0000;
  --tw-shadow-colored: 0 0 #0000;

test/dummy/Gemfile.lock (406 lines, first 30 shown)

PATH
  remote: ../..
  specs:
    source_monitor (0.7.1)
      cssbundling-rails (~> 1.4)
      faraday (~> 2.9)
      faraday-follow_redirects (~> 0.4)
      faraday-gzip (~> 3.0)
      faraday-retry (~> 2.2)
      feedjira (>= 3.2, < 5.0)
      jsbundling-rails (~> 1.3)
      nokolexbor (~> 0.5)
      rails (>= 8.0.3, < 10.0)
      ransack (~> 4.2)
      ruby-readability (~> 0.7)
      solid_cable (>= 3.0, < 4.0)
      solid_queue (>= 0.3, < 3.0)
      turbo-rails (~> 2.0)

GEM
  remote: https://rubygems.org/
  specs:
    action_text-trix (2.1.16)
      railties
    actioncable (8.1.2)
      actionpack (= 8.1.2)
      activesupport (= 8.1.2)
      nio4r (~> 2.0)
      websocket-driver (>= 0.6.1)
      zeitwerk (~> 2.6)

Active Plan


phase: 3 plan: 1 title: "Toast Stacking: Container Controller, Templates, and Integration" wave: 1 depends_on: [] must_haves:

  • "notification_container_controller.js exists at app/assets/javascripts/source_monitor/controllers/notification_container_controller.js"
  • "notification_container_controller uses MutationObserver with childList:true to detect new toasts"
  • "notification_container_controller caps visible toasts at maxVisibleValue (default 3), hides overflow with hidden class + aria-hidden + inert"
  • "notification_container_controller toggleExpand/collapseStack actions toggle expanded state"
  • "notification_container_controller recalculateVisibility() debounced via requestAnimationFrame"
  • "notification_container_controller promotes next hidden toast when a visible toast is dismissed (MutationObserver fires on DOM removal)"
  • "notification_container_controller clearAll action removes all toasts"
  • "notification_container_controller updates overflow badge count and toggles badge visibility"
  • "notification_controller.js dispatches 'notification:dismissed' custom event (bubbles:true) before removal"
  • "Error toasts (data-level='error') get 10000ms delay; others keep 5000ms default"
  • "application.js imports and registers notification-container controller"
  • "application.html.erb wraps #source_monitor_notifications with data-controller='notification-container'"
  • "application.html.erb contains overflow badge with data-notification-container-target='badge' and clear-all link"
  • "_toast.html.erb includes data-level attribute matching level_key"
  • "Click-outside collapses expanded stack (document listener, active only when expanded)"
  • "JS builds without errors (bin/rails assets:precompile in test/dummy)"
  • "bin/rubocop zero offenses"
  • "bin/rails test passes (no regressions)" skills_used: []

Plan 01: Toast Stacking -- Full Implementation

Objective

Implement the complete toast stacking feature: a new notification_container_controller.js Stimulus controller that wraps the notification container, observes child mutations, caps visible toasts at a configurable max, manages expand/collapse state with click-outside, promotes hidden toasts on dismissal, and provides a "Clear all" action. Update the existing notification_controller.js to dispatch a dismiss event and support error-level delay. Wire everything together in the layout template with badge/clear-all HTML and add data-level to the toast partial. Covers REQ-TOAST-01, REQ-TOAST-02, REQ-TOAST-03, REQ-TOAST-04.

Context

  • @app/assets/javascripts/source_monitor/controllers/notification_controller.js -- existing per-toast Stimulus controller. Has dismiss() that fades out and removes element after 200ms. Delay default is 5000ms via Stimulus value. Must add custom event dispatch + error delay override.
  • @app/assets/javascripts/source_monitor/application.js -- Stimulus registration hub. 6 controllers registered. Must add notification-container import + register.
  • @app/views/layouts/source_monitor/application.html.erb lines 16-18 -- notification container: <div id="source_monitor_notifications" class="flex w-full max-w-sm flex-col gap-3"> inside a fixed positioned wrapper. Must add data-controller and Stimulus target/action markup, plus badge and clear-all HTML.
  • @app/views/source_monitor/shared/_toast.html.erb -- toast partial with level-based Tailwind classes. Must add data-level attribute for error delay detection.
  • @lib/source_monitor/turbo_streams/stream_responder.rb -- toast() appends to source_monitor_notifications. NO CHANGES NEEDED (server-side unaffected).
  • @lib/source_monitor/realtime/broadcaster.rb -- broadcast_toast() appends via ActionCable. NO CHANGES NEEDED.
  • @.vbw-planning/phases/03-toast-stacking/03-RESEARCH.md -- research findings on MutationObserver, debounce, accessibility.

Tasks

Task 1: Create notification_container_controller.js

Files: CREATE app/assets/javascripts/source_monitor/controllers/notification_container_controller.js

Create a Stimulus controller that manages toast overflow capping, expand/collapse, and clear-all.

Stimulus API:

  • Values: maxVisible (Number, default: 3), expanded (Boolean, default: false)
  • Targets: list (the toast container div), badge (overflow indicator), badgeCount (count text span), clearAll (clear-all link)
  • Actions: toggleExpand (badge click), clearAll (clear-all click)

Core implementation:

  1. connect(): Set up MutationObserver on listTarget with { childList: true }. Bind click-outside handler and notification:dismissed event listener on listTarget. Call recalculateVisibility().

  2. disconnect(): Clean up observer, cancel any pending rAF, remove document click listener, remove event listener.

  3. scheduleRecalculate(): Debounce via requestAnimationFrame -- cancel previous rAF if pending, schedule new one that calls recalculateVisibility().

  4. recalculateVisibility(): Core logic:

    • Get all direct children of listTarget (toast elements)
    • If expandedValue is true: show all toasts (remove hidden, aria-hidden, inert)
    • If not expanded: show first maxVisibleValue, hide the rest with hidden class + aria-hidden="true" + inert attribute
    • Calculate hiddenCount = Math.max(0, total - maxVisibleValue) (when not expanded); 0 when expanded
    • If hasBadgeTarget: update badgeCountTarget.textContent to +${hiddenCount} more; toggle badge visibility based on hiddenCount > 0
    • If hasClearAllTarget: toggle clear-all visibility based on total > 0 && (hiddenCount > 0 || expandedValue)
  5. toggleExpand(event): Prevent default. If expanded, call collapseStack(); else call expandStack().

  6. expandStack(): Set expandedValue = true. Call recalculateVisibility(). Add document click-outside listener.

  7. collapseStack(): Set expandedValue = false. Remove document click-outside listener. Call recalculateVisibility().

  8. handleClickOutside(event): If event.target is not within this.element, call collapseStack().

  9. clearAll(event): Prevent default. Get all direct children of listTarget, remove each one. Set expandedValue = false. (MutationObserver will fire and trigger scheduleRecalculate automatically.)

Accessibility:

  • Hidden toasts get aria-hidden="true" and inert attribute (prevents focus/interaction)
  • Visible toasts have these removed
  • Badge uses aria-live="polite" (set in template)

Important notes:

  • The notification:dismissed custom event fires before DOM removal -- the subsequent DOM removal triggers MutationObserver which schedules recalculate. This naturally handles promote-next-hidden behavior.
  • No animation for individual toast show/hide in overflow (toggle hidden class). The "slide" effect comes from the flex container naturally reflowing.
  • Both Turbo Stream appends and ActionCable broadcasts add children to the same DOM node, so MutationObserver catches both uniformly.

Task 2: Modify notification_controller.js for dismiss event and error delay

Files: MODIFY app/assets/javascripts/source_monitor/controllers/notification_controller.js

Two changes to the existing per-toast controller:

  1. Dispatch custom event on dismiss -- In the dismiss() method, before starting the fade-out animation, dispatch a bubbling custom event so the container controller can react immediately:

    javascript
    dismiss() {
      if (!this.element) return;
      this.element.dispatchEvent(
        new CustomEvent("notification:dismissed", { bubbles: true })
      );
      this.element.classList.add("opacity-0", "translate-y-2");
      window.setTimeout(() => {
        if (this.element && this.element.remove) {
          this.element.remove();
        }
      }, 200);
    }
    
  2. Error delay override -- Add applyLevelDelay() method called at the start of connect() (before startTimer()). Check this.element.dataset.level -- if "error" and current delayValue is the default 5000, override to 10000:

    javascript
    applyLevelDelay() {
      const level = this.element.dataset.level;
      if (level === "error" && this.delayValue === 5000) {
        this.delayValue = 10000;
      }
    }
    

    Call this in connect() after clearing timeout, before startTimer().

Task 3: Update templates -- layout wrapper, badge HTML, toast data-level

Files: MODIFY app/views/layouts/source_monitor/application.html.erb, MODIFY app/views/source_monitor/shared/_toast.html.erb

Layout changes (lines 16-18 of application.html.erb):

Replace the current notification container markup:

html
<div class="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-end px-6">
  <div id="source_monitor_notifications" class="flex w-full max-w-sm flex-col gap-3"></div>
</div>

With the container-controller-wrapped version:

html
<div class="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-end px-6"
     data-controller="notification-container">
  <div class="flex w-full max-w-sm flex-col items-end gap-3">
    <div id="source_monitor_notifications"
         data-notification-container-target="list"
         class="flex w-full flex-col gap-3">
    </div>
    <div data-notification-container-target="badge"
         class="pointer-events-auto hidden">
      <button type="button"
              data-action="notification-container#toggleExpand"
              class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1 text-xs font-medium text-white shadow-lg transition hover:bg-slate-600"
              aria-live="polite">
        <span data-notification-container-target="badgeCount">+0 more</span>
      </button>
      <button type="button"
              data-action="notification-container#clearAll"
              data-notification-container-target="clearAll"
              class="ml-1 text-xs font-medium text-slate-400 underline transition hover:text-white">
        Clear all
      </button>
    </div>
  </div>
</div>

Key design decisions:

  • data-controller on the outer fixed wrapper (encompasses both the toast list and the badge)
  • listTarget is the existing #source_monitor_notifications div (Turbo Streams and ActionCable append here)
  • Badge and clear-all are siblings BELOW the list (appear at the bottom of the stack)
  • Badge starts hidden (toggled by controller when overflow exists)
  • pointer-events-auto on the badge div so it's clickable despite the pointer-events-none parent
  • aria-live="polite" on the button for screen reader announcements

Toast partial changes (_toast.html.erb):

Add data-level attribute to the root div so the notification controller can detect error level:

erb
<div
  data-controller="notification"
  data-notification-delay-value="<%= delay_ms %>"
  data-level="<%= level_key %>"
  class="pointer-events-auto w-full max-w-md rounded-lg border px-4 py-3 shadow-lg transition duration-300 <%= classes %>"
>

This is the only change to the partial -- add data-level="<%= level_key %>".

Task 4: Register controller in application.js, verify build and tests

Files: MODIFY app/assets/javascripts/source_monitor/application.js

  1. Add import after existing notification import (line 3):

    javascript
    import NotificationContainerController from "./controllers/notification_container_controller";
    
  2. Add registration after existing notification registration (line 17):

    javascript
    application.register("notification-container", NotificationContainerController);
    
  3. Verify everything builds and passes:

    bash
    cd test/dummy && bin/rails assets:precompile 2>&1 | tail -20
    cd /path/to/source_monitor && bin/rubocop
    cd /path/to/source_monitor && bin/rails test
    

    Ensure:

    • No JS import errors or build failures
    • RuboCop zero offenses
    • All tests pass (no regressions from template changes)

Files

ActionPath
CREATEapp/assets/javascripts/source_monitor/controllers/notification_container_controller.js
MODIFYapp/assets/javascripts/source_monitor/controllers/notification_controller.js
MODIFYapp/views/layouts/source_monitor/application.html.erb
MODIFYapp/views/source_monitor/shared/_toast.html.erb
MODIFYapp/assets/javascripts/source_monitor/application.js

Verification

bash
# JS build check
cd test/dummy && bin/rails assets:precompile 2>&1 | tail -20

# Ruby lint
bin/rubocop

# Full test suite (no regressions)
bin/rails test

# Spot-check: container controller exists and has MutationObserver
grep -c "MutationObserver" app/assets/javascripts/source_monitor/controllers/notification_container_controller.js

# Spot-check: dismiss event in notification controller
grep "notification:dismissed" app/assets/javascripts/source_monitor/controllers/notification_controller.js

# Spot-check: data-level in toast partial
grep "data-level" app/views/source_monitor/shared/_toast.html.erb

# Spot-check: container controller registered
grep "notification-container" app/assets/javascripts/source_monitor/application.js

# Spot-check: layout has controller wired
grep "notification-container" app/views/layouts/source_monitor/application.html.erb

Success Criteria

  • No more than 3 toasts visible simultaneously (configurable via maxVisibleValue)
  • Overflow badge shows "+N more" count and appears only when overflow exists
  • Click badge expands stack (shows all toasts), click again or outside collapses
  • Auto-dismiss still works; stack count updates as toasts expire/are removed
  • Dismissing a visible toast promotes next hidden one (natural from MutationObserver recalculate)
  • "Clear all" link removes all toasts at once
  • Error toasts get 10s auto-dismiss delay (vs 5s default)
  • Both Turbo Stream inline and ActionCable broadcast delivery paths work unmodified
  • JS builds without errors
  • RuboCop zero offenses
  • All existing tests pass

Research Findings

Phase 3: Toast Stacking -- Research

Findings

Stimulus + MutationObserver Pattern

  • stimulus-use library provides useMutation composable for detecting DOM changes
  • Config: childList: true, subtree: true to observe child additions/removals
  • Callback: mutate(entries) receives MutationRecord objects
  • Both Turbo Stream inline responses and ActionCable broadcasts trigger mutations uniformly

Overflow Capping Logic

  • Store max visible as a Stimulus value (default: 3)
  • On mutation: iterate toast elements, show top N, hide rest
  • Use visibility: hidden + opacity: 0 (not display: none) to preserve layout and enable transitions
  • Track hidden count for overflow badge

CSS Animation for Slide Reveal

  • Use max-height transition for expand/collapse of overflow section
  • Calculate actual scrollHeight on expand (avoid magic numbers)
  • On transitionend, set max-height: auto for dynamic content
  • Consider ResizeObserver as alternative to manual height calculation

Click-Outside Pattern

  • stimulus-use provides useClickOutside mixin
  • Dispatches click:outside event for clicks outside controller element
  • Only activate when overflow is expanded

Promote-on-Dismiss Pattern

  • Individual toast dispatches custom toast:dismissed event on removal
  • Container listens for events, calls recalculateVisibility()
  • Flex column handles re-flow automatically when hidden toast becomes visible

Controller Architecture Split

  • notification_controller.js (existing): per-toast lifecycle only
  • notification_container_controller.js (new): overflow state, capping, expand/collapse, clear-all
  • Communication via MutationObserver + custom events

Relevant Patterns

From stimulus-components/stimulus-notification

  • data-notification-delay-value for auto-dismiss timing
  • Transitions via data attributes (data-transition-enter-from)
  • Individual notification management; stacking left to consumer

From stimulus-use ecosystem

  • useMutation: observe child additions/removals
  • useClickOutside: click-outside-to-collapse
  • Debounce pattern with requestAnimationFrame

Risks

  1. MutationObserver performance: Rapid appends can fire hundreds of mutations. Mitigation: debounce with requestAnimationFrame
  2. Height calculation: Avoid magic max-height values. Use scrollHeight or ResizeObserver
  3. Target scoping: Use distinct target names between container and individual toast controllers
  4. Keyboard accessibility: Hidden toasts need aria-hidden="true" and inert attribute
  5. Race condition: Toast dismissed during expand animation invalidates height. Debounce recalculation
  6. CSS transition jank: Rapid append/remove queues animations. Use short durations (~150-200ms)

Recommendations

  1. Create notification_container_controller.js with MutationObserver (no stimulus-use dependency needed -- native MutationObserver is simple enough)
  2. Keep existing notification_controller.js unchanged, add custom event dispatch on dismiss
  3. Use CSS max-height + overflow: hidden transitions for expand/collapse
  4. Debounce recalculateVisibility() via requestAnimationFrame
  5. Add aria-hidden and inert to hidden toasts for accessibility
  6. Add ARIA live region for overflow count announcements