.vbw-planning/milestones/polish-and-reliability/phases/03-toast-stacking/.context-dev.md
Not available
Codebase mapping exists in .vbw-planning/codebase/. Key files:
ARCHITECTURE.mdCONCERNS.mdPATTERNS.mdDEPENDENCIES.mdSTRUCTURE.mdCONVENTIONS.mdTESTING.mdSTACK.mdRead CONVENTIONS.md, PATTERNS.md, STRUCTURE.md, and DEPENDENCIES.md first to bootstrap codebase understanding.
.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.mdapp/assets/builds/source_monitor/application.csstest/dummy/Gemfile.lock.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)
phase: 3 plan: 1 title: "Toast Stacking: Container Controller, Templates, and Integration" wave: 1 depends_on: [] must_haves:
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.
@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.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:
maxVisible (Number, default: 3), expanded (Boolean, default: false)list (the toast container div), badge (overflow indicator), badgeCount (count text span), clearAll (clear-all link)toggleExpand (badge click), clearAll (clear-all click)Core implementation:
connect(): Set up MutationObserver on listTarget with { childList: true }. Bind click-outside handler and notification:dismissed event listener on listTarget. Call recalculateVisibility().
disconnect(): Clean up observer, cancel any pending rAF, remove document click listener, remove event listener.
scheduleRecalculate(): Debounce via requestAnimationFrame -- cancel previous rAF if pending, schedule new one that calls recalculateVisibility().
recalculateVisibility(): Core logic:
listTarget (toast elements)expandedValue is true: show all toasts (remove hidden, aria-hidden, inert)maxVisibleValue, hide the rest with hidden class + aria-hidden="true" + inert attributehiddenCount = Math.max(0, total - maxVisibleValue) (when not expanded); 0 when expandedhasBadgeTarget: update badgeCountTarget.textContent to +${hiddenCount} more; toggle badge visibility based on hiddenCount > 0hasClearAllTarget: toggle clear-all visibility based on total > 0 && (hiddenCount > 0 || expandedValue)toggleExpand(event): Prevent default. If expanded, call collapseStack(); else call expandStack().
expandStack(): Set expandedValue = true. Call recalculateVisibility(). Add document click-outside listener.
collapseStack(): Set expandedValue = false. Remove document click-outside listener. Call recalculateVisibility().
handleClickOutside(event): If event.target is not within this.element, call collapseStack().
clearAll(event): Prevent default. Get all direct children of listTarget, remove each one. Set expandedValue = false. (MutationObserver will fire and trigger scheduleRecalculate automatically.)
Accessibility:
aria-hidden="true" and inert attribute (prevents focus/interaction)aria-live="polite" (set in template)Important notes:
notification:dismissed custom event fires before DOM removal -- the subsequent DOM removal triggers MutationObserver which schedules recalculate. This naturally handles promote-next-hidden behavior.hidden class). The "slide" effect comes from the flex container naturally reflowing.Files: MODIFY app/assets/javascripts/source_monitor/controllers/notification_controller.js
Two changes to the existing per-toast controller:
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:
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);
}
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:
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().
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:
<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:
<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)hidden (toggled by controller when overflow exists)pointer-events-auto on the badge div so it's clickable despite the pointer-events-none parentaria-live="polite" on the button for screen reader announcementsToast partial changes (_toast.html.erb):
Add data-level attribute to the root div so the notification controller can detect error level:
<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 %>".
Files: MODIFY app/assets/javascripts/source_monitor/application.js
Add import after existing notification import (line 3):
import NotificationContainerController from "./controllers/notification_container_controller";
Add registration after existing notification registration (line 17):
application.register("notification-container", NotificationContainerController);
Verify everything builds and passes:
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:
| Action | Path |
|---|---|
| CREATE | app/assets/javascripts/source_monitor/controllers/notification_container_controller.js |
| MODIFY | app/assets/javascripts/source_monitor/controllers/notification_controller.js |
| MODIFY | app/views/layouts/source_monitor/application.html.erb |
| MODIFY | app/views/source_monitor/shared/_toast.html.erb |
| MODIFY | app/assets/javascripts/source_monitor/application.js |
# 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
maxVisibleValue)stimulus-use library provides useMutation composable for detecting DOM changeschildList: true, subtree: true to observe child additions/removalsmutate(entries) receives MutationRecord objectsvisibility: hidden + opacity: 0 (not display: none) to preserve layout and enable transitionsmax-height transition for expand/collapse of overflow sectionscrollHeight on expand (avoid magic numbers)transitionend, set max-height: auto for dynamic contentResizeObserver as alternative to manual height calculationstimulus-use provides useClickOutside mixinclick:outside event for clicks outside controller elementtoast:dismissed event on removalrecalculateVisibility()notification_controller.js (existing): per-toast lifecycle onlynotification_container_controller.js (new): overflow state, capping, expand/collapse, clear-alldata-notification-delay-value for auto-dismiss timingdata-transition-enter-from)useMutation: observe child additions/removalsuseClickOutside: click-outside-to-collapserequestAnimationFramerequestAnimationFramemax-height values. Use scrollHeight or ResizeObserveraria-hidden="true" and inert attributenotification_container_controller.js with MutationObserver (no stimulus-use dependency needed -- native MutationObserver is simple enough)notification_controller.js unchanged, add custom event dispatch on dismissmax-height + overflow: hidden transitions for expand/collapserecalculateVisibility() via requestAnimationFramearia-hidden and inert to hidden toasts for accessibility