Sidebar Navbar Toggle Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a per-user sidebar_navbar boolean preference that swaps the navbar's center dropdowns for a persistent left sidebar on desktop, plus coordinate all navbar dropdowns so only one stays open at a time.

Architecture: Boolean column on users, persisted via a new save_sidebar_navbar helper in Users::RegistrationsController (parallel to save_gamer_visible). When the flag is on, _navbar.html.erb skips its 3-dropdown center block and application.html.erb renders a new _desktop_sidebar partial (desktop-only, mirrors the mobile drawer's flat sections). Dropdown coordination uses a shared dropdowns:opened custom event on document β€” each Stimulus controller dispatches on open and listens to close itself when another fires.

Tech Stack: Rails 8.1, Devise (custom registrations controller), Hotwire/Stimulus, Tailwind, Minitest + Capybara for system specs, i18n (pt-BR + en).

Spec: docs/superpowers/specs/2026-05-07-sidebar-navbar-toggle-design.md


File Structure

New files:

  • db/migrate/<TIMESTAMP>_add_sidebar_navbar_to_users.rb β€” column migration
  • app/views/shared/_desktop_sidebar.html.erb β€” persistent desktop sidebar partial

Modified files:

  • app/models/user.rb β€” schema annotation comment block at top
  • app/controllers/users/registrations_controller.rb β€” add save_sidebar_navbar + call from update
  • app/views/devise/registrations/edit.html.erb β€” new "Layout" card with toggle
  • app/views/shared/_navbar.html.erb β€” wrap CENTER dropdown block in conditional
  • app/views/layouts/application.html.erb β€” render desktop sidebar; pad <main>
  • app/javascript/controllers/finance_dropdown_controller.js β€” dispatch + listen
  • app/javascript/controllers/notifications_controller.js β€” dispatch + listen
  • app/javascript/controllers/dropdown_controller.js β€” dispatch via native <details> toggle event + listen
  • config/locales/pt-BR.yml, config/locales/en.yml β€” 3 new keys each
  • test/controllers/users/registrations_controller_test.rb β€” sidebar_navbar update tests

Task 1: Add sidebar_navbar column to users

Files:

  • Create: db/migrate/<TIMESTAMP>_add_sidebar_navbar_to_users.rb
  • Modify: app/models/user.rb (annotation header only)

  • Step 1: Generate the migration file

Run: bin/rails g migration AddSidebarNavbarToUsers sidebar_navbar:boolean

This creates db/migrate/<TIMESTAMP>_add_sidebar_navbar_to_users.rb. Open it.

  • Step 2: Edit the migration to set default and not-null

Replace the migration body with:

class AddSidebarNavbarToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :sidebar_navbar, :boolean, default: false, null: false
  end
end

(The Rails version in the class declaration must match the existing migrations β€” check db/migrate/20260507004257_add_focus_xp_to_gamification_profiles.rb for confirmation. Currently [8.1].)

  • Step 3: Run the migration

Run: bin/rails db:migrate

Expected: output shows add_column(:users, :sidebar_navbar, :boolean, ...) and a green elapsed-time line. db/schema.rb updates to include t.boolean "sidebar_navbar", default: false, null: false.

  • Step 4: Update annotation in app/models/user.rb

The schema annotation block at the top of app/models/user.rb (lines 1–56) must reflect the new column. Add the line # sidebar_navbar :boolean default(FALSE), not null in alphabetical order β€” between remember_created_at and reset_password_sent_at is wrong; it goes between sign_in_count and uid alphabetically. Actual position to keep alphabetical with existing entries: after sign_in_count. Final block fragment:

#  sidebar_navbar         :boolean          default(FALSE), not null
#  sign_in_count          :integer          default(0), not null

Wait β€” alphabetical order has sidebar_navbar BEFORE sign_in_count (sid... < sign_...). Insert between reset_password_token and sign_in_count. Place this line right after the reset_password_token line:

#  sidebar_navbar         :boolean          default(FALSE), not null
  • Step 5: Verify the column exists

Run: bin/rails runner 'puts User.column_names.include?("sidebar_navbar")'

Expected output: true

  • Step 6: Commit
git add db/migrate db/schema.rb app/models/user.rb
git commit -m "Add sidebar_navbar boolean column to users"

Task 2: Add save_sidebar_navbar to RegistrationsController (TDD)

Files:

  • Modify: test/controllers/users/registrations_controller_test.rb (append new tests)
  • Modify: app/controllers/users/registrations_controller.rb

  • Step 1: Write failing tests

Append to test/controllers/users/registrations_controller_test.rb, before the final end:

  # ── sidebar_navbar toggle ────────────────────────────────────────────────────

  test "update with sidebar_navbar=1 enables the sidebar layout" do
    user = users(:admin)
    user.update!(sidebar_navbar: false)
    sign_in user

    put user_registration_path, params: { user: { current_password: "" }, sidebar_navbar: "1" }

    assert user.reload.sidebar_navbar?
  end

  test "update with sidebar_navbar=0 disables the sidebar layout" do
    user = users(:admin)
    user.update!(sidebar_navbar: true)
    sign_in user

    put user_registration_path, params: { user: { current_password: "" }, sidebar_navbar: "0" }

    assert_not user.reload.sidebar_navbar?
  end

  test "update without sidebar_navbar param does not change the value" do
    user = users(:admin)
    user.update!(sidebar_navbar: true)
    sign_in user

    put user_registration_path, params: { user: { current_password: "" } }

    assert user.reload.sidebar_navbar?
  end
  • Step 2: Run the tests to verify they fail

Run: bin/rails test test/controllers/users/registrations_controller_test.rb -n "/sidebar_navbar/"

Expected: 3 failing tests. The first two fail because sidebar_navbar doesn't get persisted (controller ignores the param). The third may pass already β€” that's fine, it'll continue to pass.

  • Step 3: Implement save_sidebar_navbar

In app/controllers/users/registrations_controller.rb, add the method below in the private section, right after save_gamer_visible (around line 73):

  def save_sidebar_navbar
    return unless params.key?(:sidebar_navbar)
    enabled = ActiveModel::Type::Boolean.new.cast(params[:sidebar_navbar])
    current_user.update!(sidebar_navbar: enabled)
  end

Then wire it into update (lines 22–29). Modify the update action to call the new helper alongside the existing ones:

  def update
    save_display_name
    save_avatar
    save_gamer_visible
    save_home_page
    save_sidebar_navbar
    save_gender
    super
  end
  • Step 4: Run the tests to verify they pass

Run: bin/rails test test/controllers/users/registrations_controller_test.rb -n "/sidebar_navbar/"

Expected: 3 passing tests.

  • Step 5: Run the full registrations test file to confirm no regressions

Run: bin/rails test test/controllers/users/registrations_controller_test.rb

Expected: all tests pass.

  • Step 6: Commit
git add test/controllers/users/registrations_controller_test.rb \
        app/controllers/users/registrations_controller.rb
git commit -m "Persist sidebar_navbar preference from registrations#update"

Task 3: Add i18n keys for the Layout section

Files:

  • Modify: config/locales/pt-BR.yml
  • Modify: config/locales/en.yml

  • Step 1: Add Portuguese keys

In config/locales/pt-BR.yml, find the user_profile: block (line 2115). Insert these three lines right after the gender_woman: Mulher line (line 2122) β€” before gamer_section:

    layout_section: Layout
    sidebar_navbar: Usar barra lateral
    sidebar_navbar_hint: Substitui os menus suspensos do topo por uma barra lateral fixa no desktop.

(Indentation: 4 spaces, matching neighbors.)

  • Step 2: Add English keys

In config/locales/en.yml, find the user_profile: block (line 2074). Insert these three lines right after the gender_woman: Woman line (line 2081) β€” before gamer_section:

    layout_section: Layout
    sidebar_navbar: Use sidebar navigation
    sidebar_navbar_hint: Replaces the top dropdown menus with a persistent sidebar on desktop.

(Indentation: 4 spaces.)

  • Step 3: Verify YAML parses

Run: bin/rails runner 'puts I18n.t("user_profile.layout_section", locale: :"pt-BR"); puts I18n.t("user_profile.layout_section", locale: :en)'

Expected output:

Layout
Layout

(No translation missing or YAML syntax errors.)

  • Step 4: Commit
git add config/locales/pt-BR.yml config/locales/en.yml
git commit -m "Add i18n keys for sidebar_navbar layout section"

Task 4: Add Layout card to the user edit page

Files:

  • Modify: app/views/devise/registrations/edit.html.erb

  • Step 1: Insert the Layout card markup

In app/views/devise/registrations/edit.html.erb, locate the closing </div> of the AVATAR & PERFIL card (around line 92, just before the <%# ===== PAINEL GAMER ===== %> comment on line 94).

Insert this new card between them β€” right before the <%# ===== PAINEL GAMER ===== %> comment:

  <%# ===== LAYOUT ===== %>
  <div class="rounded-xl p-6 bg-white dark:bg-[#161b22] border border-gray-200/60 dark:border-white/[0.06]">
    <h3 class="text-base font-semibold text-gray-900 dark:text-white mb-5 flex items-center gap-3">
      <div class="w-8 h-8 rounded-lg flex items-center justify-center bg-sky-500/15">
        <svg class="size-4 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16"/>
        </svg>
      </div>
      <%= t("user_profile.layout_section") %>
    </h3>

    <label class="flex items-center justify-between gap-4 cursor-pointer">
      <span class="flex flex-col">
        <span class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= t("user_profile.sidebar_navbar") %></span>
        <span class="text-xs text-gray-500 dark:text-gray-400"><%= t("user_profile.sidebar_navbar_hint") %></span>
      </span>
      <span class="relative inline-flex shrink-0">
        <input type="hidden" name="sidebar_navbar" value="0">
        <input type="checkbox"
               name="sidebar_navbar"
               value="1"
               <%= 'checked' if current_user.sidebar_navbar? %>
               class="peer sr-only">
        <span class="block h-6 w-11 rounded-full bg-gray-300 dark:bg-gray-600 transition-colors peer-checked:bg-blue-500"></span>
        <span class="pointer-events-none absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5"></span>
      </span>
    </label>
  </div>

The hidden value="0" input pattern (used by gamer_visible in the same file) ensures unchecked checkboxes still submit a value, so params[:sidebar_navbar] is always present.

  • Step 2: Boot the dev server and visually verify

Run: bin/dev (in a separate terminal, or use the existing one).

Open http://localhost:3000/users/edit while signed in. Confirm:

  • A new card titled "Layout" appears between the Profile card and the Painel Gamer card.
  • Toggle pill is unchecked by default (since sidebar_navbar defaults to false).

  • Step 3: Submit the form with the toggle ON, reload, and verify it persists

In the browser:

  • Click the toggle pill (it slides to the on position).
  • Click "Salvar AlteraΓ§Γ΅es" / "Save Changes".
  • Page reloads. The Layout toggle should still appear ON.

(This exercises the controller wiring from Task 2 end-to-end through the UI.)

  • Step 4: Commit
git add app/views/devise/registrations/edit.html.erb
git commit -m "Add Layout card with sidebar_navbar toggle to user edit page"

Task 5: Create the desktop sidebar partial

Files:

  • Create: app/views/shared/_desktop_sidebar.html.erb

  • Step 1: Create the new partial file

Create app/views/shared/_desktop_sidebar.html.erb with this content:

<%# Desktop-only persistent sidebar β€” rendered when current_user.sidebar_navbar? is true. %>
<%# Mirrors the flat sections in shared/_sidebar.html.erb (mobile drawer) but lives on the desktop layout. %>
<aside class="hidden lg:flex fixed left-0 top-[44px] bottom-0 w-60 flex-col overflow-y-auto z-20"
       style="background: var(--bg-sidebar); border-right: 1px solid var(--sidebar-border);">
  <% if signed_in? %>
    <div class="px-4 py-4 flex-1">
      <span class="text-[10px] font-semibold tracking-[0.08em] uppercase mb-1.5 block" style="color: var(--text-muted);"><%= t("nav.finance") %></span>
      <nav class="space-y-0.5">
        <%= navbar_nav_item t("nav.overview"), dashboard_path, icon: "dashboard" %>
        <%= navbar_nav_item t("nav.finance_dashboard"), finance_dashboard_path, icon: "finance_dashboard" %>
        <%= navbar_nav_item t("nav.accounts"), accounts_path, icon: "accounts" %>
        <%= navbar_nav_item t("nav.expenses"), expenses_path, icon: "expenses" %>
        <%= navbar_nav_item t("nav.debts"), debts_path, icon: "debts" %>
        <%= navbar_nav_item t("nav.investments"), investments_path, icon: "investments" %>
        <%= navbar_nav_item t("nav.analytics"), analytics_path, icon: "analytics" %>
        <%= navbar_nav_item t("nav.simulator"), simulator_path, icon: "simulator" %>
        <%= navbar_nav_item t("nav.planning"), planning_path, icon: "planning" %>
      </nav>

      <span class="text-[10px] font-semibold tracking-[0.08em] uppercase mt-4 mb-1.5 block" style="color: var(--text-muted);"><%= t("nav.personal") %></span>
      <nav class="space-y-0.5">
        <%= navbar_nav_item t("nav.habits"), habits_path, icon: "habits" %>
        <%= navbar_nav_item t("nav.goals"), goals_path, icon: "goals" %>
        <%= navbar_nav_item t("nav.sports_label"), sports_path, icon: "sports" %>
        <%= navbar_nav_item t("nav.vision"), vision_path, icon: "vision" %>
        <%= navbar_nav_item t("nav.focus"), focus_path, icon: "focus" %>
      </nav>

      <span class="text-[10px] font-semibold tracking-[0.08em] uppercase mt-4 mb-1.5 block" style="color: var(--text-muted);"><%= t("nav.tools") %></span>
      <nav class="space-y-0.5">
        <%= navbar_nav_item t("nav.tasks"), todo_board_path, icon: "tasks" %>
        <%= navbar_nav_item t("nav.birthdays"), birthdays_path, icon: "birthdays" %>
        <%= navbar_nav_item t("nav.market_lists"), market_lists_path, icon: "market_lists" %>
        <%= navbar_nav_item t("nav.notes"), notes_path, icon: "notes" %>
        <%= navbar_nav_item t("nav.countdowns"), countdowns_path, icon: "countdowns" %>
        <%= navbar_nav_item t("nav.construction"), construction_projects_path, icon: "construction" %>
        <%= navbar_nav_item t("nav.converter"), converter_path, icon: "converter" %>
      </nav>

      <% if current_user.gamer_visible? %>
        <span class="text-[10px] font-semibold tracking-[0.08em] uppercase mt-4 mb-1.5 block" style="color: var(--text-muted);"><%= t("nav.gamification") %></span>
        <nav class="space-y-0.5">
          <%= navbar_nav_item t("nav.gamer"), gamer_path, icon: "gamer" %>
          <%= navbar_nav_item t("nav.achievements"), gamification_achievements_path, icon: "achievements" %>
          <%= navbar_nav_item t("nav.challenges"), challenges_path, icon: "challenges" %>
          <%= navbar_nav_item t("nav.leaderboard"), leaderboard_path, icon: "leaderboard" %>
        </nav>
      <% end %>
    </div>
  <% end %>
</aside>

Key details:

  • hidden lg:flex β€” invisible on mobile (mobile drawer handles that breakpoint).
  • top-[44px] β€” sits below the navbar. The navbar uses py-2.5 (10px top + 10px bottom = 20px) plus a 28px logo, so ~48px. Use top-[48px] if 44px overlaps content; verify visually in Step 3.
  • z-20 β€” below the sticky header (z-30) so navbar dropdowns float over it.
  • The link list is a copy of app/views/shared/_sidebar.html.erb's sections β€” kept independent so the mobile drawer can evolve separately.

  • Step 2: Run the partial in isolation to confirm no syntax errors

Run: bin/rails runner 'ApplicationController.render(template: "devise/registrations/edit") rescue nil; puts "ok"' is overkill β€” instead just verify ERB parses by booting the app:

Run: bin/rails server -d (or check the running dev server). If the file has a syntax error, the next page load will fail loudly. We'll wire it up in Task 6 and verify visually then.

  • Step 3: Commit
git add app/views/shared/_desktop_sidebar.html.erb
git commit -m "Add desktop sidebar partial mirroring mobile drawer sections"

Task 6: Wire the sidebar into the application layout and gate the navbar dropdowns

Files:

  • Modify: app/views/layouts/application.html.erb
  • Modify: app/views/shared/_navbar.html.erb

  • Step 1: Render the desktop sidebar conditionally and pad main

Open app/views/layouts/application.html.erb. Replace the current block (lines 41–47):

      <% if signed_in? %>
        <%= render "shared/sidebar" %>
      <% end %>
      <main class="flex-grow p-4 pb-24 lg:p-6 lg:pb-6 <%= content_for?(:page_full_width) ? "w-full" : "mx-auto w-full" %> bg-[#f1f5f9] dark:bg-[#0d1117]"
            <%= content_for?(:page_full_width) ? nil : 'style="max-width: 1420px;"'.html_safe %>>
        <%= yield %>
      </main>

with:

      <% if signed_in? %>
        <%= render "shared/sidebar" %>
        <% if current_user.sidebar_navbar? %>
          <%= render "shared/desktop_sidebar" %>
        <% end %>
      <% end %>
      <main class="flex-grow p-4 pb-24 lg:p-6 lg:pb-6 <%= 'lg:pl-[15rem]' if signed_in? && current_user.sidebar_navbar? %> <%= content_for?(:page_full_width) ? "w-full" : "mx-auto w-full" %> bg-[#f1f5f9] dark:bg-[#0d1117]"
            <%= content_for?(:page_full_width) ? nil : 'style="max-width: 1420px;"'.html_safe %>>
        <%= yield %>
      </main>

Note: lg:pl-[15rem] matches the sidebar's w-60 (60 Γ— 0.25rem = 15rem). The padding is only applied at lg and up, where the sidebar is visible.

  • Step 2: Gate the navbar's center dropdown block

Open app/views/shared/_navbar.html.erb. Find the CENTER block β€” it starts at line 60 with:

  <% if signed_in? %>
    <div class="hidden lg:flex items-center gap-2">

…and ends at line 175 with the matching <% end %> for the signed_in? check (the closing </div> for the flex container is on line 174).

Wrap ONLY the inner <div class="hidden lg:flex items-center gap-2">…</div> (lines 60–174's contents) in an unless current_user.sidebar_navbar? guard. Concretely, replace line 60:

    <div class="hidden lg:flex items-center gap-2">

with:

    <% unless current_user.sidebar_navbar? %>
    <div class="hidden lg:flex items-center gap-2">

And replace the closing </div> on line 174 (the one that closes the flex container, immediately before <% end %> on line 175) with:

    </div>
    <% end %>

This keeps the existing outer <% if signed_in? %> / <% end %> (line 59 / 175) intact and only short-circuits when the user has the sidebar layout enabled.

  • Step 3: Visual smoke test β€” flag OFF (default state)

In the browser, with current_user.sidebar_navbar = false (the default):

  • Visit /dashboard. The 3 dropdowns (Finance / Personal / Tools) appear in the navbar center.
  • No left sidebar appears on desktop.
  • <main> content is centered as before.

  • Step 4: Visual smoke test β€” flag ON

Flip the toggle on the user edit page (or in bin/rails console: User.find_by(email: "...").update!(sidebar_navbar: true)). Reload /dashboard:

  • The 3 navbar dropdowns are gone. The right-side actions (notifications, dark mode, avatar) remain.
  • A persistent left sidebar appears with FINANCE / PERSONAL / TOOLS sections (and GAMIFICATION if gamer_visible).
  • Content shifts right by ~15rem and doesn't sit under the sidebar.

  • Step 5: If top-[44px] looks misaligned

If the sidebar's top edge overlaps navbar content or leaves a gap, edit the top-[Npx] value in app/views/shared/_desktop_sidebar.html.erb. Inspect the navbar element in DevTools to read its rendered height; use that exact value. Common values: 44, 48, 52, 56.

  • Step 6: Commit
git add app/views/layouts/application.html.erb app/views/shared/_navbar.html.erb \
        app/views/shared/_desktop_sidebar.html.erb
git commit -m "Render desktop sidebar and hide navbar dropdowns when sidebar_navbar is on"

(Re-include the desktop sidebar partial in case Step 5 required tweaks.)


Task 7: Coordinate finance_dropdown_controller via shared event

Files:

  • Modify: app/javascript/controllers/finance_dropdown_controller.js

  • Step 1: Add dispatch in open() and listener in connect() / disconnect()

Replace the entire contents of app/javascript/controllers/finance_dropdown_controller.js with:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["menu", "button"]

  connect() {
    this.closeHandler = this.closeOnOutsideClick.bind(this)
    this.escHandler = this.closeOnEsc.bind(this)
    this.siblingHandler = this.closeIfSibling.bind(this)
    document.addEventListener("dropdowns:opened", this.siblingHandler)
  }

  toggle(event) {
    event.stopPropagation()
    if (this.menuTarget.classList.contains("hidden")) {
      this.open()
    } else {
      this.close()
    }
  }

  open() {
    this.menuTarget.classList.remove("hidden")
    requestAnimationFrame(() => {
      this.menuTarget.classList.add("opacity-100")
      this.menuTarget.classList.remove("opacity-0")
    })
    document.addEventListener("click", this.closeHandler)
    document.addEventListener("keydown", this.escHandler)
    document.dispatchEvent(new CustomEvent("dropdowns:opened", { detail: { source: this } }))
  }

  close() {
    if (this.menuTarget.classList.contains("hidden")) return
    this.menuTarget.classList.add("opacity-0")
    this.menuTarget.classList.remove("opacity-100")
    setTimeout(() => {
      this.menuTarget.classList.add("hidden")
    }, 150)
    document.removeEventListener("click", this.closeHandler)
    document.removeEventListener("keydown", this.escHandler)
  }

  closeOnOutsideClick(event) {
    if (!this.element.contains(event.target)) {
      this.close()
    }
  }

  closeOnEsc(event) {
    if (event.key === "Escape") {
      this.close()
    }
  }

  closeIfSibling(event) {
    if (event.detail && event.detail.source !== this) {
      this.close()
    }
  }

  disconnect() {
    document.removeEventListener("click", this.closeHandler)
    document.removeEventListener("keydown", this.escHandler)
    document.removeEventListener("dropdowns:opened", this.siblingHandler)
  }
}

Key changes from the original:

  • New siblingHandler bound in connect(), registered/unregistered alongside the existing handlers.
  • open() dispatches dropdowns:opened on document with detail.source = this.
  • close() is now idempotent β€” it returns early if already hidden, so the sibling handler firing on already-closed dropdowns doesn't run the close transition needlessly.
  • closeIfSibling calls close() only when another controller dispatched the event.

  • Step 2: Visual smoke test

Reload any page with the navbar. Click the Finance dropdown β€” it opens. Click the Personal dropdown β€” Finance closes, Personal opens. Click Tools β€” Personal closes, Tools opens. (Notifications and profile menu still misbehave at this stage β€” those are next tasks.)

  • Step 3: Commit
git add app/javascript/controllers/finance_dropdown_controller.js
git commit -m "Coordinate finance dropdowns via shared dropdowns:opened event"

Task 8: Coordinate notifications_controller via shared event

Files:

  • Modify: app/javascript/controllers/notifications_controller.js

  • Step 1: Add dispatch on open and sibling listener

Open app/javascript/controllers/notifications_controller.js. Make these targeted edits:

In connect() (currently lines 33–37), replace:

  connect() {
    this.boundClose = this.closeOnClickOutside.bind(this)
    document.addEventListener("click", this.boundClose)
    document.addEventListener("keydown", this.handleKeydown.bind(this))
  }

with:

  connect() {
    this.boundClose = this.closeOnClickOutside.bind(this)
    this.boundKeydown = this.handleKeydown.bind(this)
    this.boundSibling = this.closeIfSibling.bind(this)
    document.addEventListener("click", this.boundClose)
    document.addEventListener("keydown", this.boundKeydown)
    document.addEventListener("dropdowns:opened", this.boundSibling)
  }

(The original re-binds handleKeydown separately in disconnect, leaking the listener β€” this fix is included.)

In disconnect() (currently lines 39–42), replace:

  disconnect() {
    document.removeEventListener("click", this.boundClose)
    document.removeEventListener("keydown", this.handleKeydown.bind(this))
  }

with:

  disconnect() {
    document.removeEventListener("click", this.boundClose)
    document.removeEventListener("keydown", this.boundKeydown)
    document.removeEventListener("dropdowns:opened", this.boundSibling)
  }

In toggle(event) (currently lines 44–48), modify so a transition to open dispatches the event:

  toggle(event) {
    event.stopPropagation()
    this.openValue = !this.openValue
    this.render()
    if (this.openValue) {
      document.dispatchEvent(new CustomEvent("dropdowns:opened", { detail: { source: this } }))
    }
  }

After the closeOnClickOutside method (around line 65), add a new method:

  closeIfSibling(event) {
    if (event.detail && event.detail.source !== this && this.openValue) {
      this.close()
    }
  }
  • Step 2: Visual smoke test

Click the Finance dropdown β†’ it opens. Click the notification bell β†’ Finance closes, notifications open. Click Personal β†’ notifications close, Personal opens. (Profile menu is still independent until Task 9.)

  • Step 3: Commit
git add app/javascript/controllers/notifications_controller.js
git commit -m "Coordinate notifications dropdown via shared dropdowns:opened event"

Task 9: Coordinate dropdown_controller (user-profile <details>) via shared event

Files:

  • Modify: app/javascript/controllers/dropdown_controller.js

  • Step 1: Listen to the native <details> toggle event and the shared sibling event

Replace the entire contents of app/javascript/controllers/dropdown_controller.js with:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["menu"]

  connect() {
    this.handleClickOutside = this.handleClickOutside.bind(this)
    this.handleNativeToggle = this.handleNativeToggle.bind(this)
    this.handleSiblingOpen = this.handleSiblingOpen.bind(this)

    document.addEventListener("click", this.handleClickOutside)
    document.addEventListener("dropdowns:opened", this.handleSiblingOpen)
    this.element.addEventListener("toggle", this.handleNativeToggle)
  }

  disconnect() {
    document.removeEventListener("click", this.handleClickOutside)
    document.removeEventListener("dropdowns:opened", this.handleSiblingOpen)
    this.element.removeEventListener("toggle", this.handleNativeToggle)
  }

  toggle() {
    if (this.hasMenuTarget) {
      this.menuTarget.classList.toggle("hidden")
    }
  }

  handleClickOutside(event) {
    if (!this.element.contains(event.target)) {
      this.close()
    }
  }

  handleNativeToggle() {
    if (this.element.open) {
      document.dispatchEvent(new CustomEvent("dropdowns:opened", { detail: { source: this } }))
    }
  }

  handleSiblingOpen(event) {
    if (event.detail && event.detail.source !== this && this.element.open) {
      this.close()
    }
  }

  close() {
    if (this.hasMenuTarget) {
      this.menuTarget.classList.add("hidden")
    }
    this.element.removeAttribute("open")
  }
}

Key changes:

  • Listen to the native toggle event on the <details> element (fires when its open attribute changes). Dispatch dropdowns:opened on transition to open.
  • Listen for dropdowns:opened from siblings; close if the source isn't us and we're currently open.
  • All other behavior (click-outside, close()) preserved.

  • Step 2: Visual smoke test β€” full coordination

Reload any signed-in page. In sequence:

  1. Click Finance dropdown β†’ opens.
  2. Click Personal β†’ Finance closes, Personal opens.
  3. Click Tools β†’ Personal closes, Tools opens.
  4. Click notification bell β†’ Tools closes, notifications open.
  5. Click profile avatar β†’ notifications close, profile menu opens.
  6. Click Finance again β†’ profile menu closes, Finance opens.

All five dropdowns now coordinate.

  • Step 3: Commit
git add app/javascript/controllers/dropdown_controller.js
git commit -m "Coordinate user-profile dropdown via shared dropdowns:opened event"

Task 10: System test β€” sidebar layout toggle end-to-end

Files:

  • Create: test/system/sidebar_navbar_test.rb

  • Step 1: Write the system test

Create test/system/sidebar_navbar_test.rb:

require "application_system_test_case"

class SidebarNavbarTest < ApplicationSystemTestCase
  setup do
    @user = users(:admin)
    sign_in @user
  end

  test "default layout shows navbar dropdowns and no desktop sidebar" do
    @user.update!(sidebar_navbar: false)
    visit dashboard_path

    assert_selector "[data-controller='finance-dropdown']", minimum: 3
    assert_no_selector "aside.hidden.lg\\:flex.fixed.left-0"
  end

  test "with sidebar_navbar on, navbar dropdowns are hidden and desktop sidebar renders" do
    @user.update!(sidebar_navbar: true)
    visit dashboard_path

    assert_no_selector "[data-controller='finance-dropdown']"
    assert_selector "aside.fixed.left-0", text: I18n.t("nav.finance")
    assert_selector "aside.fixed.left-0", text: I18n.t("nav.personal")
    assert_selector "aside.fixed.left-0", text: I18n.t("nav.tools")
  end

  test "toggling sidebar_navbar from the edit page persists and reflows the layout" do
    @user.update!(sidebar_navbar: false)
    visit edit_user_registration_path

    # Find the sidebar_navbar checkbox by its name attribute
    find("input[type='checkbox'][name='sidebar_navbar']", visible: false).click
    click_button I18n.t("user_profile.save_changes")

    # After redirect back to edit, the toggle is checked
    assert find("input[type='checkbox'][name='sidebar_navbar']", visible: false).checked?
    assert @user.reload.sidebar_navbar?
  end
end
  • Step 2: Run the system tests

Run: bin/rails test:system test/system/sidebar_navbar_test.rb

Expected: 3 passing tests.

If the third test fails because the checkbox is sr-only and can't be clicked: replace the click line with the parent <label> click. Use:

find("label", text: I18n.t("user_profile.sidebar_navbar")).click
  • Step 3: Commit
git add test/system/sidebar_navbar_test.rb
git commit -m "Add system test for sidebar_navbar layout toggle"

Task 11: System test β€” single-open dropdown coordination

Files:

  • Create: test/system/navbar_dropdown_coordination_test.rb

  • Step 1: Write the system test

Create test/system/navbar_dropdown_coordination_test.rb:

require "application_system_test_case"

class NavbarDropdownCoordinationTest < ApplicationSystemTestCase
  setup do
    @user = users(:admin)
    @user.update!(sidebar_navbar: false) # Need the dropdowns visible
    sign_in @user
    visit dashboard_path
  end

  test "opening a second navbar dropdown closes the first" do
    finance_button   = find_button(I18n.t("nav.finance"))
    personal_button  = find_button(I18n.t("nav.personal"))
    tools_button     = find_button(I18n.t("nav.tools"))

    finance_button.click
    assert_selector "[data-finance-dropdown-target='menu']:not(.hidden)", text: I18n.t("nav.accounts")

    personal_button.click
    assert_selector "[data-finance-dropdown-target='menu']:not(.hidden)", text: I18n.t("nav.habits")
    assert_no_selector "[data-finance-dropdown-target='menu']:not(.hidden)", text: I18n.t("nav.accounts")

    tools_button.click
    assert_selector "[data-finance-dropdown-target='menu']:not(.hidden)", text: I18n.t("nav.notes")
    assert_no_selector "[data-finance-dropdown-target='menu']:not(.hidden)", text: I18n.t("nav.habits")
  end

  test "opening notifications closes any open navbar dropdown" do
    find_button(I18n.t("nav.finance")).click
    assert_selector "[data-finance-dropdown-target='menu']:not(.hidden)", text: I18n.t("nav.accounts")

    find("[data-controller='notifications'] button").click
    assert_no_selector "[data-finance-dropdown-target='menu']:not(.hidden)", text: I18n.t("nav.accounts")
    assert_selector "[data-notifications-target='dropdown']:not(.hidden)"
  end

  test "opening the profile dropdown closes any open navbar dropdown" do
    find_button(I18n.t("nav.finance")).click
    assert_selector "[data-finance-dropdown-target='menu']:not(.hidden)", text: I18n.t("nav.accounts")

    find("details[data-controller='dropdown'] summary").click
    assert_no_selector "[data-finance-dropdown-target='menu']:not(.hidden)", text: I18n.t("nav.accounts")
    assert_selector "details[data-controller='dropdown'][open]"
  end
end
  • Step 2: Run the tests

Run: bin/rails test:system test/system/navbar_dropdown_coordination_test.rb

Expected: 3 passing tests.

If a test is flaky due to the 150ms close transition in finance_dropdown_controller, add sleep 0.2 between the click that should close one dropdown and the assertion. Example:

personal_button.click
sleep 0.2
assert_no_selector ...
  • Step 3: Commit
git add test/system/navbar_dropdown_coordination_test.rb
git commit -m "Add system test for single-open navbar dropdown coordination"

Task 12: Final regression sweep

Files: none modified.

  • Step 1: Run the full test suite

Run: bin/rails test test/ then bin/rails test:system

Expected: all green. If any pre-existing failures appear, confirm they're unrelated to this branch (compare against git stash; bin/rails test:system; git stash pop).

  • Step 2: Manual sanity check in dev

Boot bin/dev and walk through:

  • Sign in as a user with sidebar_navbar = false. Confirm navbar dropdowns work, all 5 coordinate (open one closes others).
  • Toggle sidebar_navbar ON via /users/edit. Confirm sidebar appears, navbar dropdowns gone, content padded.
  • Resize browser to mobile width (<lg). Confirm hamburger + mobile drawer still works regardless of sidebar_navbar.
  • Toggle OFF. Confirm everything returns to original state.

  • Step 3: Final commit (if any tweaks were made during the sweep)
git status
# If anything is uncommitted from the sweep:
git add <files>
git commit -m "Address regressions found in final sweep"

If nothing changed, no commit needed.


Self-review notes

  • Spec coverage: Section 1 (data) β†’ Task 1, 2. Section 2 (views) β†’ Tasks 4, 5, 6. Section 3 (Stimulus) β†’ Tasks 7, 8, 9. Section 4 (translations) β†’ Task 3. Section 5 (testing) β†’ Tasks 2 (controller), 10 (layout), 11 (coordination).
  • Placeholder scan: No TBD, TODO, or "implement later" text. Every step has concrete code or commands.
  • Type/name consistency: Event name dropdowns:opened used identically in Tasks 7, 8, 9. event.detail.source accessed identically. Method name closeIfSibling / handleSiblingOpen differs by file (preserved each controller's local naming style) but behavior is the same.
  • Risk note from spec: Task 5 Step 1 uses top-[44px] with explicit instructions in Task 6 Step 5 to tune visually. Z-index z-20 on sidebar, navbar header z-30 β€” sidebar sits below navbar dropdowns as intended.