Sidebar Navbar Toggle + Single-Open Dropdowns — Design
Date: 2026-05-07 Status: Approved (design phase)
Summary
Two related navigation improvements:
sidebar_navbaruser preference — a per-user boolean that, when ON, replaces the navbar's three center dropdowns (Finance / Personal / Tools) with a persistent left sidebar on desktop. Editable from the user edit page (devise/registrations#edit). Mobile behavior is unchanged.- Single-open dropdowns — opening any one navbar dropdown closes all others. Coordinates all five dropdowns: Finance, Personal, Tools, Notifications, and the user-profile menu.
Goals
- Give users a layout choice between top-navbar dropdowns and a persistent left sidebar.
- Eliminate the "multiple dropdowns hanging open at once" UX papercut.
- Keep changes additive — no refactor of existing controllers, no rename of existing partials.
- Mobile (under
lg) is untouched: same hamburger → slide-in drawer.
Non-goals
- Collapsing/grouping inside the sidebar (deferred — flat sections like the existing mobile drawer).
- Restyling the mobile drawer.
- Coordinating dropdowns outside the navbar (e.g., page-level menus).
- Persisting the toggle anywhere other than the user record (no cookie fallback for guests — feature only matters when signed in).
1. Data & persistence
Migration
class AddSidebarNavbarToUsers < ActiveRecord::Migration[7.x]
def change
add_column :users, :sidebar_navbar, :boolean, default: false, null: false
end
end
Default false preserves current behavior for every existing user. No backfill task required.
Model
User gets the new column auto-magically. No new validations, scopes, or methods. Update the schema annotation comment block at the top of app/models/user.rb.
Controller — Users::RegistrationsController
The existing update action already calls save_gamer_visible and save_home_page as side-channel persistence helpers. Add a parallel:
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
Wire it into update next to the existing helpers. Permitted-params lists need no change since the value is read directly from params.
2. View structure
app/views/devise/registrations/edit.html.erb
Insert a new "Layout" card between the Profile section and the Painel Gamer section. Use the same card chrome as the others: rounded-xl p-6 bg-white dark:bg-[#161b22] border border-gray-200/60 dark:border-white/[0.06], an icon header, and a single toggle row that mirrors the existing gamer_visible toggle markup (hidden value="0" input + checkbox value="1" + sliding pill).
Toggle field name: sidebar_navbar. Card title: t("user_profile.layout_section"). Toggle label/hint: t("user_profile.sidebar_navbar") / t("user_profile.sidebar_navbar_hint"). No JS controller needed (form submit applies the change; page reload renders the new layout).
app/views/shared/_navbar.html.erb
Wrap the existing CENTER block (the <div class="hidden lg:flex items-center gap-2"> containing the 3 dropdowns, currently lines 60–174) with:
<% unless signed_in? && current_user.sidebar_navbar? %>
...existing block...
<% end %>
Everything else in the navbar (logo, exchange rate, impersonation indicator, hide-balances, dark-mode, notifications, gamification XP, user profile, mobile hamburger) stays in place. The signed-out branch is unaffected.
app/views/shared/_desktop_sidebar.html.erb (new)
Persistent desktop sidebar. Visibility class: hidden lg:flex. Positioning: fixed left-0 top-[NAVBAR_HEIGHT] bottom-0 w-60 overflow-y-auto z-20, with flex-col. Background and border match --bg-sidebar / --sidebar-border tokens (same as existing sidebar partial).
Content sections, copied in spirit from app/views/shared/_sidebar.html.erb:
- FINANCE — overview, finance_dashboard, accounts, expenses, debts, investments, analytics, simulator, planning
- PERSONAL — habits, goals, sports, vision, focus
- TOOLS — tasks, birthdays, market_lists, notes, countdowns, construction, converter
- GAMIFICATION (only if
current_user.gamer_visible?) — gamer, achievements, challenges, leaderboard
Each section: a small uppercase label (text-[10px] tracking-[0.08em] uppercase) followed by a <nav class="space-y-0.5"> of navbar_nav_item calls. Reuse the existing helper as-is — no new helpers introduced.
The exact link list mirrors the mobile drawer (shared/_sidebar.html.erb), so behavior is consistent across breakpoints.
app/views/layouts/application.html.erb
Render the new partial alongside the mobile sidebar:
<% if signed_in? %>
<%= render "shared/sidebar" %>
<% if current_user.sidebar_navbar? %>
<%= render "shared/desktop_sidebar" %>
<% end %>
<% end %>
Adjust <main> to leave room for the sidebar on desktop when the flag is on. Cleanest approach: add a body class or a conditional CSS class to <main> like lg:pl-60 only when signed_in? && current_user.sidebar_navbar?. Existing mx-auto max-w-[1420px] continues to work — content centers in the remaining viewport.
3. Stimulus / dropdown coordination
Mechanism: shared custom event
When any dropdown transitions closed → open, it dispatches a dropdowns:opened event on document with event.detail.source = this (the Stimulus controller instance). Every dropdown controller listens for the event in connect() and calls its own close() if event.detail.source !== this. Listeners are removed in disconnect().
Event name: dropdowns:opened (no namespace prefix needed — short, scoped to navigation behavior).
Per-controller changes
app/javascript/controllers/finance_dropdown_controller.js
- In
open():document.dispatchEvent(new CustomEvent("dropdowns:opened", { detail: { source: this } })) - In
connect(): registerthis.closeIfSibling = (e) => { if (e.detail.source !== this) this.close() }thendocument.addEventListener("dropdowns:opened", this.closeIfSibling) - In
disconnect():document.removeEventListener("dropdowns:opened", this.closeIfSibling)
app/javascript/controllers/notifications_controller.js
- In
toggle(), when transitioning to open (this.openValuebecomestrue), dispatch the same event. - Same listener pair in
connect()/disconnect(). Closing on sibling open should setopenValue = falseand callrender()(the controller's existing close path).
app/javascript/controllers/dropdown_controller.js (used on the user-profile <details>)
- The native
<details>element fires atoggleevent when itsopenattribute changes. Inconnect(), attach a listener: whenthis.element.open === true, dispatchdropdowns:opened. - Add the same
dropdowns:openeddocument listener; when triggered by another source, call existingclose()(which already removes theopenattribute).
Why this shape
- Additive. No controller renames, no shared singleton, no Stimulus outlets needed.
- Public contract. Future dropdowns just dispatch and listen. No central registry to update.
- Self-cleaning. Each controller owns its listener lifecycle in connect/disconnect.
Edge cases
- Clicking an already-open dropdown's own button: handled by each controller's existing
togglelogic, unaffected. - Click-outside-to-close: per-controller, unchanged.
- Keyboard
Esc: per-controller, unchanged. - Turbo navigation: each controller's
disconnectremoves its listener; reconnect on the new page re-registers.
4. Translations
New i18n keys, added to both config/locales/pt-BR.yml and config/locales/en.yml under the existing user_profile: namespace:
layout_section— card title (e.g., "Layout" / "Layout")sidebar_navbar— toggle label (e.g., "Usar barra lateral" / "Use sidebar navigation")sidebar_navbar_hint— short description (e.g., "Substitui os menus do topo por uma barra lateral fixa no desktop." / "Replaces the top dropdowns with a persistent sidebar on desktop.")
5. Testing
Controller spec
In spec/requests/users/registrations_spec.rb (or wherever gamer_visible / home_page update tests live), add cases parallel to the existing ones:
- PATCH with
sidebar_navbar: "1"flips the user'ssidebar_navbartotrue. - PATCH with
sidebar_navbar: "0"flips it back tofalse. - PATCH without the param does not change the value.
Layout / view spec
Request spec or system spec asserting the rendered HTML:
current_user.sidebar_navbar = false: page contains the navbar's Finance/Personal/Tools dropdown markup; does NOT contain the desktop sidebar's distinguishing markup (e.g., the FINANCE section label inside an element matching the new partial's class).current_user.sidebar_navbar = true: navbar does NOT contain those dropdowns; the desktop sidebar IS rendered with the four sections.
Dropdown coordination — system spec (JS-enabled)
On a signed-in dashboard page:
- Click Finance dropdown → its menu becomes visible.
- Click Personal dropdown → Finance menu hidden, Personal menu visible.
- Click Tools dropdown → Personal menu hidden, Tools menu visible.
- Click notification bell → Tools menu hidden, notifications dropdown visible.
- Click profile avatar → notifications dropdown hidden, profile
<details>open.
Translation coverage
If the project has an i18n-tasks (or similar) test, the three new keys must exist in both locales.
File changes summary
New:
db/migrate/<timestamp>_add_sidebar_navbar_to_users.rbapp/views/shared/_desktop_sidebar.html.erbdocs/superpowers/specs/2026-05-07-sidebar-navbar-toggle-design.md(this file)- Spec file additions (controller + system, paths TBD by existing test layout)
Modified:
app/models/user.rb(annotation only)app/controllers/users/registrations_controller.rb(addsave_sidebar_navbar, call fromupdate)app/views/devise/registrations/edit.html.erb(new Layout card)app/views/shared/_navbar.html.erb(wrap CENTER dropdown block)app/views/layouts/application.html.erb(render desktop sidebar conditionally; 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<details>toggle event + listen)config/locales/pt-BR.yml,config/locales/en.yml(3 new keys each)
Risks / open questions
- Navbar height for sidebar
topoffset. The sidebar must start below the sticky navbar. Use the same height the navbar renders at (currentlypy-2.5+ 28px logo ≈ 53px). Verify in the browser; consider a CSS variable rather than a hard-coded value. - Announcement banner. When the announcement banner is shown above the main content, the sidebar's
topoffset must account for it. Either render the sidebar inside the same<header>container so it inheritsstickypositioning, or compute height dynamically. Picking the simpler one — render after<header>and let it scroll independently — needs to be verified visually. - Z-index ordering. Mobile drawer uses
z-50; navbar header usesz-30. Desktop sidebar should sit atz-20(below the sticky header so dropdowns from header still float above) but above page content.