Files
sure/app/views/layouts/application.html.erb
Guillem Arias Fauste 5bb9d0881f fix(accounts): make the sync toolbar and toast agree, fix toast overlap (#2813)
* fix(accounts): make the sync toolbar and toast agree, fix toast overlap

The Accounts page's own sync toolbar (refresh icon, "Cancel sync") and the
global sync-complete toast were three inconsistently-styled, disconnected
pieces of UI representing one action, and the toast overlapped the page's
own header instead of sitting near it.

- "Cancel sync" was hand-rolled markup instead of a DS::Button, unlike its
  sibling refresh icon right next to it — now both are DS::Button (:ghost).
- The refresh icon just went `disabled` with no visible "working" state —
  now shows a spinning loader-circle while a family sync is in progress,
  matching the pattern already used in provider_sync_summary.html.erb.
- The toolbar was plain server-rendered HTML with no way to know a sync
  finished, so it stayed stuck showing "still syncing" indefinitely next to
  a toast now saying otherwise. Family::SyncCompleteEvent now broadcasts a
  second replace target for the toolbar alongside the existing toast
  replace, so both resolve together.
- The notification tray was a <body>-level fixed overlay centered on the
  full viewport, but every layout that renders it has a sidebar of some
  kind — so it never actually centered on the visible content pane, and
  landed on top of the settings-layout header. It now renders in-flow at
  the top of each layout's own content region (opt-in via
  notification_tray_inline, since the simpler single-column layouts don't
  have this mismatch and are unaffected).
- Added the Catalan sync_toast/cancel_sync strings that were missing
  entirely, which is why the toast/toolbar showed English text on an
  otherwise-Catalan page.

Verified live in a real browser via a new system test covering the idle,
syncing, and cancel-flash states, plus a model test on the new broadcast
target.

* fix(accounts): keep the tray a floating overlay, sidebar-aware instead

Codex on this PR: with the tray as first-child-of-scrollable-main, a
notification delivered while scrolled down is inserted above the
viewport and stays unseen — breaking the sync toast's manual-refresh
path specifically, since sync_toast_controller.js suppresses
auto-refresh while a form is focused and relies on the toast being
visible to offer that manual refresh.

Reverts the tray to a position: fixed overlay for every layout (so it
can't be scrolled out of view), and fixes the actual bug that made it
overlap the accounts toolbar in the first place — a ResizeObserver on
<main> centers it on the real content pane instead of the viewport,
for the two layouts with a sidebar (application, settings passed via
sidebar_aware:). The five single-column layouts are untouched; for
those, viewport-center already is content-pane-center.

One trap worth flagging: this app renders turbo_refreshes_with
method: :morph, and idiomorph resets any inline style a client script
set that isn't in the freshly-fetched HTML — including the JS-set
`left`. data-turbo-permanent looked like the fix but isn't: it invokes
idiomorph's node-identity matching (same id preserved across ANY
morphed page), which broke navigation once the id existed on
structurally different layouts (app vs settings) — a real, reproduced
bug, caught by the system test before it shipped. Went with the
narrower turbo:before-morph-attribute event instead, which blocks only
the `style` attribute on this one element, with no node-identity
system involved.

Rewrote the system test's positioning assertion to match: it now
asserts the tray centers on <main> rather than sitting above the page
header, since a fixed overlay was never going to satisfy the latter by
construction.

Verified: full bin/rails test (6023 runs, 0 failures), rubocop, erb_lint,
brakeman (0 warnings) all clean. Live-verified in a browser across both
sidebar-aware layouts and a simple layout, including the full
cancel-sync -> morph -> re-render cycle.

* fix(accounts): use declarative Stimulus action for morph-attribute guard

Replace the manual addEventListener/removeEventListener pair for
turbo:before-morph-attribute with a data-action, per the repo's
declarative-actions convention. Same element, same listener — just no
manual lifecycle management.
2026-07-29 18:01:02 -07:00

241 lines
12 KiB
Plaintext

