Files
sure/app/views/settings/preferences/show.html.erb
Guillem Arias Fauste 2bcdf6c554 fix(design-system): replace undefined utility classes and broken /N modifiers (#1660)
* fix(design-system): replace undefined utility classes and broken /N modifiers

Audit of class-name resolution in views surfaced two related silent
failures across ~17 files:

1. Class names that don't exist anywhere in the design system. Tailwind
   silently drops them and the element renders with no CSS for that
   property.
   - bg-primary (and bg-primary/5, /10, /90): never defined as a
     custom utility, no --color-primary in @theme. Used as a CTA bg
     in 8 places, all rendered transparent.
   - text-inverted: typo of text-inverse.
   - text-primary-foreground: shadcn/Radix vocabulary, not in our
     token system.
   - bg-accent / border-accent / text-accent: same shadcn vocabulary;
     not defined.

2. Slash modifier (/N) used on custom @utility blocks. Modifiers only
   resolve on Tailwind theme colors (anything in tokens.json color.*).
   Custom @utility blocks compile to static @apply statements and
   silently drop the /N variant. Affected uses:
   - border-surface-inset/50 across provider account selectors.
   - border-secondary/30, /40 in admin SSO form and simplefin setup.
   - bg-surface-inset/30, /40 in settings preferences and simplefin.

Fixes:

| From                                              | To                                                  |
|---------------------------------------------------|------------------------------------------------------|
| bg-primary text-white (and similar primary CTAs)  | button-bg-primary text-inverse                      |
| bg-primary text-primary-foreground (badges)       | button-bg-primary text-inverse                      |
| bg-primary text-inverted (typo)                   | button-bg-primary text-inverse                      |
| bg-primary text-primary (broken active pill)      | bg-inverse text-inverse                             |
| bg-primary (status dot)                           | bg-inverse                                          |
| bg-primary/5, bg-primary/10 (subtle accent bg)    | bg-gray-tint-5, bg-gray-tint-10                     |
| hover:bg-primary/90                               | hover:button-bg-primary-hover                       |
| border-accent bg-accent/10 text-accent (badges)   | border-secondary bg-surface-inset text-secondary    |
| border-surface-inset/50                           | border-secondary                                     |
| border-secondary/30, /40                          | border-tertiary                                      |
| bg-surface-inset/30                               | bg-surface-inset (full strength)                     |
| bg-surface-inset/40                               | bg-container-inset                                   |

Also documents the alpha-modifier limitation in design/tokens/README.md
under a new "Alpha modifiers in views (/N syntax)" section, with the
opacity-N convention for custom utilities and a note that the
gray-tint-5 / gray-tint-10 family (and similar pre-resolved tints) are
theme colors and accept /N modifiers natively.

The accent-badge mapping uses neutral semantics for now. A dedicated
brand-accent token (text-link-tint-10 etc.) is worth considering as a
follow-up if the "highlighted metadata badge" pattern recurs.

* fix(design-system): replace undefined divide-primary / divide-secondary with alpha tokens

Same class of bug as the rest of this PR: divide-{name} requires the
name to be a theme color (i.e. expose --color-{name}), and our custom
@utility utilities (primary, secondary, etc.) do not. Tailwind silently
drops the unrecognized class and rows render with no separator.

Spotted six instances during the visual audit:

- admin/users/index.html.erb (×2): users table + pending invitations
- admin/sso_providers/index.html.erb (×2): configured + legacy lists
- transactions/categorizes/_transaction_list.html.erb: categorize sidebar
- settings/preferences/show.html.erb: divide-secondary/60 (also broken)

Swapped to the alpha-black/white pattern already used elsewhere in the
codebase (imports/cleans/show, transactions/_summary, etc.):

  divide-y divide-primary
  -> divide-y divide-alpha-black-200 theme-dark:divide-alpha-white-200

  divide-y divide-secondary/60
  -> divide-y divide-alpha-black-100 theme-dark:divide-alpha-white-100

The lighter (-100) variant on the preferences list matches the original
intent of /60 (more subtle).
2026-05-04 21:40:17 +02:00

209 lines
11 KiB
Plaintext

<%= content_for :page_title, t(".page_title") %>
<%= settings_section title: t(".general_title"), subtitle: t(".general_subtitle") do %>
<div>
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<%= form.select :locale,
language_options,
{ label: t(".language"), include_blank: t(".language_auto") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= form.fields_for :family do |family_form| %>
<%= family_form.select :timezone,
timezone_options,
{ label: t(".timezone") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :date_format,
Family::DATE_FORMATS,
{ label: t(".date_format") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= form.select :default_account_order,
AccountOrder.all.map { |order| [ order.label, order.key ] },
{ label: t(".default_account_order") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :country,
country_options,
{ label: t(".country") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :month_start_day,
(1..28).map { |day| [day.ordinalize, day] },
{ label: t(".month_start_day"), hint: t(".month_start_day_hint") },
{ data: { auto_submit_form_target: "auto" } } %>
<% if @user.family.uses_custom_month_start? %>
<div class="text-sm text-warning bg-warning/10 p-3 rounded-lg">
<%= t(".month_start_day_warning") %>
</div>
<% end %>
<p class="text-xs italic pl-2 text-secondary">Please note, we are still working on translations for various languages.</p>
<% end %>
<% end %>
</div>
<% end %>
<% if Current.user.admin? %>
<%= settings_section title: t(".currencies_title", moniker: family_moniker), subtitle: t(".currencies_subtitle", moniker: family_moniker_downcase) do %>
<% selected_count_translations = t(".selected_currencies_count") %>
<div class="space-y-4"
data-controller="currency-preferences"
data-currency-preferences-base-currency-value="<%= @user.family.primary_currency_code %>"
data-currency-preferences-locale-value="<%= I18n.locale %>"
data-currency-preferences-selected-count-translations-value="<%= selected_count_translations.to_json %>">
<% additional_preview_currencies = @user.family.secondary_enabled_currency_objects.first(6) %>
<% additional_currency_count = @user.family.secondary_enabled_currency_objects.count %>
<div class="rounded-xl border border-secondary p-4 bg-container-inset">
<div class="grid gap-4 md:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-secondary"><%= t(".base_currency_label") %></p>
<div class="mt-2">
<p class="text-sm font-medium text-primary"><%= @user.family.primary_currency_code %></p>
<p class="text-sm text-secondary"><%= currency_label(@user.family.primary_currency_code) %></p>
</div>
</div>
<div class="md:border-l md:border-secondary md:pl-4">
<p class="text-xs font-medium uppercase tracking-wide text-secondary"><%= t(".additional_currencies_label") %></p>
<% if additional_preview_currencies.any? %>
<div class="mt-2 flex flex-wrap gap-2">
<% additional_preview_currencies.each do |currency| %>
<span class="inline-flex items-center rounded-full bg-surface-inset px-2.5 py-1 text-xs font-medium text-primary">
<%= currency.iso_code %>
</span>
<% end %>
<% if additional_currency_count > additional_preview_currencies.count %>
<span class="inline-flex items-center rounded-full bg-surface-inset px-2.5 py-1 text-xs font-medium text-secondary">
<%= t(".currencies_more", count: additional_currency_count - additional_preview_currencies.count) %>
</span>
<% end %>
</div>
<% else %>
<p class="mt-2 text-sm text-secondary"><%= t(".no_additional_currencies") %></p>
<% end %>
</div>
</div>
</div>
<div class="flex justify-end">
<%= render DS::Button.new(
text: t(".manage_currencies"),
type: :button,
class: "md:w-auto w-full justify-center",
data: { action: "currency-preferences#open" }
) %>
</div>
<%= render DS::Dialog.new(
id: "currency-preferences-dialog",
auto_open: false,
disable_frame: true,
width: "md",
data: { currency_preferences_target: "dialog" }
) do |dialog| %>
<% dialog.with_header(title: t(".manage_currencies"), subtitle: t(".manage_currencies_subtitle")) %>
<% dialog.with_body do %>
<% primary_currency_code = @user.family.primary_currency_code %>
<% selected_currency_codes = @user.family.enabled_currency_codes %>
<% base_currency_rows, other_currency_rows = Money::Currency.as_options.partition { |currency| currency.iso_code == primary_currency_code } %>
<% currency_rows = base_currency_rows + other_currency_rows %>
<%= styled_form_with model: @user, url: user_path(@user), class: "space-y-4", data: { action: "turbo:submit-end->currency-preferences#handleSubmitEnd" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<%= hidden_field_tag "user[family_attributes][id]", @user.family.id %>
<%= hidden_field_tag "user[family_attributes][enabled_currencies][]", primary_currency_code, id: nil %>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap gap-2">
<%= render DS::Button.new(
text: t(".select_all_currencies"),
type: :button,
variant: :ghost,
size: :sm,
data: { action: "currency-preferences#selectAll" }
) %>
<%= render DS::Button.new(
text: t(".select_base_only"),
type: :button,
variant: :ghost,
size: :sm,
data: { action: "currency-preferences#selectBaseOnly" }
) %>
</div>
<p class="text-sm font-medium text-secondary"
data-currency-preferences-target="selectedCount"
role="status"
aria-live="polite"
aria-atomic="true"></p>
</div>
<div data-controller="list-filter" class="space-y-3">
<div class="relative">
<input type="search"
autocomplete="off"
placeholder="<%= t(".currency_search_placeholder") %>"
aria-label="<%= t(".currency_search_placeholder") %>"
data-list-filter-target="input"
data-action="input->list-filter#filter"
class="block w-full border border-secondary rounded-md py-2.5 pl-10 pr-3 bg-container focus:ring-gray-500 sm:text-sm">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<%= icon("search", class: "text-secondary") %>
</div>
</div>
<div data-list-filter-target="list" class="max-h-80 overflow-auto rounded-xl border border-secondary divide-y divide-alpha-black-100 theme-dark:divide-alpha-white-100">
<p class="hidden px-4 py-3 text-sm text-secondary" data-list-filter-target="emptyMessage">
<%= t(".no_matching_currencies") %>
</p>
<% currency_rows.each do |currency| %>
<% checked = selected_currency_codes.include?(currency.iso_code) %>
<% base_currency = currency.iso_code == primary_currency_code %>
<label class="<%= class_names(
"filterable-item flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors hover:bg-surface-inset",
"bg-surface-inset": base_currency,
"cursor-default": base_currency
) %>"
data-filter-name="<%= "#{currency.iso_code} #{currency.name}" %>">
<%= check_box_tag "user[family_attributes][enabled_currencies][]",
currency.iso_code,
checked,
disabled: base_currency,
class: "checkbox checkbox--light mt-0.5",
data: {
currency_preferences_target: "checkbox",
action: "change->currency-preferences#updateSelectedCount"
} %>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-primary"><%= currency.iso_code %></span>
<% if base_currency %>
<span class="inline-flex items-center rounded-full bg-surface px-2 py-0.5 text-[11px] font-medium text-secondary">
<%= t(".base_currency_badge") %>
</span>
<% end %>
</div>
<p class="mt-0.5 text-sm text-secondary"><%= currency.name %></p>
</div>
</div>
</div>
</label>
<% end %>
</div>
</div>
<div class="flex justify-end gap-2">
<%= render DS::Button.new(text: t("shared.cancel"), type: :button, variant: :ghost, data: { action: "DS--dialog#close" }) %>
<%= render DS::Button.new(text: t(".save_currencies")) %>
</div>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
<%= settings_section title: t(".sharing_title", moniker: family_moniker), subtitle: t(".sharing_subtitle", moniker: family_moniker_downcase) do %>
<div>
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<%= form.fields_for :family do |family_form| %>
<%= family_form.select :default_account_sharing,
[
[t(".sharing_shared"), "shared"],
[t(".sharing_private"), "private"]
],
{ label: t(".sharing_default_label") },
{ data: { auto_submit_form_target: "auto" } } %>
<% end %>
<% end %>
</div>
<% end %>
<% end %>