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.