Sidebar Navbar Toggle + Single-Open Dropdowns — Design

Date: 2026-05-07 Status: Approved (design phase)

Summary

Two related navigation improvements:

  1. sidebar_navbar user 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.
  2. 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(): register this.closeIfSibling = (e) => { if (e.detail.source !== this) this.close() } then document.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.openValue becomes true), dispatch the same event.
  • Same listener pair in connect() / disconnect(). Closing on sibling open should set openValue = false and call render() (the controller's existing close path).

app/javascript/controllers/dropdown_controller.js (used on the user-profile <details>)

  • The native <details> element fires a toggle event when its open attribute changes. In connect(), attach a listener: when this.element.open === true, dispatch dropdowns:opened.
  • Add the same dropdowns:opened document listener; when triggered by another source, call existing close() (which already removes the open attribute).

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 toggle logic, unaffected.
  • Click-outside-to-close: per-controller, unchanged.
  • Keyboard Esc: per-controller, unchanged.
  • Turbo navigation: each controller's disconnect removes 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's sidebar_navbar to true.
  • PATCH with sidebar_navbar: "0" flips it back to false.
  • 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.

On a signed-in dashboard page:

  1. Click Finance dropdown → its menu becomes visible.
  2. Click Personal dropdown → Finance menu hidden, Personal menu visible.
  3. Click Tools dropdown → Personal menu hidden, Tools menu visible.
  4. Click notification bell → Tools menu hidden, notifications dropdown visible.
  5. 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.rb
  • app/views/shared/_desktop_sidebar.html.erb
  • docs/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 (add save_sidebar_navbar, call from update)
  • 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 top offset. The sidebar must start below the sticky navbar. Use the same height the navbar renders at (currently py-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 top offset must account for it. Either render the sidebar inside the same <header> container so it inherits sticky positioning, 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 uses z-30. Desktop sidebar should sit at z-20 (below the sticky header so dropdowns from header still float above) but above page content.