<% intro_mode = Current.user&.ui_layout_intro? %>
<% home_path = intro_mode ? chats_path : root_path %>
<% mobile_nav_items = if intro_mode
[
{ name: t(".nav.home"), path: chats_path, icon: "home", icon_custom: false, active: page_active?(chats_path) },
{ name: "Intro", path: intro_path, icon: "sparkles", icon_custom: false, active: page_active?(intro_path) }
]
else
[
{ name: t(".nav.home"), path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) },
{ name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
{ name: t(".nav.reports"), path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) },
{ name: t(".nav.budgets"), path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) },
preview_gated_nav_item({ name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) }),
{ name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
].compact
end %>
<% desktop_nav_items = mobile_nav_items.reject { |item| item[:mobile_only] } %>
<% expanded_sidebar_class = "w-full" %>
<% collapsed_sidebar_class = "w-0 overflow-hidden" %>
<%= render "layouts/shared/htmldoc", sidebar_aware: true do %>
<div
class="flex flex-col lg:flex-row h-full bg-surface"
data-controller="app-layout privacy-mode sidebar-resize"
data-app-layout-expanded-sidebar-class="<%= expanded_sidebar_class %>"
data-app-layout-collapsed-sidebar-class="<%= collapsed_sidebar_class %>"
data-app-layout-user-id-value="<%= Current.user.id %>">
<%= link_to t("layouts.application.skip_to_main"), "#main",
class: "sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:px-3 focus:py-2 focus:rounded-lg focus:bg-container focus:text-primary focus:shadow-border-xs" %>
<div
class="hidden fixed inset-0 bg-surface z-20 h-full w-full pt-[calc(env(safe-area-inset-top)+0.75rem)] pr-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] pl-3 overflow-y-auto transition-all duration-300"
data-app-layout-target="mobileSidebar">
<% unless intro_mode %>
<div class="mb-2">
<%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
</div>
<% end %>
<%= render(
"accounts/account_sidebar_tabs",
family: Current.family,
active_tab: @account_group_tab,
mobile: true
) %>
</div>
<%# MOBILE - Top nav %>
<nav class="lg:hidden flex justify-between items-center p-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
<% if intro_mode %>
<% else %>
<%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
<% end %>
<%= link_to home_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
<div class="flex items-center gap-1">
<button type="button"
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring"
data-action="click->privacy-mode#toggle"
data-privacy-mode-target="toggle"
title="<%= t("layouts.application.privacy_mode") %>"
aria-label="<%= t("layouts.application.privacy_mode") %>"
aria-pressed="false">
<%= icon("eye-off", size: "sm", data: { privacy_mode_target: "iconOff" }) %>
<%= icon("eye", size: "sm", class: "hidden", data: { privacy_mode_target: "iconOn" }) %>
</button>
<%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12, intro_mode: intro_mode %>
</div>
</nav>
<%# DESKTOP - Left navbar %>
<div class="hidden lg:block border-r border-divider">
<nav class="h-full flex flex-col shrink-0 w-[84px] py-4">
<div class="pl-2 mb-3">
<%= link_to home_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
</div>
<ul class="space-y-0.5">
<% desktop_nav_items.reject { |item| item[:mobile_only] }.each do |nav_item| %>
<li>
<%= render "layouts/shared/nav_item", **nav_item %>
</li>
<% end %>
</ul>
<div class="pl-2 mt-auto mx-auto flex flex-col gap-2">
<%= render DS::Link.new(
variant: "icon",
icon: "message-circle-question",
href: "https://discord.gg/36ZGBsxYEK",
target: "_blank"
) %>
<%= render "users/user_menu", user: Current.user, intro_mode: intro_mode %>
</div>
</nav>
</div>
<%# DESKTOP - Left sidebar %>
<%= tag.div class: class_names(
"hidden lg:block relative py-4 shrink-0 transition-all duration-300 border-divider",
Current.user.show_sidebar? ? [expanded_sidebar_class, "border-r"] : [collapsed_sidebar_class, "border-r-0"],
),
style: "max-width: var(--left-sidebar-width, 320px)",
inert: !Current.user.show_sidebar?,
data: { app_layout_target: "leftSidebar", sidebar_resize_target: "leftSidebar" } do %>
<%= render "layouts/shared/sidebar_resize_handle", side: "left" %>
<div class="px-4 h-full flex flex-col overflow-y-auto">
<% if content_for?(:sidebar) %>
<%= yield :sidebar %>
<% else %>
<div class="grow">
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_tab: @account_group_tab %>
</div>
<% if Current.family.trialing? && !self_hosted? %>
<div class="px-4 py-3 space-y-4 bg-container shadow-border-xs rounded-xl">
<div class="flex items-start justify-between">
<div>
<p class="text-sm font-medium text-primary"><%= t("layouts.trial.open_demo") %></p>
<p class="text-sm text-secondary"><%= t("layouts.trial.data_deleted_in_days", days: Current.family.days_left_in_trial) %></p>
</div>
<%= render DS::Link.new(
text: t("layouts.trial.contribute"),
href: upgrade_subscription_path,
) %>
</div>
<div class="flex items-center gap-0.5 h-1.5">
<div class="h-full bg-warning rounded-full" style="width: <%= Current.family.percentage_of_trial_completed %>%"></div>
<div class="h-full bg-surface-inset rounded-full" style="width: <%= Current.family.percentage_of_trial_remaining %>%"></div>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>
<%# SHARED - Main content %>
<%= tag.main id: "main", class: class_names("grow overflow-y-auto px-3 lg:px-10 w-full mx-auto pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-0"), data: { app_layout_target: "content", viewport_target: "content", notification_tray_target: "main" } do %>
<% unless intro_mode %>
<div class="hidden lg:flex gap-2 items-center justify-between mb-6 sticky top-0 z-10 -mx-3 lg:-mx-10 px-3 lg:px-10 py-4 bg-surface border-b border-tertiary">
<div class="flex items-center gap-2">
<%= icon("panel-left", as_button: true, data: { action: "app-layout#toggleLeftSidebar" }) %>
<% if content_for?(:breadcrumbs) %>
<%= yield :breadcrumbs %>
<% else %>
<%= render "layouts/shared/breadcrumbs", breadcrumbs: breadcrumbs %>
<% end %>
</div>
<div class="flex items-center gap-2">
<%# Preview-gated. Gating the whole block (rather than just the
link) also keeps the unread COUNT off every page render for
users who haven't opted in. No preview dot on the icon: the
unread badge already occupies that corner. %>
<% if Current.family && preview_features_enabled? %>
<% unread_insights_count = Current.family.insights.active.count %>
<%# Prefetch disabled: /insights marks insights read on real visits
and deliberately skips prefetch requests, so a prefetched
response served on click would never clear the badge. %>
<%= link_to insights_path,
class: "relative inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring",
title: t("layouts.application.insights"),
aria: { label: t("layouts.application.insights") },
data: { turbo_prefetch: false } do %>
<%= icon("lightbulb", size: "sm") %>
<% if unread_insights_count.positive? %>
<span class="absolute top-1 right-1 min-w-4 h-4 px-1 rounded-full bg-inverse text-inverse text-[10px] font-medium flex items-center justify-center pointer-events-none"><%= unread_insights_count > 9 ? "9+" : unread_insights_count %></span>
<% end %>
<% end %>
<% end %>
<button type="button"
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring"
data-action="click->privacy-mode#toggle"
data-privacy-mode-target="toggle"
title="<%= t("layouts.application.privacy_mode") %>"
aria-label="<%= t("layouts.application.privacy_mode") %>"
aria-pressed="false">
<%= icon("eye-off", size: "sm", data: { privacy_mode_target: "iconOff" }) %>
<%= icon("eye", size: "sm", class: "hidden", data: { privacy_mode_target: "iconOn" }) %>
</button>
<%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %>
</div>
</div>
<% end %>
<% if content_for?(:page_header) %>
<%= yield :page_header %>
<% end %>
<%= yield %>
<% end %>
<%# DESKTOP - Right sidebar %>
<%= tag.div class: class_names(
"hidden lg:block relative h-full shrink-0 transition-all duration-300 border-divider",
Current.user.show_ai_sidebar? ? [expanded_sidebar_class, "border-l"] : [collapsed_sidebar_class, "border-l-0"],
),
style: "max-width: var(--right-sidebar-width, 400px)",
inert: !Current.user.show_ai_sidebar?,
data: { app_layout_target: "rightSidebar", sidebar_resize_target: "rightSidebar" } do %>
<%= render "layouts/shared/sidebar_resize_handle", side: "right" %>
<%= tag.div id: "chat-container", class: "relative h-full px-4 overflow-y-auto", data: { controller: "chat hotkey", turbo_permanent: true } do %>
<div class="flex flex-col h-full justify-between shrink-0">
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
<div class="flex justify-center items-center h-full">
<%= icon("loader-circle", class: "animate-spin") %>
</div>
<% end %>
</div>
<% unless Current.user.ai_enabled? %>
<div class="absolute backdrop-blur-lg inset-0 h-full w-full flex flex-col justify-center items-center px-4">
<%= render "chats/ai_consent" %>
</div>
<% end %>
<% end %>
<% end %>
<%# MOBILE - Bottom Nav %>
<%= tag.nav class: "lg:hidden fixed bottom-0 left-0 right-0 bg-surface z-10 border-t border-tertiary flex justify-around pb-[env(safe-area-inset-bottom)]",
data: { viewport_target: "bottomNav" } do %>
<% mobile_nav_items.each do |nav_item| %>
<%= render "layouts/shared/nav_item", **nav_item %>
<% end %>
<% end %>
</div>
<% end %>