Commit Graph

2810 Commits

Author SHA1 Message Date
Guillem Arias
709b7bf91b fix(goals): replace ring-gray-500 with semantic ring-alpha-black-500 token
The colour-picker swatches were using the raw Tailwind palette utility
`peer-checked:ring-gray-500`. Swap for the design-system functional
token `peer-checked:ring-alpha-black-500` so the focus ring inherits
the same alpha-on-surface treatment used elsewhere in the system and
respects dark mode.

Flagged by sure-design's DS Drift Patrol on #1798.
2026-05-21 16:08:34 +02:00
Guillem Arias
48862f8ed9 refactor(goals): use semantic color tokens for ring + status callout
`Goals::CardComponent#ring_color` and `goals/_status_callout` reached
into the Tailwind palette directly (`text-yellow-700`,
`var(--color-green-600)`, etc.) for status-coded colors. The
sure-design-system already exposes the matching semantic tokens
(`text-warning`, `text-success`, `--color-success`, `--color-warning`),
which theme-swap correctly in dark mode and survive palette renames
without view edits.

- `ring_color`: collapse `:reached` / `:on_track` to `--color-success`
  (the status pill already differentiates them via icon — completed star
  vs check) and `:behind` to `--color-warning`. The `:no_target_date`
  fallback keeps `--color-gray-400` for now since there's no semantic
  neutral token; that gets cleaned up alongside the DS::ProgressRing
  extraction.
- `_status_callout`: drop `text-yellow-700 theme-dark:text-yellow-300`
  and `text-green-700 theme-dark:text-green-300` for the equivalent
  semantic `text-warning` / `text-success` utilities.

No visual regression in light mode (success collapses two adjacent
greens into one); dark mode now properly inverts via the design
system's theme variants instead of hand-rolled overrides.

The `stroke="var(--budget-unused-fill)"` track on the inline card ring
stays for now — that's a token-rename refactor that touches budget
code outside this PR's scope and lands cleanest with the DS::ProgressRing
primitive that consolidates the three ring implementations.
2026-05-21 15:55:47 +02:00
Guillem Arias
e16d9c4a6a Merge origin/feat/goals-v2-architecture; reconcile beta→preview rename
Remote branch added a beta_gated_nav_item helper + 'Gating the main nav'
docs section. Main concurrently renamed the beta-features gate to
preview-features (concern, predicate, JSONB key, locale flash). Rename
the new helper / partial local / pill marker to match preview naming and
port the nav-gating docs into gating-a-preview-feature.md so the
improvement survives the rename.

Resolved conflicts:
- db/schema.rb: take the later schema version (2026_05_19_100000).
- docs/llm-guides/gating-a-beta-feature.md: accept main's deletion;
  port the 'Gating the main nav' section into the preview guide.

Renames carried through to keep the gate wired end-to-end:
- application_helper.rb: beta_gated_nav_item → preview_gated_nav_item;
  beta_features_enabled? → preview_features_enabled?; beta: → preview:.
- _nav_item.html.erb: beta: local → preview: local; shared.beta i18n
  key → shared.preview.
- application.html.erb: caller renamed to preview_gated_nav_item.
- goals/index.html.erb: pill label uses shared.preview.
- shared/en.yml: 'beta: Beta' → 'preview: Preview'.
- goals_controller, goal_pledges_controller: require_beta_features! →
  require_preview_features!.
- goals_controller_test, goal_pledges_controller_test: flip the
  preference key, flash matcher, and test names to 'preview'.
2026-05-20 21:47:27 +02:00
Guillem Arias
926d71c74a Merge origin/main into feat/goals-v2-architecture
Resolved conflicts:
- db/schema.rb: take main's schema version (later migration timestamp);
  goals + debug_log_entries tables both present.
- app/views/categories/_form.html.erb: keep branch's shared
  color-icon-picker controller action; adopt main's t('.auto_adjust') i18n.
