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 migrationapp/views/shared/_desktop_sidebar.html.erb— persistent desktop sidebar partial
Modified files:
app/models/user.rb— schema annotation comment block at topapp/controllers/users/registrations_controller.rb— addsave_sidebar_navbar+ call fromupdateapp/views/devise/registrations/edit.html.erb— new "Layout" card with toggleapp/views/shared/_navbar.html.erb— wrap CENTER dropdown block in conditionalapp/views/layouts/application.html.erb— render desktop sidebar; pad<main>app/javascript/controllers/finance_dropdown_controller.js— dispatch + listenapp/javascript/controllers/notifications_controller.js— dispatch + listenapp/javascript/controllers/dropdown_controller.js— dispatch via native<details>toggle event + listenconfig/locales/pt-BR.yml,config/locales/en.yml— 3 new keys eachtest/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_navbardefaults tofalse). - 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 usespy-2.5(10px top + 10px bottom = 20px) plus a 28px logo, so ~48px. Usetop-[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 inconnect()/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
siblingHandlerbound inconnect(), registered/unregistered alongside the existing handlers. open()dispatchesdropdowns:openedondocumentwithdetail.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.-
closeIfSiblingcallsclose()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
toggleevent on the<details>element (fires when itsopenattribute changes). Dispatchdropdowns:openedon transition to open. - Listen for
dropdowns:openedfrom 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:
- Click Finance dropdown → opens.
- Click Personal → Finance closes, Personal opens.
- Click Tools → Personal closes, Tools opens.
- Click notification bell → Tools closes, notifications open.
- Click profile avatar → notifications close, profile menu opens.
- 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 ofsidebar_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:openedused identically in Tasks 7, 8, 9.event.detail.sourceaccessed identically. Method namecloseIfSibling/handleSiblingOpendiffers 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-indexz-20on sidebar, navbar headerz-30— sidebar sits below navbar dropdowns as intended.