2026-05-20 21:41:47 +02:00
ghost
655895341d feat(imports): verify Sure NDJSON import readback (#1869)
* feat(imports): verify Sure NDJSON readback

* fix(imports): tighten Sure readback verification

* fix(imports): polish Sure verification review nits
2026-05-20 21:35:22 +02:00
Sure Admin (bot)
663a624cc9 fix(preview): surface demo data failure logs (#1865) 2026-05-20 21:20:48 +02:00
sentry[bot]
1f7970010a fix(destroy): prevent UnknownAttributeError in DestroyJob rescue (#1852)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-05-20 21:19:10 +02:00
Michal Tajchert
e21ab9819f feat(dashboard): zoom into cashflow sankey categories (#1807)
* feat(dashboard): zoom into cashflow sankey categories

Click a category node on the dashboard cashflow Sankey to focus on it and
its descendants only; a back button restores the full view. Clicking the
Cash Flow node zooms to the expense (outbound) side.

- Pure utility (app/javascript/utils/sankey_zoom.js) computes the
  descendant subgraph from a clicked node, with direction inferred by
  reachability from the cash flow node (outbound for expense, inbound
  for income).
- Stable node ids emitted from the controller so the JS can identify
  nodes across re-renders.
- Stimulus controller adds chart + zoomOutButton targets, fade
  transition, and only sets a pointer cursor when a node has children.
- Node:test coverage for expense, income, cash-flow, and malformed-data
  cases; \"type\": \"module\" added to package.json so the .js util is
  ESM-compatible under Node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(dashboard): extract cashflow sankey chart partial

Deduplicate sankey chart markup between inline and expanded dialog views,
and reset zoom state when chart data changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(js): rename sankey_zoom util to .mjs to drop project-wide ESM flag

Removes "type": "module" from package.json to avoid implicitly switching
every .js file in the project to ESM (a future footgun for any .js config
file added by Biome, Vite, etc.). Renames the utility to .mjs so node --test
can import the ES module directly, and adds an explicit importmap pin since
pin_all_from only globs .js/.jsm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(assets): register .mjs MIME type for Propshaft

Propshaft derives Content-Type from Mime::Type.lookup_by_extension, which
returns nil for :mjs by default. Browsers refuse to execute ES modules
served with an empty Content-Type, breaking the sankey_zoom util loaded
via importmap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:17:35 +02:00
Josh
675a7164ed fix(helm): skip Redis/DB auto-wiring when extraEnv override is set (#1868)
* fix(helm): skip Redis/DB auto-wiring when extraEnv override is set

Setting rails.extraEnv.REDIS_URL or DATABASE_URL caused the chart to
emit the URL twice, once from auto-wiring and once from the generic
extraEnv loop. Server-side apply rejected the Deployment with a
duplicate-key error.

The auto-wiring path also added REDIS_PASSWORD / DB_PASSWORD env vars
sourced from secret keys that external-service users typically don't
have, so even after deduplicating, pods failed CreateContainerConfigError.

Gate the auto-emit block on the absence of a user override. The
extraEnv loop emits the URL once and no *_PASSWORD env is injected.
Chart-managed Redis/Postgres behavior is unchanged.

* fix(helm): guard extraEnv index against nil

CodeRabbit flagged that `index $ctx.Values.rails.extraEnv "DATABASE_URL"`
(and the REDIS_URL equivalent) panic if a user sets `rails.extraEnv: null`
or omits the field entirely. Wrap with `default (dict)` first, matching
the defensive pattern already used elsewhere in this template.

* fix(helm): use hasKey for extraEnv presence check

Switch from `(index $railsExtraEnv "URL_KEY")` to `hasKey $railsExtraEnv
"URL_KEY"`. With `index`, a user setting `DATABASE_URL: ""` or
`REDIS_URL: ""` in extraEnv would still trigger chart auto-injection
(empty string is falsy), recreating the duplicate-emission bug for the
empty-value case. `hasKey` keys the gate on presence, so any explicit
override (including empty) suppresses auto-wiring.
2026-05-20 20:30:21 +02:00
William Wei Ming
444f5e6a2d optimize net_category_totals() by using memoized cache (#1881)
* optimize net_category_totals() by using memoized cache

* fix issue - net_category_totals cache is never populated - suggested by coderabbitAI
2026-05-20 20:28:26 +02:00
sentry[bot]
ba87a4f0f3 fix(imports): prevent MissingTemplate for JSON requests in index (#1875)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-05-20 20:23:03 +02:00
sentry[bot]
fc5cc26812 fix(entry): Guard entryable_attributes for non-transaction entries in bulk_update! (#1877)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-05-20 19:21:31 +02:00
Guillem Arias Fauste
12785754c8 feat(design-system): split DS::Menu into strict action-list + new DS::Popover (#1850)
* feat(design-system): split DS::Menu into strict action-list + new DS::Popover for mixed content

Closes #1743.

DS::Menu used to absorb both action-list dropdowns (row context menus,
"more actions") AND mixed-content panels (user-account dropdown,
filter forms, picker pop-ups). The two shapes carry incompatible a11y
contracts:

- **Action list**: `role="menu"` container, `role="menuitem"` children,
  Up/Down arrow nav per WAI-ARIA APG.
- **Mixed content**: NO menu role — `role="menu"` restricts AT users
  to menuitem-only navigation and breaks any panel with forms,
  headings, or generic groupings.

This PR splits the component:

## DS::Menu (tightened)

Strict action-list primitive. Variants reduced to `:icon` and
`:button` (no `:avatar`). `custom_content` slot removed. Bakes in:

- `role="menu"` on the panel, `aria-haspopup="menu"` +
  `aria-expanded` + `aria-controls` on the trigger.
- `role="menuitem"` + `tabindex="-1"` on every DS::MenuItem; the
  controller installs roving tabindex (first item gets `tabindex="0"`
  when the menu opens) and handles ArrowUp/Down/Home/End +
  Escape + Enter/Space activation.
- `role="separator"` on the divider variant.
- Stable per-instance `menu-<8-char hex>` id so the trigger's
  `aria-controls` resolves correctly.

`DS::Menu.new(variant: :avatar, ...)` now raises ArgumentError
pointing at DS::Popover.

## DS::Popover (new)

Positioned panel for **mixed**, **non-action-list** content: account
menus, picker forms, filter forms, embedded controls. Slots: `button`,
`header`, `custom_content`. Variants: `:icon`, `:button`, `:avatar`.
NO `role="menu"` — the panel announces as a generic dialog-popup
(`aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`).
Mirrors DS::Menu's floating-ui positioning + Escape/outside-click
lifecycle in its own Stimulus controller (`DS--popover`). Avatar
variant ships a focus ring + bumped touch target (44×44 via `w-11
h-11` per #1738).

## Migrated callsites (7 → DS::Popover)

- `app/views/users/_user_menu.html.erb` — avatar trigger + profile
  header + nav links (items kept as DS::MenuItem inside
  `custom_content` for visual parity)
- `app/views/categories/_menu.html.erb` — turbo-framed category picker
- `app/views/budgets/_budget_header.html.erb` — budget picker
- `app/views/reports/index.html.erb` — period picker
- `app/views/holdings/_cost_basis_cell.html.erb` — cost-basis edit form
- `app/views/transactions/searches/_form.html.erb` — filter form
- `app/components/UI/account/activity_feed.html.erb:70` — status
  checkboxes (the row-level "new" menu on line 9 stays as DS::Menu)

The other 33 DS::Menu callsites stay as-is — pure action lists.

Locale: `ds.popover.avatar_default_label` + `users.user_menu.aria_label`
keys added (en only; other locales handled in a separate i18n pass).

* fix(test): update sidebar user-menu selector for Menu→Popover migration

The user-menu now renders as `DS::Popover` (variant: :avatar) instead
of `DS::Menu` after the menu split, so its trigger carries
`data-DS--popover-target="button"` rather than the old
`data-DS--menu-target`. Update the sidebar-driven settings test helper
to match — every system test that drives Settings via the sidebar
gates on this selector.

* fix(review): DS::Popover/Menu trigger a11y + caller-attr preservation

- popover.rb / menu.rb: button slot now merges (not overwrites) caller-
  provided data and aria hashes, sets aria-haspopup/expanded/controls on
  the :button variant, defaults type="button" on block-rendered buttons.
- menu.rb / menu.html.erb: drop renders_one :header (strict-menu API
  shouldn't expose an arbitrary-markup escape hatch); preview updated.
- menu_controller.js: handle Enter/Space activation on focused menuitem
  so keyboard navigation matches the ARIA menu pattern.
- cost_basis_cell / transactions/searches/_menu: retarget cancel button
  data-action from DS--menu#close to DS--popover#close (host controller
  changed in the migration).

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

* fix(review): MenuItem roving: false for DS::Popover usage

Codex P1 on #1850: \`DS::MenuItem\` hard-codes \`tabindex=\"-1\"\` and
\`role=\"menuitem\"\` for both link and button variants — correct
inside \`DS::Menu\` (which provides arrow-key roving and announces
\`role=\"menu\"\`), but breaks every \`DS::MenuItem\` rendered inside
\`DS::Popover\` (\`app/views/users/_user_menu.html.erb\`). Popover has
no roving handler, so Tab skips every item — Settings, Changelog,
Feedback, Contact, Log out become keyboard-unreachable.

Add a \`roving:\` keyword (default \`true\`) to \`DS::MenuItem\` that
gates both \`tabindex=\"-1\"\` and \`role=\"menuitem\"\`. \`DS::Menu\`
callers keep the default (roving menu semantics intact). Pass
\`roving: false\` from \`_user_menu.html.erb\` so user-menu items land
in the normal Tab order. Existing \`menu.with_item(...)\` callers in
the design system still default to \`true\`, so no behavior change for
\`DS::Menu\` consumers.

* fix(review): make menuitem_attrs authoritative on roving

CodeRabbit Major on #1850: \`merged_opts\` was splatted AFTER
\`menuitem_attrs\` in \`DS::MenuItem#wrapper\`, so a stray
\`role: :button\` or \`tabindex: 0\` from a \`menu.with_item(..., role: …)\`
caller could silently downgrade the \`DS::Menu\` ARIA contract that
\`menuitem_attrs\` enforces.

Strip \`:role\` and \`:tabindex\` from \`merged_opts\` whenever
\`roving\` is enabled, then splat \`menuitem_attrs\` last. When
\`roving: false\` (popover usage in \`_user_menu.html.erb\`) callers
keep full control — Tab order and explicit ARIA stay tunable by the
caller.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-20 18:30:25 +02:00
Guillem Arias Fauste
355648c4a6 refactor(design-system): migrate orphan btn-- buttons to DS::Button / DS::Link (#1715 §5 part A) (#1859)
* refactor(design-system): migrate 9 hand-rolled buttons with orphan btn-- classes to DS::Button / DS::Link

Part of #1715 §5. The `btn`, `btn--primary`, `btn--outline`, `btn--ghost`,
`btn--sm` CSS classes have no backing styles anywhere in the codebase
(no .btn definition in app/assets/, no Bootstrap dependency). These
callsites have been rendering unstyled buttons / links since the
underlying CSS was last removed.

Migrate the 9 broken callsites:

- `app/views/transactions/show.html.erb` — duplicate-merge action
  buttons (×2): `button_to ... class: "btn btn--primary btn--sm"` /
  `class: "btn btn--outline btn--sm"` → DS::Button with href +
  variant + size + `data: { turbo_method: :post }`.
- `app/views/snaptrade_items/select_existing_account.html.erb` —
  "Go to Provider Settings" link → DS::Link primary sm.
- `app/views/indexa_capital_items/select_existing_account.html.erb` —
  same pattern → DS::Link primary sm.
- `app/views/import/confirms/show.html.erb` — Publish button +
  Cancel link → DS::Button primary full-width + DS::Link ghost
  full-width.
- `app/views/simplefin_items/new.html.erb` — Cancel link
  (`class: "btn"` only) + Connect submit → DS::Link secondary +
  bare `f.submit` (already routes to DS::Button via
  StyledFormBuilder).
- `app/views/settings/providers/_ibkr_panel.html.erb`,
  `_snaptrade_panel.html.erb`,
  `_indexa_capital_panel.html.erb` — strip the orphan
  `class: "btn btn--primary"` from `f.submit` callers; the submit
  is already a styled DS::Button via the form builder.

The next PR in this chain (Phase B) will tackle the larger inline-
button cluster (~29 files, 38 instances) — provider panels and
provider-item flows hand-rolling the same
`inline-flex items-center justify-center rounded-lg px-4 py-2
text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover
focus:outline-none focus:ring-2 focus:ring-primary transition-colors`
string.

* fix(review): render DS::Button for unstyled submits in PR #1859

- simplefin_items/new.html.erb uses plain form_with (not
  styled_form_with), so f.submit was rendering a bare browser submit
  input. Render DS::Button with type: :submit explicitly.
- _indexa_capital_panel.html.erb already uses styled_form_with;
  strip the orphan Tailwind class string from f.submit so
  StyledFormBuilder fully owns the DS::Button styling (matches the
  IBKR and SnapTrade panel pattern).

Addresses Codex and CodeRabbit feedback on #1859.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-20 18:27:51 +02:00
Guillem Arias Fauste
e67ff3e3dc refactor(design-system): migrate single-color tokens to @theme + lint @utility /N footgun (#1849)
* refactor(design-system): migrate single-color semantic tokens to @theme + lint @utility /N footgun

Closes #1653. Tailwind v4 auto-generates the `/N` opacity-modifier
pipeline (`color-mix(in oklab, var(--color-X) N%, transparent)`)
only for colors declared in `@theme`. Tokens emitted as
`@utility name { @apply ... }` bypass that pipeline entirely, so
`text-link/70`, `bg-surface/50`, etc. silently compile to nothing —
the workaround from #1626 was `text-inverse opacity-70`.

Migrate the 11 single-color semantic tokens whose class names match
Tailwind's color-utility convention (`bg-X`, `text-X`, `border-X`)
and have no cross-prefix collision:

  bg-surface, bg-surface-hover, bg-surface-inset, bg-surface-inset-hover
  bg-container, bg-container-hover, bg-container-inset, bg-container-inset-hover
  bg-nav-indicator
  text-link
  border-tertiary

After migration, `--color-surface`, `--color-container`, etc. live
in `@theme` and Tailwind auto-generates every prefix variant
(`bg-surface`, `text-surface`, `border-surface`, plus
`/10`..`/100`). The original utility class names are preserved
(now via auto-generation instead of `@utility` blocks), so every
existing callsite continues to work.

NOT migrated, by design:

- **inverse family** (`bg-inverse`, `text-inverse`, `bg-inverse-hover`,
  `border-inverse`): bg- and text- variants have *different* colors,
  cannot share one `--color-inverse`. Renaming the family
  (`bg-strong-surface` + `text-on-strong-surface`) would touch
  ~61 view files and trade one footgun for semantic loss; deferred
  until a concrete `bg-inverse/N` use case appears.
- **primary/secondary/subdued/destructive** (cross-prefix collision):
  `text-primary` (gray.900) and `border-primary` (alpha-black.300)
  carry deliberately distinct values, can't share `--color-primary`.
  Same for the secondary/subdued pairs. Migrating either alone
  would force a rename of the other.
- **button-bg-*, tab-item-*, tab-bg-group**: class names don't
  follow Tailwind's `<prefix>-<name>` convention, so
  auto-generation would emit `bg-button-bg-primary` not
  `button-bg-primary`.
- **composites** (`bg-loader`, `bg-overlay`, `shadow-border-*`,
  `border-divider`): compile to multiple properties or
  alias-reference other utilities — must stay as @utility.

Add an `erb_lint` DeprecatedClasses rule covering the
@utility-only tokens with `\d+` regex modifiers so any future
`text-inverse/70` etc. fails CI with the explanation that
`opacity-N` is the workaround and #1653 is the tracking issue.
Verified the rule fires on synthetic input; verified zero new
violations on the existing app.

Stats: `@utility` blocks dropped from 45 → 34; @theme primitives
grew from 183 → 194.

* fix(review): cover remaining @utility /N footgun tokens in erb_lint

CodeRabbit flagged that the new DeprecatedClasses /N rule missed
seven still-defined @utility color tokens: border-destructive,
border-solid, button-bg-secondary-strong, button-bg-secondary-strong-hover,
button-bg-disabled, button-bg-ghost-hover, button-bg-outline-hover.
Without them, classes like button-bg-disabled/50 pass lint while
Tailwind silently drops the class.

Adding the patterns surfaced two pre-existing offenders
(border-destructive/30, border-destructive/20). Swap both to solid
border-destructive — the @utility override defines red-500 (light)
while --color-destructive in @theme is red-600, so the /N modifier
was rendering an off-shade rather than the intended faded variant.

Verified the rule fires on synthetic input for all seven new
patterns, then verified zero remaining violations on the new
patterns across app/**/*.erb.

* chore(erb_lint): add trailing newline to .erb_lint.yml

Per review feedback on #1849. Some editors flag the missing newline;
keeps style consistent with the rest of the codebase.
2026-05-20 18:20:38 +02:00
Guillem Arias Fauste
25bb394378 fix(design-system): DS::Select a11y — fix aria-expanded, listbox keyboard nav, label binding (#1848)
* fix(design-system): DS::Select a11y — fix aria-expanded, listbox keyboard nav, label binding

Closes #1744. Several concrete bugs from the savings-goals audit:

1. **`aria-expanded` wired to the wrong state.** The template had
   `aria-expanded="<%= @selected_value.present? ? "true" : "false" %>"`,
   which is "has a value been chosen", not "is the menu open".
   AT users heard a misleading signal on every page load.
   Init to `"false"`; the Stimulus controller's openMenu/close
   already correctly maintains the attribute after that.

2. **`aria-labelledby` referenced a nonexistent id.** The trigger
   pointed at `"#{method}_label"`, but the rendered `<label>` had
   no id at all — the binding silently failed. Add
   `id: "#{method}_label"` to `form.label` so the reference
   actually resolves to the label text. Only emit
   `aria-labelledby` when there *is* a visible label.

3. **`tabindex="0"` on every option.** Listbox options should use
   roving tabindex (only the selected option is in tab order; the
   rest are reachable via ArrowUp/Down). Set
   `tabindex="0"` on the selected option only; `"-1"` on the rest.
   The select controller's `select()` handler keeps the roving
   invariant on user interaction.

4. **No keyboard navigation between options.** Add ArrowDown/Up
   (cycle), Home (first), End (last). The existing Enter/Escape
   handlers stay. ArrowUp/Down inside the search input is left
   alone so the input's caret behavior isn't hijacked.

5. **Search input had no accessible name.** Add an explicit
   `aria-label` matching the placeholder copy so AT users hear
   "search" when focus enters the field.

API unchanged. Builder-level routing fix in
`StyledFormBuilder#select` (calling DS::Select for `f.select(...)`
the same way `f.collection_select` already does) is intentionally
out of scope — it's a separate translation pass for the choices
format. Documented as a follow-up.

* fix(review): bridge search input to visible options in DS::Select

ArrowDown/Up from the search input now focus the first/last visible
option, and keyboard navigation operates on visible options only. After
typing a search query, the controller promotes the first visible option
to tabindex="0" so Tab can land on it even when the previously
tab-eligible option is filtered out.

Addresses Codex review on PR #1848 (issue #1744).

* fix(review): include trigger in DS::Select aria-labelledby

Codex P2 follow-up on #1848: \`aria-labelledby=\"#{method}_label\"\`
makes the trigger button's accessible name come solely from the
external form label — that overrides the button's own text node
(\`selected_item[:label]\` / placeholder). Screen readers therefore
announce only "Currency" without ever hearing the selected "USD"
unless the user opens the listbox.

Give the trigger \`id=\"#{method}_trigger\"\` and reference both ids:
\`aria-labelledby=\"#{method}_label #{method}_trigger\"\`. The
accessible-name algorithm concatenates the two, so AT users now
hear \"<Label> <selected value>\" while \`aria-expanded\` /
\`aria-haspopup\` continue to convey the dropdown state.
2026-05-20 18:19:58 +02:00
Guillem Arias Fauste
56ff8513cb fix(design-system): DS::Tabs a11y — WAI-ARIA tab pattern + keyboard nav (#1847)
* fix(design-system): DS::Tabs a11y — WAI-ARIA tab pattern + keyboard nav

Closes #1745. DS::Tabs rendered as a bare `<nav>` + `<button>` list
with no role wiring. AT users would hear "navigation, button,
button, button" instead of the tab semantics. Keyboard users got
no arrow-key nav between tabs.

Five fixes:

1. **Role scaffolding.** `<nav>` → `role="tablist"`,
   `aria-orientation="horizontal"`. Each tab `<button>` →
   `role="tab"`, `aria-selected`, `aria-controls="panel-#{id}"`.
   Each panel `<div>` → `role="tabpanel"`, `id="panel-#{tab_id}"`,
   `aria-labelledby="#{tab_id}"`, `tabindex="0"` (so the panel
   itself is reachable via keyboard for in-panel content nav).

2. **Roving tabindex.** Active tab is `tabindex="0"`, inactive are
   `tabindex="-1"`. ArrowLeft/Right cycles focus across the tablist
   without leaving the widget; Tab jumps past the whole widget.
   Stimulus controller updates both `aria-selected` and `tabindex`
   on tab switch.

3. **Manual activation.** Per WAI-ARIA APG "Tabs with Manual
   Activation" — arrow keys MOVE focus, Enter/Space ACTIVATES the
   focused tab. Avoids accidental tab swaps when the user is just
   navigating. Important here because several tab contents trigger
   Turbo fetches (transactions index, account sidebar, budgets).

4. **Home/End shortcuts.** Home jumps focus to the first tab, End
   to the last. WAI-ARIA APG-standard.

5. **Raw palette → token.** Replace `bg-white theme-dark:bg-gray-700`
   on the active button with the existing `tab-item-active` utility
   (defined in `_generated.css` from `design/tokens/sure.tokens.json`).
   Single class, dual-mode. Also gate the transition behind
   `motion-safe:` so reduced-motion users get an instant snap.

API unchanged — the slot signatures (`btns(id:, label:)`,
`panels(tab_id:)`) take the same args. Caller-provided `id:` is
still the public identifier; `panel-#{id}` is internal naming for
the `aria-controls`/`aria-labelledby` pair.

* fix(review): scope DS::Tabs DOM ids to component instance

Per CodeRabbit review on #1847: raw `panel-#{tab_id}` and `id: tab_id`
on buttons collide when multiple DS::Tabs widgets on the same page
share generic tab ids (e.g., "all", "overview", "transactions"),
breaking aria-controls / aria-labelledby associations.

Scope ids via per-instance `dom_prefix` ("tabs-#{object_id}") and
share the same prefix between DS::Tabs and DS::Tabs::Nav so button
ids and panel labelledby/controls stay consistent.

* fix(review): use <div> host for role=tablist in DS::Tabs::Nav

Codex P2 follow-up on #1847: \`<nav>\` has a fixed landmark role per
ARIA-in-HTML and may not be repurposed as a tablist. The current
\`tag.nav class: ..., role: \"tablist\"\` produces invalid markup —
some AT implementations ignore the role override, in which case the
child \`role=\"tab\"\` buttons end up without a valid tablist parent
and the keyboard / AT contract this PR is meant to add silently
regresses.

Swap the container for a neutral \`tag.div\`. Tab semantics (\`role\`,
\`aria-orientation\`, keyboard nav, manual-activation pattern) are
unchanged.
2026-05-20 18:19:17 +02:00
Guillem Arias Fauste
7a0cafd6ba fix(design-system): DS::Dialog a11y — role, aria-modal, aria-labelledby, heading_level (#1846)
* fix(design-system): DS::Dialog a11y — role, aria-modal, aria-labelledby, heading_level

Closes #1740. The savings-goals audit captured the dialog rendering
without `role`, `aria-modal`, or `aria-labelledby` — AT users
landing focus inside the dialog hear no title and no modal-mode
hint. Affects every modal/drawer surface in the app (transfer
matches, valuations, trades, imports, settings, etc. — 30+ views).

Fixes:

1. `role="dialog"` + `aria-modal="true"` on the `<dialog>` element.
   Native `<dialog>` already maps to these implicitly in modern
   browsers, but Safari and pre-2024 mappings benefit from the
   explicit role.

2. `aria-labelledby` wired to a stable `dialog-title-<8-char hex>`
   id minted in initialize. The header slot's `<h*>` carries the
   matching id; AT now announces the title on focus-in. If the
   caller passes `custom_header: true` (no title), the
   `aria-labelledby` reference resolves to nothing and AT
   gracefully falls back to the first focusable.

3. New `heading_level:` kwarg (default `2`). Lets callers nest
   dialogs inside surfaces that already have an `<h2>` heading
   without breaking outline order. The existing `<h2>` baseline
   stays as the default.

API is additive; existing 30+ DS::Dialog callsites work without
modification.

Out of scope (own issues):
- Drawer modal-vs-non-modal split (`<dialog>` is currently always
  opened via `showModal()`). Browser behavior is correct for both
  variants today; non-modal drawer is a separate UX call.
- Reduced-motion audit — no CSS transitions on `dialog` open/close.
- Explicit focus-on-open (title vs first input) — browser-native
  `showModal()` already focuses the first focusable; caller can
  override with `autofocus`. Not changing the default here.
- `en.common.close` missing translation — separate bug, filed.

* fix(review): gate aria-labelledby + validate heading_level

Only emit aria-labelledby when the header slot rendered an auto-title
so the id reference never dangles (custom_header: true and body-only
dialogs like the global confirm dialog no longer expose a broken
label). Validate heading_level is an Integer 1..6 in the initializer
to prevent invalid <h0>/<h7> markup. Update stale comment that
referenced tag.public_send instead of content_tag.

* fix(ds-dialog): always emit aria-labelledby (slot lambda is lazy)

The previous fix gated `aria-labelledby` on `@has_auto_title`, set
inside the `renders_one :header` slot lambda. ViewComponent v3
evaluates slot lambdas lazily at slot-render time (after the parent
template's `tag.dialog` opening attributes are computed), so the
flag was always `false` when the `aria-labelledby` attribute was
read.

Verified end-to-end via Playwright on `/design-system/preview/dialog/{modal,drawer}`:
the rendered `<dialog>` is missing `aria-labelledby` even when
`with_header(title: ...)` is set, despite the matching `<h2 id="dialog-title-...">`
being present in the DOM. AT therefore announces "dialog" with no
title — the exact regression the PR set out to fix on slot-driven
callers (which is every dialog in the app).

Always emitting `aria-labelledby="dialog-title-<hex>"` is safe per
the WAI-ARIA spec: a dangling reference (e.g. `custom_header: true`
or body-only dialogs) is silently ignored, and callers can override
via `**opts` (last-wins). This matches the intent stated in the PR
body of #1740.

- Drop now-dead `@has_auto_title` ivar + `has_auto_title?` predicate.
- Update template comment to explain the slot-lambda timing trap.
2026-05-20 18:18:38 +02:00
Guillem Arias Fauste
e30ccd94af fix(design-system): DS::Tooltip a11y — focusable trigger, keyboard parity, Esc dismiss (#1845)
* fix(design-system): DS::Tooltip a11y — focusable trigger, keyboard parity, Esc dismiss

Closes #1747. Five fixes on the tooltip primitive.

1. **Tooltip anchor not in a11y tree.** The trigger was a bare
   Lucide icon, which Lucide renders with `aria-hidden="true"`.
   The tooltip target had `role="tooltip"` but nothing referenced
   it, so AT users had no way to discover the description. Wrap
   the icon in a focusable `<button type="button">` with
   `aria-describedby="<tooltip-id>"` so the underlying icon stays
   `aria-hidden` and the button picks up the description binding.

2. **Stable per-instance id.** Each DS::Tooltip now mints a
   `tooltip-<8-char hex>` id wired between the trigger's
   `aria-describedby` and the tooltip's `id`.

3. **Keyboard parity.** Hover-only triggers locked keyboard-only
   users out. Add `focusin` / `focusout` listeners on the
   controller element so Tab onto the trigger reveals the
   tooltip, Tab away dismisses it.

4. **Esc-to-dismiss.** Matches the WAI-ARIA tooltip pattern.
   `Escape` while the tooltip is open closes it without removing
   focus from the trigger.

5. **Resize-safe width cap.** Replace the hard-coded
   `max-w-[200px]` with `max-w-[20rem]` so the tooltip scales
   with the user's root font-size setting (large-text accessibility
   pref). Slightly wider visual cap (320px @ default) but no longer
   clips on text-zoom.

Plus: docstring note that tooltip content must be non-interactive
(no buttons / links / form controls inside) — `aria-describedby`
exposes content as a description, not as an interactive subtree.
Callers needing actions should reach for a popover/menu primitive.

API unchanged. Existing 30+ DS::Tooltip callsites work without
modification — they all pass `text:`-only payloads, which still
render correctly under the new markup.

* fix(review): as: option + alpha focus-ring on DS::Tooltip

Addresses two AI review findings on #1845:

1. **Button-inside-summary spec violation.** Wrapping the icon in
   `<button>` regressed keyboard/AT behavior at 13 callsites where
   DS::Tooltip lives inside a `<summary>` (8 provider items, lunchflow
   disclosure, activity_date, 4 simplefin badges). HTML's content
   model forbids interactive content inside `<summary>`; browsers
   and AT can drop focus or conflate activation with the disclosure
   toggle. Add `as:` parameter — default `:button` preserves the
   standalone a11y wrap; `:span` renders a non-focusable wrapper for
   summary-nested usage. `focusin` bubbles up to the controller from
   the ancestor `<summary>`, so keyboard tooltips still appear on
   tab. Migrate the 13 in-summary callsites to `as: :span`.

2. **Raw palette focus ring → alpha tokens.** Swap
   `outline-gray-900 theme-dark:focus-visible:outline-white` to the
   established focus-ring pattern `focus-visible:ring-2
   focus-visible:ring-alpha-black-300
   theme-dark:focus-visible:ring-alpha-white-300` — matches the
   DS::Toggle fix landed in #1843 review and provider_card /
   form-field tokens.

* fix(review): bind tooltip focus on ancestor <summary>

Codex P2 follow-up on #1845: \`as: :span\` renders a non-focusable
trigger inside the disclosure \`<summary>\`. Keyboard users hit Tab
and focus lands on the summary itself; \`focusin\` fires on the
summary and bubbles UP — never down to a descendant span — so the
existing listener on \`this.element\` never fires and the tooltip
stays hidden for keyboard-only users on every in-summary row
(provider _item partials, lunchflow disclosure, activity_date,
simplefin badges). My earlier reply that the focusin "bubbles up to
the Stimulus controller on the outer span" was wrong about the
direction; \`focusin\` only bubbles upward.

In \`addEventListeners\`, resolve \`this.element.closest("summary")\`
and bind \`focusin\` / \`focusout\` / \`keydown\` on it too. Track the
ancestor on the controller and undo the bindings in
\`removeEventListeners\` so reconnect-on-Turbo cycles don't leak.
Update the template comment to reflect the actual mechanism.

* docs(ds-tooltip): correct as=:span comment to match controller mechanism

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-20 18:17:51 +02:00
Guillem Arias Fauste
f2782901d3 fix(design-system): DS::Link a11y — distinguishable default, icon-only label, external-link hardening (#1844)
* fix(design-system): DS::Link a11y — distinguishable default,
icon-only label, external-link hardening

Closes #1739. DS::Link extends Buttonish, so the styled variants
(`:primary`, `:secondary`, `:icon`, `:ghost`, etc.) inherit the
Buttonish styling pipeline. The `default` variant is the bare
inline link, which had multiple a11y gaps:

1. **WCAG 1.4.1 — color is not the only difference.** The default
   variant had `container_classes: ""`, so a link rendered as plain
   text-color text with no underline, no weight change, nothing.
   Color-only differentiation fails WCAG 1.4.1 for low-vision and
   colorblind users. Now: `text-link underline underline-offset-2
   hover:no-underline` — underlined at rest, underline removed on
   hover for a polish hint, plus the `text-link` token (blue-600
   light / blue-500 dark) for color.

2. **Focus ring.** `<a>` doesn't pick up the `button` focus rule
   from base.css (#1738). Add `focus-visible:outline-2
   outline-offset-2 outline-gray-900 theme-dark:outline-white`
   directly on the default variant. The Buttonish-derived variants
   render as buttons visually but as `<a>` in markup — out of scope
   here; covered by their own callsites styling.

3. **Icon-only accessible name.** Mirror the DS::Button fix from
   #1738: derive a humanized `aria-label` from the icon key when
   the caller doesn't provide one, so AT users hear "More
   horizontal" instead of just the URL.

4. **External-link hardening.** `target="_blank"` without
   `rel="noopener"` exposes `window.opener` to the new tab
   (reverse-tabnabbing). Always set `noopener noreferrer` when the
   target is `_blank`. Authors can override by passing `rel:`
   explicitly.

5. **sr-only "(opens in new tab)" hint.** Append an `sr-only` span
   after the link text when `target="_blank"` so AT users hear the
   navigation behavior. Visual indication (e.g. an external-link
   icon) stays at the caller's discretion.

Locale key: `ds.link.opens_in_new_tab` (en only — other locales in
a separate translation pass per repo norm).

API unchanged. No existing callsites use `target="_blank"` or
icon-only links, so no migration needed.

* fix(review): fold new-tab cue into icon-only aria-label

When an icon-only DS::Link also targets `_blank`, the generated
`aria-label` was overriding the descendant accessible name, masking
the sr-only "(opens in new tab)" span. Include the cue directly in
the generated label so AT users hear the warning. Also switch
`capitalize` to `humanize` so multi-word icon keys like
`external-link` read as "External link" rather than "External link"
already worked but `humanize` is the more idiomatic Rails choice and
keeps us aligned with the suggested patch.

Flagged by Codex P2 + CodeRabbit on PR #1844.

* fix(review): swap raw outline palette to alpha-ring tokens

Codex P1 follow-up after the ready-for-review transition: the default
\`DS::Link\` focus ring used raw \`outline-gray-900\` +
\`theme-dark:focus-visible:outline-white\`, which violates the DS-hygiene
rule that bans raw Tailwind palette utilities in component styling.

Swap to the established alpha-ring pattern already used by DS::Toggle
(#1843), DS::Tooltip (#1845), provider_card, and form-field —
\`focus-visible:ring-2 focus-visible:ring-alpha-black-300\` +
\`theme-dark:focus-visible:ring-alpha-white-300\`. Same visual contract
(WCAG 1.4.11), theme tokens centralized.
2026-05-20 18:16:20 +02:00
Guillem Arias Fauste
cdce00c71e refactor(design-system): migrate 38 hand-rolled provider buttons to DS::Button / DS::Link (#1715 §5 part B) (#1860)
* refactor(design-system): migrate 9 hand-rolled buttons with orphan btn-- classes to DS::Button / DS::Link

Part of #1715 §5. The `btn`, `btn--primary`, `btn--outline`, `btn--ghost`,
`btn--sm` CSS classes have no backing styles anywhere in the codebase
(no .btn definition in app/assets/, no Bootstrap dependency). These
callsites have been rendering unstyled buttons / links since the
underlying CSS was last removed.

Migrate the 9 broken callsites:

- `app/views/transactions/show.html.erb` — duplicate-merge action
  buttons (×2): `button_to ... class: "btn btn--primary btn--sm"` /
  `class: "btn btn--outline btn--sm"` → DS::Button with href +
  variant + size + `data: { turbo_method: :post }`.
- `app/views/snaptrade_items/select_existing_account.html.erb` —
  "Go to Provider Settings" link → DS::Link primary sm.
- `app/views/indexa_capital_items/select_existing_account.html.erb` —
  same pattern → DS::Link primary sm.
- `app/views/import/confirms/show.html.erb` — Publish button +
  Cancel link → DS::Button primary full-width + DS::Link ghost
  full-width.
- `app/views/simplefin_items/new.html.erb` — Cancel link
  (`class: "btn"` only) + Connect submit → DS::Link secondary +
  bare `f.submit` (already routes to DS::Button via
  StyledFormBuilder).
- `app/views/settings/providers/_ibkr_panel.html.erb`,
  `_snaptrade_panel.html.erb`,
  `_indexa_capital_panel.html.erb` — strip the orphan
  `class: "btn btn--primary"` from `f.submit` callers; the submit
  is already a styled DS::Button via the form builder.

The next PR in this chain (Phase B) will tackle the larger inline-
button cluster (~29 files, 38 instances) — provider panels and
provider-item flows hand-rolling the same
`inline-flex items-center justify-center rounded-lg px-4 py-2
text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover
focus:outline-none focus:ring-2 focus:ring-primary transition-colors`
string.

* refactor(design-system): migrate 38 hand-rolled provider buttons to DS::Button / DS::Link (#1715 §5 part B)

Bulk sweep of the second cluster from §5. 29 files, 38 button
instances — each one hand-rolled the same long Tailwind string for
the primary action button:

  inline-flex items-center justify-center rounded-lg px-4 py-2
  text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover
  focus:outline-none focus:ring-2 focus:ring-primary transition-colors

(some variations used `button-bg-primary hover:button-bg-primary-hover`
instead of `bg-inverse hover:bg-inverse-hover` — same intent).

Every instance is now a DS::Button / DS::Link with `variant: :primary`,
which:

- Picks up the new focus-ring + touch-target work from #1840 once
  that merges.
- Stops duplicating the long Tailwind string across 29 files —
  single source of truth in `DS::Buttonish::VARIANTS[:primary]`.
- Picks up consistent `aria-label` derivation for icon-only forms.
- Removes the misnamed `focus:ring-primary` (no token) — the new
  ring comes from `base.css` automatically.

Migration patterns applied:

- `f.submit text, class: "inline-flex …"` inside `styled_form_with`
  → bare `<%= f.submit text %>`. StyledFormBuilder routes through
  DS::Button.
- `link_to text, path, class: "inline-flex …"` → DS::Link primary.
- `button_to text, path, method: :X, class: "inline-flex …"` →
  DS::Button with `href: path` and `data: { turbo_method: :X }`.
- `submit_tag text, class: "inline-flex …"` inside raw `form_with`
  → DS::Button with `type: :submit`.

Notable adjustments:

- `holdings/show.html.erb` — the form was `form_with` (not styled).
  Switched to `styled_form_with` so `f.submit` routes through
  DS::Button. `f.combobox` (hotwire_combobox) still works through
  the styled builder.
- Two `link_to settings_providers_path` callsites in
  `coinstats_items/new.html.erb` + `enable_banking_items/new.html.erb`
  had `w-full inline-flex … hidden md:inline-flex` — the responsive
  pair conflicted (both `inline-flex` and `hidden md:inline-flex`
  on the same element). Migrated to `full_width: true` without the
  responsive split; the buttons now render at all breakpoints
  consistently. (Pre-existing copy-paste bug, fixed in passing.)
- `enable_banking_panel` add-connection button gained
  `icon: "plus"` via the DS::Button API; the explicit `gap-2 …
  icon "plus"` markup is now redundant.

Sibling buttons that don't match the primary spec (destructive
trash, secondary outline-bordered, button-bg-secondary-strong on
holdings/show.html.erb, etc.) are intentionally left alone — they
need their own audit pass once #1840 lands and the focus-ring
behavior on those variants is stable.

* fix(review): restore SimpleFIN submit styling + i18n provider_form label

- SimpleFIN new modal: switch form_with -> styled_form_with so f.submit
  picks up the DS::Button render via styled builder (Codex #1860).
- _provider_form: replace hardcoded "Save and connect" with t(".save_and_connect")
  and add scoped key under settings.providers.provider_form (CodeRabbit).
2026-05-20 18:15:15 +02:00
Guillem Arias Fauste
272b8acd66 feat(theme): broadcast theme:change so SVG/canvas consumers can repaint (#1839)
`theme_controller#setTheme` already toggles `data-theme` on the
document element, but D3/SVG/canvas consumers that bake color into
attributes (`fill`, `stroke`, `stop-color`) can't observe a CSS
variable change — they need an imperative re-render hook.

Dispatch a `theme:change` CustomEvent on the document element after
the attribute flips, with `detail: { theme: "dark" | "light" }`.
Consumers subscribe via standard connect/disconnect listeners.

Refactor the if/else into a single path while at it — same behavior,
half the lines.

Closes #1764.
2026-05-20 18:13:07 +02:00
Guillem Arias Fauste
e07d641ead fix(design-system): DS::Button a11y audit — focus ring, touch target, type default, icon-only label (#1840)
* fix(design-system): DS::Button a11y audit

Closes #1738. Four concrete fixes surfaced by the savings-goals
audit + #1737 universal checklist:

1. Focus ring (WCAG 2.4.7). `base.css` had
   `focus-visible:outline-gray-900` which is **1.07:1** against the
   primary button's gray-900 background — invisible. Widen to
   `outline-2 outline-offset-2`, place outline outside the button
   via offset, and add a dark-mode `outline-white` so the ring is
   always visible against the page chrome regardless of the button
   surface.

2. Touch target (WCAG 2.5.5). Icon-only buttons at the default
   `:md` size were `w-9 h-9` = 36×36, below the 44×44 enhanced
   target. Bump `md.icon_container_classes` to `w-11 h-11` and
   `lg.icon_container_classes` to `w-12 h-12` to keep the size
   scale intact. `sm` stays at 32×32 (already passes WCAG 2.5.8
   AA's 24×24 minimum; intentional compact-density variant).

3. Default button type. `content_tag(:button, ...)` inherits the
   HTML default `type="submit"`, so a DS::Button rendered inside a
   form steals Enter-key submission from the first text input
   (reproducible in the form stepper). Default to `type="button"`
   in the non-`href` branch; existing form submitters pass
   `type: "submit"` explicitly and continue to work. The `button_to`
   (href) branch keeps the submit default because button_to wraps
   its own form.

4. Icon-only accessible name. Icon-only buttons render no text
   node, so AT users hear "button" with no name. Derive a
   humanized aria-label from the icon key (e.g. `icon: "more-horizontal"`
   → `aria-label="More horizontal"`); explicit
   `aria: { label: }` on the caller still wins. Soft fallback —
   callers should still pass meaningful labels for richer copy.

Plus: replace the stale `fg-white` icon class on the destructive
variant with `text-inverse` (the `fg-*` namespace was deprecated
in #1626 so `fg-white` resolved to nothing; the icon was using its
helper-default color rather than the white the design intended).

Out of scope:
- Menu avatar trigger (custom 36×36 button bypassing DS::Button) —
  belongs to #1743 DS::Menu audit.
- DS::FilledIcon `lg` size container (decorative, not interactive)
  — belongs to #1742.

* fix(design-system): force type=submit on StyledFormBuilder#submit

The DS::Button default-type-button change in the previous commit
broke every `form.submit "Log in"` callsite because
`StyledFormBuilder#submit` (app/helpers/styled_form_builder.rb)
renders a DS::Button under the hood with no explicit `type:`.

After the default flip, those submit buttons rendered as
`type="button"`, so submitting forms (login, password reset, every
form using `form.submit`) silently no-ops. CI surfaced this via
~30 system tests failing in the `sign_in` helper, which couldn't
get past the login page.

Pin `type: "submit"` on the DS::Button rendered by
`StyledFormBuilder#submit`. The 22 view-level `f.submit` /
`render DS::Button.new(type: :submit, ...)` callers already pass
type explicitly and are unaffected.

* fix(review): href-branch type-button bug + focus-ring tokens + profile Save submit

CodeRabbit P1+P2 review on #1840:

1. button.rb: `merged_opts.delete(:href)` always returned nil because
   Buttonish#initialize strips :href from opts into @href, so the
   `if href.blank?` guard was ALWAYS true. Every DS::Button rendered via
   button_to (the href branch) got `type="button"` on the inner button,
   breaking submission of those button_to-generated forms (e.g.
   imports/_ready.html.erb publish button, imports/_failure.html.erb
   try-again button). Drop the local `href = merged_opts.delete(:href)`
   so the guard now reads the @href reader, leaving the href branch's
   HTML default intact.

2. settings/profiles/show.html.erb: the Save button is rendered with
   `render DS::Button.new(...)` inside `styled_form_with` (not via
   form.submit), so the StyledFormBuilder#submit type-pin from
   624e9794 doesn't cover it. Pass `type: :submit` explicitly so the
   profile form submits again under the default-type-button policy.

3. base.css: replace raw `outline-gray-900` / `outline-white` with the
   established alpha-ring focus pattern
   (focus-visible:ring-alpha-black-300 + theme-dark:ring-alpha-white-300)
   already used by app/components/settings/provider_card.html.erb and
   sure-design-system/components.css. Keeps a11y focus ring while using
   DS tokens.

* fix(review): add type: :submit to DS::Button submitters inside forms

CI test_system on #1840 surfaced 6 failures (confirm-dialog close,
property create/edit, transaction filter apply) caused by the same
gap that db563f3d started addressing: the default-type-button policy
on DS::Button means every \`render DS::Button.new(...)\` inside a
\`<form>\` (or \`styled_form_with\`) that relies on the HTML default to
submit is now an inert \`type="button"\`.

Audited every \`render DS::Button.new(\` callsite repo-wide for the
combination (no \`type:\`, no \`href:\`, inside a form context) and
pinned \`type: :submit\` explicitly on the 12 forms that need it:

- layouts/shared/_confirm_dialog.html.erb: Confirm button inside the
  global \`<form method=\"dialog\">\` — fixes
  test_should_allow_revoking_API_key_with_confirmation.
- properties/{new,edit,balances}.html.erb: Save/Next submitter inside
  \`styled_form_with\` — fixes test_can_create_property_account,
  test_can_persist_property_subtype.
- transactions/searches/_menu.html.erb: Apply inside the filter form —
  fixes test_can_filter_uncategorized_transactions,
  test_all_filters_work_and_empty_state_shows_if_no_match,
  test_can_open_filters_and_apply_one_or_more.
- transactions/bulk_updates/new.html.erb: Save in bulk-edit drawer.
- account_sharings/show.html.erb: Save in account-sharing form.
- category/deletions/new.html.erb, tag/deletions/new.html.erb:
  destructive + safe submit buttons in deletion dialog forms.
- family_merchants/merge.html.erb: Submit in merge form.
- subscriptions/upgrade.html.erb: contribute_and_support_sure submit.
- rules/_category_rule_cta.html.erb: Dismiss inside the
  rule_prompts_disabled form.

Cancel/close DS::Button instances inside these same forms intentionally
keep the \`type=button\` default since they drive JS-only actions
(\`DS--dialog#close\`, \`DS--menu#close\`).

* fix(review): add type: :submit to 4 remaining form-context DS::Button callers

Second sweep for the same default-type-button regression that 24c517eb
fixed for 12 callsites. The latest CI run on this branch narrowed the
failures from 6 to 2 (the property wizard's Address step still failed
because that view was not in the first sweep). Audited via a wider
4000-char form-context window:

- app/views/properties/address.html.erb: Save inside
  styled_form_with — fixes the remaining
  test_can_create_property_account + test_can_persist_property_subtype
  by letting Step 3 of the property wizard complete.
- app/views/onboardings/goals.html.erb: Submit inside form_with so
  the onboarding goals step submits.
- app/views/account_sharings/show.html.erb (owner-side form): Save
  button for the family-share permissions form (the non-owner Save
  was already fixed in 24c517eb).
- app/views/transactions/_attachments.html.erb: Upload inside
  styled_form_with — kept the JS-driven hook (attachment_upload_target)
  but explicit type:submit covers the no-JS fallback.

* fix(review): pin type=submit on the Save currencies button

Codex P1 (third pass) caught one more in-form DS::Button I missed in
the earlier sweeps: \`app/views/settings/preferences/show.html.erb:185\`
renders the Save currencies submit deep inside a long
\`styled_form_with\` block. The form-context scan I used had a finite
look-back window which missed it because the matching
\`styled_form_with\` opener sits ~80 lines / 4k+ characters above the
button. Switched to a whole-file scan to confirm no further callsite
remains.
2026-05-20 18:12:36 +02:00
Guillem Arias Fauste
34d6f4d8d6 fix(design-system): DS::Disclosure focus ring + motion-safe chevron (#1841)
Closes #1741. Two small a11y polishes on the native `<details>` /
`<summary>` primitive:

- Add a token-backed focus-visible ring on `<summary>`. Previously
  inherited only the browser default outline, which was thin and
  inconsistent across engines. Match the new pattern from #1738:
  `outline-2 outline-offset-2 outline-gray-900` plus
  `theme-dark:outline-white` so the ring lands on the page chrome
  outside the disclosure regardless of mode. (WCAG 2.4.7.)

- Gate the chevron rotation behind `motion-safe:transition-transform`
  + `motion-safe:duration-150`. The chevron now slides between
  closed/open states for users who haven't opted out of motion, and
  snap-rotates instantly under `prefers-reduced-motion: reduce`.
  (WCAG 2.3.3, AAA.)

While here: drop the no-op `group-open:transform` class. Tailwind
v4 applies `rotate-90` / `rotate-180` directly without needing the
explicit `transform` utility — it was a v3 holdover.
2026-05-20 18:10:16 +02:00
Guillem Arias Fauste
51b0336262 fix(design-system): DS::FilledIcon decorative-vs-meaningful API (#1842)
* fix(design-system): DS::FilledIcon decorative-vs-meaningful API

Closes #1742. `DS::FilledIcon` is mostly used as a decorative
visual indicator next to a textual label (transaction merchant
avatar, recurring-transaction icon, payment-method tile, etc.).
The wrapper was rendering without any aria scaffolding, so screen
readers had to traverse the inner `<svg>` or single-letter `<span>`
with no context.

Two new kwargs:

- `description:` (nil) — when set, the wrapper emits
  `role="img" aria-label="<description>"`. Use this when the
  surrounding DOM does not carry the label (e.g. icon-only badges
  in a grid).

- `aria_hidden:` (auto) — defaults to `true` when `description:` is
  blank (= decorative), `false` when description is present. Pass
  explicitly to override for the rare case where you want the
  visual exposed but the name already lives in adjacent text.

API stays backwards-compatible: existing 33 callsites get
`aria-hidden="true"` by default, which is correct — the visible
text next to the icon already carries the name.

While here: doc the `:text` variant gotcha — only `text.first` is
rendered, so AT users hearing "A" can't infer "Apple". Callers
should pass the full `description:` when relying on this variant.

Out of scope (filed elsewhere if needed):
- Touch-target audit (decorative wrapper, WCAG 2.5.5 doesn't apply).
- `hex_color:` palette soft-validation (would require a token-name
  registry; deferred until #1736 / #1653 land).
- `color-mix(in oklab, ...)` browser-support note for the
  transparent variant — tier-2 concern.

* fix(review): gate role/aria-label when hidden, normalize blank description

CodeRabbit feedback on #1842:
- Avoid emitting role="img" and aria-label alongside aria-hidden="true"
  (dead markup; AT ignores semantics on hidden subtrees).
- Normalize blank description strings to nil via .presence so the
  default aria_hidden fallback treats "" the same as nil.

* fix(review): use description.presence so aria-label drops blank strings

Codex follow-up review 4319747515 caught that the prior fix still
emitted `aria-label=""` when description was a blank string.
`.presence` returns nil for blank — Rails `tag.div` drops the
attribute entirely when the value is nil.
2026-05-20 18:08:58 +02:00
Guillem Arias Fauste
e56ad3de42 fix(design-system): DS::Toggle focus ring, role=switch, and semantic tokens (#1843)
* fix(design-system): DS::Toggle a11y + token swaps

Closes #1746. Four fixes on the toggle primitive (visual switch
backed by a sr-only checkbox).

1. **Focus ring (WCAG 2.4.7)** — the `<input>` is `sr-only`, so the
   browser-default focus ring lands on an invisible 0px element.
   The label (the track) had no focus styling, meaning the
   component had **no visible focus indicator at all**. Add
   `peer-focus-visible:ring-2 ring-offset-2 ring-gray-900` with
   `theme-dark:peer-focus-visible:ring-white` so the ring appears
   on the visible track when the underlying checkbox receives
   keyboard focus.

2. **Role semantics** — visual is a switch, but the element was
   announced as "checkbox, checked" because the native input is
   a checkbox. Add `role="switch"` so AT users hear "switch, on"
   / "switch, off". `aria-checked` is inherited from the
   checkbox's checked state, no manual wiring needed.

3. **Token swaps** — replace raw palette references with semantic
   tokens:
   - Track `bg-gray-100 theme-dark:bg-gray-700` → `bg-surface-inset`
   - Checked `peer-checked:bg-green-600` → `peer-checked:bg-success`
   Picks up the contrast bump from #1735 automatically.

4. **Motion safety (WCAG 2.3.3)** — gate the bg color +
   thumb-translate transitions behind `motion-safe:`. Reduced-motion
   users see an instant state snap; everyone else gets the
   existing 300ms ease.

API unchanged. Existing 8 callsites (settings/preferences,
settings/appearances, account_sharings, budgets/edit,
recurring_transactions, styled_form_builder bridge) work without
changes.

* fix(review): use alpha tokens for Toggle focus ring

Swap raw palette (ring-gray-900 / theme-dark:ring-white) on the
DS::Toggle focus ring to ring-alpha-black-300 / ring-alpha-white-300
to match the focus-ring token pattern already used by
form-field, provider_card, and shared/_badge.

Closes AI review feedback on #1843.
2026-05-20 18:08:46 +02:00
Guillem Arias Fauste
04ba4dd28f fix(design-system): bump --color-success for WCAG 1.4.11 contrast (#1838)
Light: green-600 (#10A861) -> green-700 (#078C52). Lifts the
success icon on `bg-success/10` from 2.77:1 to 3.77:1, clearing
the WCAG 1.4.11 3:1 minimum for non-text UI components.

Dark: green-500 (#12B76A) -> green-400 (#32D583), keeping the
warning/destructive 600-light/400-dark step pattern intact and
moving from 5.95 to 7.90.

Source change in design/tokens/sure.tokens.json; _generated.css
regenerated via `npm run tokens:build`.

Closes #1735. Resolves #1736 child #4.
2026-05-20 07:18:33 +02:00
Guillem Arias Fauste
e8ce28648d refactor: rename beta features gate to preview features (#1837)
* refactor: rename beta features gate to preview features

Renames the opt-in gate introduced in PR #1829 from "beta" to "preview".
Same shape (per-user JSONB toggle, `before_action` concern, marker pill)
just retitled so the surface speaks the language Sure uses elsewhere
("preview" reads as in-progress, "beta" had baggage with provider
maturity copy and external testing programs).

Renames:
- BetaGateable -> PreviewGateable
- require_beta_features! -> require_preview_features!
- beta_features_enabled? -> preview_features_enabled?
- preferences["beta_features_enabled"] -> preferences["preview_features_enabled"]
- DS::Pill default label "Beta" -> "Preview"
- Settings -> Preferences toggle copy "beta features" -> "preview features"
- config/locales/views/beta/ -> config/locales/views/preview/
- docs/llm-guides/gating-a-beta-feature.md -> gating-a-preview-feature.md

Includes a data migration that copies any existing
`beta_features_enabled` JSONB key into `preview_features_enabled` so early
opt-ins survive the rename, then removes the old key. The migration is
fully reversible.

Provider maturity copy ("maturity.beta = Beta" under Settings -> Bank
sync) is intentionally untouched - that's a separate concept describing
a provider's integration stability, not Sure's feature gate.

* review: apply CodeRabbit findings on PR #1837

- Settings::PreferencesController#update now routes the
  `preview_features_enabled` input through strong params and casts via
  ActiveModel::Type::Boolean instead of reading raw params and string-
  comparing to "1". Matches Sure's controller convention for permitted
  params and avoids stringly-typed boolean handling.

- Rename migration now wraps the destination JSONB key write in COALESCE
  so a row that somehow ends up with both keys keeps the destination
  value instead of having it overwritten by the source. Up and down
  paths get the same defensive shape.

* 📝 CodeRabbit Chat: Implement requested code changes

* 📝 CodeRabbit Chat: Implement requested code changes

* fix: restore all missing translation keys; rename beta→preview label

* fix: restore all missing sections (appearances, debugs, llm_usages, providers, etc.); rename beta→preview

* fix: restore missing keys (member_removal_failed, confirm_delete, etc.); add preview section

* fix(i18n/ca): use 'està en vista prèvia' instead of 'és una vista prèvia'

* fix(i18n/ca): use 'en desenvolupament'; drop article in preview title

* fix(i18n/es): use 'en desarrollo' instead of 'en progreso'

* fix(i18n/ca): use 'funcions experimentals' instead of 'vista prèvia'

* fix(i18n/es): use 'funciones experimentales' instead of 'vista previa'

* fix(i18n/ca): use 'funcions experimentals' in preferences.show.preview

* fix(i18n/es): use 'funciones experimentales' in preferences.show.preview

* fix(i18n/ca): use 'Experimental' pill label instead of 'Vista prèvia'

* fix(i18n/es): use 'Experimental' pill label instead of 'Vista previa'

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-19 14:41:02 +02:00
Guillem Arias Fauste
65bed0db2f i18n(en): hardcode 'One error' in sso_providers errors_title singular form (#1854)
* i18n(en): drop %{count} interpolation in sso_providers errors_title 'one' branch

Per @jjmata review feedback on #1830: when count == 1, English reads
more naturally as 'One error prohibited...' than '1 error prohibited...'.
The 'other' branch keeps %{count} so '2 errors...', '3 errors...' still
interpolate.

* Update en.yml

Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>

---------

Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
2026-05-19 14:31:10 +02:00
Guillem Arias Fauste
1ddd8bd040 feat(i18n): complete Catalan translations + extract residual hardcoded strings (#1836)
* feat(i18n): complete Catalan translations + extract residual hardcoded strings

CA coverage
- All view/model/breadcrumb/doorkeeper/mailer locale files for ca: 0 missing
  keys (was ~3,400). Translations follow informal "tu" register, sentence case,
  domain glossary (Compte/Saldo/Transacció/Posició/Operació/Pressupost/...).
- Catalan pluralization test: ca uses one/other; mirrors
  test/lib/polish_pluralization_test.rb.
- 8 LanguageTool-flagged grammar fixes applied (Connexió òrfena, Secret de
  l'API, comma-pero, apostrophe elisions, etc).

Hardcoded string extraction (also fixes EN parity)
- UI::Account::Chart#title + chart.html.erb view tabs -> UI.account.chart.*
- UI::Account::BalanceReconciliation labels + tooltips ->
  UI.account.balance_reconciliation.{labels,tooltips}.*
- transactions/_transfer_match.html.erb (Auto-matched, A/M, Confirm/Reject
  match, Payment/Transfer is confirmed) -> transactions.transfer_match.*
- AccountOrder labels (Name/Balance asc/desc) -> account_order.* keys with
  fallback to existing hardcoded labels.
- Depository::SUBTYPES surface in account list -> depositories.subtypes.*.*
- User role badge -> users.roles.* (admin / member / super_admin).
- 110+ country names -> countries.* (config/locales/countries.ca.yml).

Breadcrumb locale fix
- Breadcrumbable was a before_action that ran before Localize's around_action
  switched I18n.locale, so default crumbs rendered in EN even when locale=ca.
- Convert to helper_method that defers translation to render-time (when
  I18n.locale is already correct). Add all missing breadcrumb keys to ca + en.
- Layouts switched from @breadcrumbs to breadcrumbs helper.

Locale-aware helpers / formatters
- ApplicationHelper#localized_ordinal: ordinalize that respects ca
  (1r/2n/3r/4t/Nè). Wired into preferences month_start_day select.
- Family#moniker_label / moniker_label_plural: translate the default "Family"/
  "Group" monikers via shared.family_moniker.* with fallback to the family's
  custom override.
- Budget#name: use I18n.l for month_year/short/long instead of strftime("%B %Y")
  so the budget header date follows the active locale.

Tooling
- script/lt_check_ca.rb: batched LanguageTool checker (premium endpoint when
  LT_USERNAME/LT_API_KEY are set, free fallback otherwise), picky mode,
  motherTongue=en for false-friend detection.
- lib/tasks/i18n_screenshot.rake: dev-only rake to set user.locale=ca and
  role=super_admin on the demo user so the i18n surfaces can be walked.

Out of scope (pre-existing, not introduced here)
- Native browser file input "Choose Files / No file chosen" (browser locale).
- D3.js client-side chart x-axis dates (JS-side Intl.DateTimeFormat needed).
- Sankey/donut labels = seed category names (data, not i18n).
- 2 rails-i18n datetime/errors interpolation warnings inherited from
  config/locales/defaults/ca.yml.

* fix(i18n): apply idiomatic Catalan review (3-agent + native review)

Three parallel review agents flagged 203 findings (31 high / 73 medium / 99 low)
across all 111 ca.yml files. This commit applies the high-severity bugs plus a
curated subset of medium-impact fixes.

Grammar / agreement
- provider_sync_summary.health.stale_pending: `(exclòs)` -> `(exclosa/excloses)`
  to agree with feminine `transacció(s)`.
- accounts.confirm_unlink.warning_no_sync: added reflexive `es` -
  `el compte ja no es sincronitzarà`.
- sophtron_setup_required.heading: `no configurats` -> `sense configurar`
  (avoids broken agreement across "ID" masc. + "clau" fem.).
- admin.sso_providers.form.errors_title: split into one/other pluralization
  keys (en + ca); singular `ha impedit` was wrong for count > 1.

Brand consistency
- IndexaCapital -> Indexa Capital (37 occurrences across one file).
- Lunchflow -> Lunch Flow in two remaining places.

Anglicisms / domain mistranslations
- kraken_items setup_accounts.instructions: `ompliments d'operacions`
  (lit. dental/food fillings) -> `execucions d'operacions`.
- settings kraken_panel.read_only_title: `Sincronització d'intercanvi`
  (swap/trade) -> `Sincronització només de lectura amb l'exchange`.
- transactions convert_to_trade.security_custom + security_not_listed_hint:
  `cotització` (price quote) -> `ticker` (the EN field IS a ticker symbol).
- loans.form.rate_type: `Tipus d'interès` collided with sibling
  interest_rate -> `Modalitat del tipus`.
- brex_items.provider_panel.sandbox_note_html: `L'staging` (broken
  contraction) -> `el staging`.

Idiom traps
- coinbase/binance/kraken wait_for_sync: `acabi de sincronitzar` is
  ambiguous in CA (`acabar de + inf` reads as "has just done X") ->
  `acabi la sincronització`.
- chats.ai_greeting.there: `a tothom` -> `''` (the EN fallback "Hey there"
  is singular; literal CA `tothom` is plural and wrong for 1:1 chat).
- transactions.split_parent_row.split_label: `Divideix` (imperative) is
  wrong as a status badge -> `Divisió` (noun).
- transactions.keep_both (2 occurrences): infinitive `mantenir ambdues` ->
  imperative `mantén-les totes dues` to match the sibling Yes/No buttons.
- rules.clear_ai_cache: `Reinicia` (restart) -> `Buida` (empty/clear),
  which matches the success notice (`s'està netejant`).

Moniker gender breakage (cross-file)
%{moniker} is interpolated downcased from family.moniker_label and may
resolve to feminine `família`/`llar` or masculine `grup`. Strings that
hard-code a gendered article ('al teu %{moniker}', 'aquesta %{moniker}',
'aquest/a %{moniker}') broke on at least one branch. Restructured the
affected sentences to drop the gendered determiner:

- account_sharings.show.no_members
- merchants.family_empty / family_title / provider_empty
- registrations.new.join_family_title
- settings.preferences.show.currencies_subtitle / sharing_subtitle
- simplefin_items.select_existing_account.no_accounts_found
- invitations.new.subtitle
- invitation_mailer.invite_email.subject (mailers/) + body (views/)
- snaptrade_items.providers.snaptrade.free_tier_warning

Terminology consistency
- models/account_statement/ca.yml attributes aligned with view-side
  forms: `Saldo d'obertura`/`Saldo de tancament` ->
  `Saldo inicial`/`Saldo final`; `Suggeriment de...` -> `Pista de...`.
- account_statements.coverage.status.not_expected:
  `No s'esperava` -> `No previst` (status label, not past action).
- account_statements.index.empty_unmatched: aligned with the section's
  own label `Safata sense aparellar`.
- imports.create.document_provider_not_configured + document_upload_failed:
  `arxiu vectorial` -> `magatzem vectorial` (correct TermCat term).
- coinstats_items blockchain gender: `els blockchains` / `un blockchain` ->
  `les blockchains` / `una blockchain` (feminine per TermCat).
- accounts.account.remove_default: `Treu el predeterminat` ->
  `Treu com a predeterminat` (pairs with sibling `Estableix com a
  predeterminat`).
- accounts.tax_treatments.tax_deferred: `Diferit fiscalment` (lit. calque)
  -> `Tributació diferida` (standard CA tax-accounting term).
- settings.payments.show.currently_on_plan: `Actualment al` ->
  `Actualment al pla:` (was a fragment).

Out of scope (review flagged, not applied here)
- LOW-severity stylistic preferences (Veure vs Mostra, etc).
- `models/category/ca.yml` default category names — seeded at family
  creation, not via I18n at runtime, so changes wouldn't affect existing
  families.
- `models/period/ca.yml` short labels mixing EN (MTD/YTD) and CA (STD/MA)
  — needs a one-convention decision separately.

* fix(i18n,ca): drop gendered article in period_activity + tighten cash-flow terms

- pages.dashboard.investment_summary.period_activity: 'Activitat del
  %{period}' contracted 'del' = 'de el' (masc.sg.). %{period} resolves
  to mixed forms ('Setmana en curs' fem, 'Últims 30 dies' pl., 'Any en
  curs' apostrophe), so hard-coded 'del' was wrong on most labels.
  Replaced with 'Activitat — %{period}' (em-dash) to skip the
  contraction entirely.
- pages.dashboard.outflows_donut.title / total_outflows: switched from
  bare 'Sortides' / 'Total de sortides' to 'Sortides de caixa' /
  'Total de sortides de caixa' to match TermCat's precise term
  ('sortida de caixa' = cash outflow).

* fix(i18n,ca): rephrase transfer source/destination amount labels

'Import d'origen' / 'Import de destinació' were literal calques of
'Source amount' / 'Destination amount'. In a multi-currency transfer
form (sender/receiver in different currencies) the natural CA pair is
'Import enviat' / 'Import rebut'.

* fix(i18n,ca): 'Dades en brut' -> 'Dades sense processar'

The literal calque of 'Raw data' read as too technical for personal-
finance UI. 'Dades sense processar' is the more natural Catalan
equivalent for raw/unprocessed data files.

* fix(i18n): localize Import col_sep label + separator options

The CSV upload form rendered 'Col sep' (the auto-humanized attribute
name) plus hardcoded English 'Comma (,)' / 'Semicolon (;)' options
from Import::SEPARATORS.

- activerecord.attributes.import.col_sep added (en + ca: 'Column
  separator' / 'Separador de columnes').
- Import.separator_options class method returns translated tuples;
  view switched from Import::SEPARATORS to Import.separator_options.
- activerecord.attributes.import.col_seps.{comma,semicolon} added so
  the option labels follow the active locale.

* fix(i18n,ca): drop moniker apposition in sharing/currencies section titles

- sharing_title 'Compartició de %{moniker}' rendered as 'Compartició
  de Família' (a noun-noun apposition that's odd in CA) -> 'Compartició
  de comptes'.
- sharing_subtitle replaced '%{moniker}' with 'entre els membres' so
  the sentence reads naturally and doesn't depend on moniker gender.
- currencies_title 'Divises de %{moniker}' had the same apposition
  -> 'Divises'. Subtitle no longer references moniker either.

* fix(i18n,ca): keep 'Self Hosting' untranslated

Reverted 'Autoallotjament' / 'autoallotjada' / 'autoallotjats' usages
to the original English 'Self Hosting' (sidebar label, breadcrumbs,
hostings page title, chat assistant settings hint, redis configuration
subheading, LLM usages cost-estimates description).

The brand-style term reads more naturally in EN for technical users
configuring their own deployment.

* fix(i18n,ca): lowercase 'self hosting' (sentence case in labels)

* fix(i18n): extract budget_categories stepper + allocation_progress strings

Hardcoded English strings on the budget category editor:
- 'Setup' / 'Categories' stepper labels in budgets/_budget_nav.html.erb
- 'X% set' / '> 100% set' / 'left to allocate' / 'Budget exceeded by ...'
  in budget_categories/_allocation_progress.erb
- '/m avg' caption + 'Shared' placeholder + 'Leave empty to share
  parent's budget' tooltip in budget_categories/_budget_category_form
  and _uncategorized_budget_category_form

Extracted to:
- budgets.budget_nav.{setup,categories}
- budget_categories.allocation_progress.{percent_set,over_set,left_to_allocate,budget_exceeded_html}
- budget_categories.budget_category_form.{monthly_average,shared_placeholder,shared_title}

CA translations added; EN keys mirror the prior literals.

* chore(i18n): drop translation tooling from PR

These were dev-only helpers used during the Catalan translation pass:

- script/lt_check_ca.rb: LanguageTool API checker (premium/free
  endpoint, picky mode, batching). Useful for ongoing locale QA but
  shouldn't ship in this feature PR.
- lib/tasks/i18n_screenshot.rake: rake task that flips user.locale and
  role on the demo user for walking the i18n surfaces locally.

Both stay available locally; pulled out of the PR scope.

* fix(i18n): apply PR review feedback (CodeRabbit + Codex)

- balance_reconciliation crypto_items: use :end_balance_crypto tooltip
  (was :end_balance_investment). Added new UI.account.balance_reconciliation.tooltips.end_balance_crypto key in en + ca.
- doorkeeper.ca.yml confidentiality.no: was YAML boolean false, now string 'No'.
- views/categories: 'Poor contrast, choose darker color or' continued with hardcoded 'auto-adjust.' button text; extracted to categories.form.auto_adjust key (en + ca).
- imports.create.document_upload_failed: 'a l'magatzem' was broken
  contraction -> 'al magatzem'.
- invitation_mailer body + mailer subject: 'unir-se' -> 'unir-te' (was
  3rd person, should be 2nd to match the rest of the copy).
- 7 strings across mercury_items / sophtron_items / simplefin_items /
  lunchflow_items / brex_items / indexa_capital_items / other_assets:
  'se sincronitzaran' -> 'es sincronitzaran', 'se segueixen' ->
  'es segueixen' (correct reflexive pronoun before consonants).
- settings.providers.status: key was 'false' (YAML-coerced), now 'off'
  to match settings/en.yml status.off used in view lookups.
- sophtron_items.sophtron_setup_required.message: stripped trailing
  blank line from the quoted scalar.
- settings/profiles/show.html.erb: switched 'family_moniker ==
  "Group"' branch checks to 'Current.family&.moniker == "Group"'.
  After Family#moniker_label started returning translated values,
  callers using the display label for branching would render the
  household copy for group families in ca. Compare the stored sentinel
  instead.
- Did not apply CodeRabbit's webauthn 'eliminada' -> 'desada' suggestion:
  the key is wired to the destroy action (verified at
  settings/webauthn_credentials_controller.rb:55), so 'eliminada' is
  correct.
2026-05-19 13:37:10 +02:00
Guillem Arias
91b9d368e4 fix(goals): card hover uses bg-container-hover (gray-50) instead of shadow lift
Mirror the affordance used in the accounts/select_provider screen and
the bank-sync flows: a near-imperceptible gray-50 fill swap rather
than a shadow. Lighter than the previous shadow lift, doesn't introduce
elevation noise inside the grid, and the ring track (gray-200) + status
pill outline still keep enough separation against the gray-50 hover bg
that we don't reintroduce the original contrast issue.
2026-05-19 11:50:41 +02:00
Guillem Arias
aa67f84c21 fix(goals/demo): seed goals in generate_new_user_data_for!
Cloudflare preview entrypoint, FamilyResetJob, and the Settings
"Reset + load sample data" flow all go through generate_new_user_data_for!,
which seeded categories/accounts/transactions/budget but not goals. Move
generate_goals! inside this method (alongside the same call already in
generate_default_data!) so every sample-data surface gets the full
state-coverage matrix.
2026-05-18 22:31:18 +02:00
Guillem Arias
78f97320de feat(goals): footer 'N pending' on cards + drop warning tone from top callout
- Pending pledges surface in the card footer as '· N pending' tacked
  on after the existing footer line (text-subdued). Quiet, semantic,
  doesn't compete with the status pill or the avatar.
- The top-of-page 'You have pending pledges' callout was using the
  amber DS::Alert warning variant. Pending isn't a warning — it's a
  passive 'we're waiting on a sync' state. Switch to the info
  variant so the visual weight matches the meaning.
2026-05-18 22:29:20 +02:00
Guillem Arias
66680877f4 fix(goals): swap pending-pledge avatar dot for inline clock glyph
The amber DS::Pill dot on the avatar collided visually with the
amber 'Behind' status pill on the same card — two amber signals in
the same eye-line. Move the indicator to a text-subdued clock icon
between the goal name and the status pill: quieter, semantically
clearer (clock = pending sync / time-bound), no tone collision with
the status palette. Same accessibility hook (aria-label + title).
2026-05-18 22:26:38 +02:00
Guillem Arias
25eb18d9af feat(goals): pending-pledge dot on card avatar
Goals with open + unexpired pledges now carry a small amber DS::Pill
dot at the top-right of the avatar on the index card. Same primitive
+ position pattern as the beta gate dot on the sidebar nav, so the
'small marker' affordance reads consistently across the app.

Pledges are preloaded via the existing .includes(:open_pledges, ...) on
the index query, so the indicator is free at request time.
2026-05-18 22:19:44 +02:00
Guillem Arias
82e3ba8ef7 fix(goals): keep archived cards out of the filter loop entirely
The earlier 'filter archived too' attempts kept toggling the archived
section based on chip state, which produced more confusion than value
(filter shows partial counts, archived hides on some chips, etc.).
Step back: archived stays in its own collapsed-by-default section,
always visible, never reacts to the chip / search filter. Render
the cards with filterable: false so they don't add a filter target
in the first place — no JS handling needed, and the active grid +
chips behave exactly like they did before this whole thread.
2026-05-18 22:16:38 +02:00
Guillem Arias
746d733599 Merge branch 'feat/goals-v2-architecture' of https://github.com/we-promise/sure into feat/goals-v2-architecture 2026-05-18 22:04:08 +02:00
Guillem Arias
adbef877a3 fix(goals): drop no-target-date goals from the on-track denominator
The KPI tile reads 'X of Y on track'. Y was every active goal minus
reached + paused, which included open-ended goals (no target_date).
But an open-ended goal has no required monthly pace to compare
against — by definition it can be neither on track nor behind. Counting
it in the denominator dragged the ratio down and never improved as the
user kept saving (the fraction stays stuck because the open-ended goal
is never a hit).

Exclude :no_target_date from tracked_total. Numerator unchanged. The
subline still surfaces 'N without a deadline' as informational so the
user knows those goals exist.
2026-05-18 22:04:00 +02:00
Guillem Arias Fauste
a3db348ce6 Merge branch 'main' into feat/goals-v2-architecture 2026-05-18 21:54:47 +02:00
Guillem Arias
dcb6f391e5 fix(goals): drop hover bg on card, use subtle shadow lift instead
The card was the only place in Sure setting hover:bg-surface-hover on
a bg-container card; every other static card (settings, recurring
transactions, ai_prompts, etc.) stays still. The hover bg (gray-100)
landed almost exactly on top of the ring track (gray-200) and washed
out the pill fill, fighting the very signals the card was trying to
show. Swap to hover:shadow-sm — the lift matches the transaction-tabs
precedent already in Sure, the bg stays white, and ring track + status
pill keep their contrast.
2026-05-18 21:53:09 +02:00
Guillem Arias
f0c9490c09 fix(goals): goal status pill uses DS::Pill outline (consolidate + survive hover bg)
The status pill on the goal card used a 10%-alpha fill (bg-warning/10,
bg-green-500/10). On the card's hover state (bg-surface-hover) the fill
blended into the new background and the pill lost its tint outline.

Extend DS::Pill with a green tone and an optional icon: param (renders
a Lucide icon in place of the dot) so the same primitive can carry both
the beta marker and the goal status badges. Map Goals::StatusPillComponent
to DS::Pill outline style — transparent fill + colored border + colored
text + glyph — which is immune to any change in the surrounding card bg.

One badge primitive, light-mode contrast already fixed (the color-mix
30% darkening on text), and the card hover state no longer washes out
the status.
2026-05-18 21:48:40 +02:00
Juan José Mata
bc9f13059a Bump version by hand 2026-05-18 21:46:28 +02:00
Guillem Arias
3bf36efc66 revert(goals): move .goal-avatar back into application.css
Sure's convention for one-off custom classes (.combobox, .scrollbar,
.windows, .prose variants, .table-divider, .turbo-progress-bar) is to
define them directly in application.css. Topical files (privacy-mode,
date-picker-dark-mode, print-report) are reserved for coherent feature
namespaces with multiple rules. A single .goal-avatar rule belongs
with the other custom classes.
2026-05-18 21:41:25 +02:00
Guillem Arias
612af6c14b fix(goals): apply CodeRabbit findings
- Switch the goal_accounts → accounts FK from on_delete: :cascade to
  :restrict. `Goal#must_have_at_least_one_linked_account` is enforced
  at write time; the cascade let a raw DELETE silently orphan a Goal
  whose only link pointed at the deleted account. Normal Rails
  Account#destroy still cleans up via `dependent: :destroy`, but the
  restrict guarantees the DB rejects any path that bypasses the
  association.
- projection_payload: required_monthly is now monthly_target_amount&.to_f
  so open-ended (no-target-date) goals serialize required_monthly: null
  instead of 0, matching the absence of a required pace.
- index page + sidebar nav-rail dot now read the Beta label via
  t("shared.beta") (and a new shared.beta locale key) instead of the
  hardcoded "Beta" literal.
- _status_callout uses the view-helper t(...) instead of I18n.t(...)
  for the status label so it follows the same convention as the rest
  of the goals views.
- goal_projection_chart: read the computed style before stamping
  position: relative so a stylesheet-defined position (fixed/sticky/
  absolute) isn't clobbered.
- preview-deploy: add `set -euo pipefail` around the wrangler
  container lookup so a curl/jq failure fails the job instead of
  producing an empty CONTAINER_ID and silently skipping cleanup.
2026-05-18 21:33:09 +02:00
Guillem Arias
3da89b30d3 Merge branch 'feat/goals-v2-architecture' of https://github.com/we-promise/sure into feat/goals-v2-architecture 2026-05-18 21:23:19 +02:00
Guillem Arias
f672aae3cf perf + tests(goals): share account-ids across velocity windows + cover gaps
- Family#savings_inflow_windows wraps the current/prior 30d sums in a
  single helper that memoizes the linked-account-id lookup. The KPI tile
  on the goals index used to run the join+pluck twice per request.
- Replace two instance_variable_set pokes and one any_instance.stubs in
  the goal/controller tests. Refetching the goal exercises the real
  request lifecycle and stops the tests from leaning on implementation
  details. The 'All caught up' assertion now relies on a real reached
  state (target 1 vs the depository fixture's 5000 balance) rather than
  stubbing :status.
- Add tests covering: hex format validation on Goal#color, AASM cache
  reset (display_status reads the new state on the same instance after
  pause!), negative pledge amount rejection, expire! no-op on already-
  expired pledge, cancel! NotOpenError on non-open pledge, sweep job
  idempotency on a second pass, and strong-params rejection of state /
  family_id on goal create.
2026-05-18 21:11:30 +02:00
Guillem Arias
fbcd13c44d fix(a11y): focus trap + returnFocus on DS::Dialog
Native <dialog>.showModal() moves focus inside the dialog on open but
doesn't trap Tab / Shift+Tab, and focus restoration on close is
inconsistent across engines. Add three things to the dialog controller:

- Capture document.activeElement before showModal() so the trigger is
  recoverable when the dialog closes (ESC, backdrop click, explicit
  close button, programmatic close all route through the native close
  event).
- Wrap Tab inside the dialog so a keyboard user can't tab out into the
  scrim-covered page behind.
- Restore focus to the captured trigger on the close event. If the
  trigger has been detached (Turbo morphed it out), skip silently
  rather than throw.

Verified manually: opening the new-goal modal moves focus to the name
input; ESC restores focus to the "New goal" link; Tab wraps from the
last focusable back to the first.
2026-05-18 21:06:47 +02:00
Guillem Arias
d51c88d323 fix(goals JS): listener cleanup on color/icon picker + debounce filter URL sync
- color_icon_picker: listeners were attached in initialize() with inline
  arrows, so they had no removable reference and no disconnect() cleanup.
  Every Turbo navigation that re-rendered the picker stacked another
  listener on the same node and left the Pickr instance alive. Move
  attachment to connect(), store bound references, and clean up in
  disconnect() (including destroyAndRemove on the Pickr).
- goals_filter: replaceState fired on every keystroke. Debounce the URL
  sync 200 ms so a typing burst collapses into a single update. The
  visible filtering stays real-time. Clear the timer in disconnect()
  so a pending sync doesn't fire on an unmounted controller.
2026-05-18 21:03:55 +02:00
CrossDrain
6262b0a493 fix(ibkr): correct historical cash/non-cash split for linked accounts (#1813)
* fix(ibkr): resolve weekend balance oscillations and improve data processing

Address issues where IBKR weekend/holiday data caused incorrect balance
calculations and improve the robustness of IBKR account processing.

- Fix historical balance oscillations by ignoring anomalous weekend rows
  and filling gaps by carrying forward the last known trading day value.
- Normalize report dates to the last trading day to ensure consistency.
- Improve `HoldingsProcessor` to skip individual bad lots instead of
  failing the entire group.
- Refactor `ActivitiesProcessor` to accumulate fee counts locally via
  return values instead of using instance variables.
- Add support for accounting parentheses notation in `DataHelpers`.
- Memoize the account object in `IbkrAccount::Processor` to reduce
  database queries.
- Update tests to reflect date normalization and improved precision
  assertions.

* fix(ibkr): derive historical cash from materializer balances, not equity summary

Real IBKR Flex exports do not include a reliable cash/stock breakdown in
EquitySummaryByReportDateInBase — only the total is consistently present.
The previous implementation parsed the missing cash field as zero and wrote
cash_balance=0 for every historical date, causing negative and wildly
incorrect cash values throughout the account history.

Instead, read the materializer's already-computed cash_balance for each date
(derived from holdings via the reverse calculator) and use only IBKR's total
as an authoritative balance anchor. This is consistent with how present-day
balances are handled and requires no weekend/holiday filtering since IBKR does
not emit weekend rows and holiday totals are legitimate data points.

Also accept equity summary rows without an explicit currency field (some Flex
configurations omit it) and explicitly reject BASE_SUMMARY aggregate rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: simplify boolean coercion in import_commission_transaction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ibkr): cover trailing weekend gap and align qty with valid lots

HistoricalBalancesSync: extend fill_gaps to account.current_anchor_date
so days after the last equity summary row (e.g. Saturday/Sunday when a
sync runs over the weekend) are also overridden rather than left with the
materializer's stale total=cash value.

HoldingsProcessor: replace separate quantity sum + weighted_cost_basis_for
with a single valid_lots method that computes both from the same set of
parseable lots. Previously a lot with a valid position but unparseable
cost_basis_price was excluded from the cost basis calculation but still
counted in quantity, producing inconsistent qty/cost_basis values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* review: address PR feedback on ibkr fix branch

- Remove all "Fix N:" review-artifact comment labels
- Add Sentry.capture_message for silenced anchor repair failure so it surfaces in production monitoring
- Add Rails.logger.warn for zero/nil total rows skipped in HistoricalBalancesSync
- Document normalize_to_last_trading_day holiday limitation and why gap-fill covers it
- Rewrite two non-obvious comments to stand alone without the label prefix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: remove alignment padding in balance_rows hash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ibkr): address two P1 review findings

- Allow zero and negative equity summary totals through HistoricalBalancesSync
  so fully-liquidated and margin accounts are not silently skipped (which would
  cause fill_gaps to propagate a stale non-zero total forward).
- Remove normalize_to_last_trading_day from HoldingsProcessor: shifting weekend
  report_dates to Friday caused Balance::SyncCache#get_holdings_value to find
  no holdings on Saturday/Sunday (exact-date lookup), collapsing non_cash to
  zero — reintroducing the very oscillation the fix was meant to prevent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(ibkr): add tests for historical balances sync and data helpers

- Add test case to verify non-cash balance calculation in historical balances sync
- Add test case to ensure rows with unparseable or nil totals are skipped
- Add new test file for IBKR data helpers

* fix(ibkr): prevent date range overflow during historical sync

Adjust the calculation of `last_date` in `HistoricalBalancesSync` to
ensure it does not exceed the current anchor date or today's date.
This prevents the sync process from attempting to fetch or process
future dates, which was causing oscillations in weekend data.

Also remove the conditional check for Sentry before capturing
error messages in the account processor.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
v0.7.1-alpha.9
2026-05-18 21:03:04 +02:00
Guillem Arias
79c81377ac i18n + a11y(goals): extract picker strings + drop redundant status-pill aria-label
- Color picker had four hardcoded English strings ("Color", "Icon",
  "Poor contrast, choose darker color or", "auto-adjust."). Move them
  under `goals.color_picker.*` and call them through `t()`. CLAUDE.md
  requires every user-facing string go through i18n.
- Status pill duplicated its visible label in `aria-label`, which makes
  screen readers ignore the visible text. Drop the override so the
  visible label is the accessible name.
2026-05-18 21:02:05 +02:00