Commit Graph

1089 Commits

Author SHA1 Message Date
Guillem Arias
db80ad4deb Merge origin/main into feat/goals-v2-architecture
Pulls in #1857 (DS::Disclosure :card_inset), #1858 (:inline variant),
#1902 (DS::Pill marker:false + semantic tones + :red palette), #1903
(settings/debugs token fix), plus #1878 (entry.date guard) and other
minor fixes that landed.

Resolves one conflict in app/components/DS/pill.rb: takes main's new
extended API (marker: flag, SEMANTIC_TONE_ALIASES, :red tone, updated
docstring) and preserves the goals-branch color-mix(...30% black)
text treatment that was added for light-mode contrast. Applies the
same color-mix to the new :red tone for consistency.
2026-05-22 08:53:10 +02:00
sentry[bot]
ced133d06e fix(views): guard against nil entry.date in partials (#1878)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-05-22 02:33:28 +02:00
Guillem Arias Fauste
548c4d1a3f fix(settings/debugs): replace 2 raw palette tokens flagged by DS drift scan (#1903)
`app/views/settings/debugs/show.html.erb` had two non-functional Tailwind
classes flagged by sure-design's weekly merged-commit scan (#1895, #1898):

- `bg-surface-default` → `bg-surface`. `bg-surface-default` doesn't map
  to any DS color variable (`--color-surface-default` isn't defined);
  `--color-surface` is the canonical token, auto-generates `bg-surface`.
- `divide-gray-100` → `divide-alpha-black-200 theme-dark:divide-alpha-white-200`.
  Matches the existing pattern used by `admin/sso_providers/index.html.erb`,
  `admin/users/index.html.erb`, and `settings/preferences/show.html.erb`
  for tbody dividers. No `divide-primary` utility exists yet, so the
  bot's suggestion gets the same effect via the alpha tokens.

The third drift finding on this file — the in-cell `<details>`
metadata expander — is deferred until #1858's `DS::Disclosure :inline`
variant lands on `main`. The `:default` variant renders a
`bg-surface px-3 py-2 rounded-xl` card chrome that's wrong for an
in-table-cell trigger; the `:inline` variant in #1858 is the right
shape and will get a follow-up PR once that lands.

Closes #1895 partially. Closes #1898 partially. Both bot issues stay
open until the `<details>` migration also lands.
2026-05-22 02:17:32 +02:00
Guillem Arias Fauste
8de14ed2a5 feat(design-system): DS::Disclosure :inline variant + migrate indexa_capital + snaptrade panels (#1715 §6) (#1858)
* feat(design-system): add :inline variant + migrate indexa_capital + snaptrade panels

Adds an `:inline` variant to `DS::Disclosure` for plain text-link-style
toggles that have no surface, no padding, no shadow — the disclosure
reads as a clickable summary text + revealed content, nothing more.

Use case: "Alternative auth" form section toggle in the Indexa Capital
provider panel; "Manage connections" lazy-loaded toggle in the
Snaptrade provider panel. Both were the last raw-`<details>` callsites
in `app/views/settings/providers/`.

Migrations:

- `_indexa_capital_panel.html.erb` — single inline `<details>` revealing
  username / document / password form fields under an "Alternative auth"
  summary text.
- `_snaptrade_panel.html.erb` — lazy-load `<details>` with
  `data-controller="lazy-load"` etc. The new `tag.details ... **opts`
  forwarding from #1857 lets the Stimulus controller attrs flow
  through cleanly via DS::Disclosure's `data:` keyword.

Chevron rotation on snaptrade gets the standard
`motion-safe:transition-transform motion-safe:duration-150` treatment
(was `transition-transform` without the motion-safe gate).

Variant summary now:

| Variant | Details surface | Use case |
|---|---|---|
| `:default` | none / bg-surface summary | inline expander inside parent card |
| `:card` | `bg-container shadow-border-xs rounded-xl p-4` | provider rows, settings sections |
| `:card_inset` | `bg-surface-inset rounded-xl p-4` | inset sub-panels |
| `:inline` | no surface | text-link-style toggles |

* fix(review): guard variant.to_sym against nil in DS::Disclosure

CodeRabbit on #1858 flagged that `variant: nil` crashed with
`NoMethodError` at `variant.to_sym` before the explicit `VARIANTS`
check could run. Use safe navigation (`variant&.to_sym`) so nil
falls through to the validation, and inspect `@variant` in the
error message so nil / non-symbol inputs render readably.

Verified manually via runner: `DS::Disclosure.new(variant: nil)` now
raises `ArgumentError: Invalid variant: nil. Must be one of
[:default, :card, :card_inset, :inline]`.
2026-05-22 02:14:44 +02:00
Guillem Arias Fauste
834ec19fdc feat(design-system): DS::Disclosure :card_inset variant + migrate ibkr_panel + settings/_section (#1715 §6) (#1857)
* feat(design-system): add :card_inset variant + migrate ibkr_panel and settings/_section

Wraps up the disclosure migration cluster from #1715 §6:

1. **New `:card_inset` variant** on `DS::Disclosure`. Same contract
   as `:card` but uses `bg-surface-inset rounded-xl p-4` (no shadow)
   for inset sub-panels embedded inside a parent card surface.

2. **Migrate `_ibkr_panel.html.erb`** — the "flex query details"
   disclosure (`<details class="group bg-surface-inset rounded-xl p-4">`)
   was the one panel skipped from #1856 because it used the inset
   surface. Now uses `DS::Disclosure(variant: :card_inset)`. Chevron
   gets the `motion-safe:transition-transform motion-safe:duration-150`
   treatment along the way.

3. **Migrate `settings/_section.html.erb`** — the global "collapsible
   settings card" primitive backing 19 callsites via the
   `settings_section(...)` helper. The collapsible branch's
   `<details class="group bg-container shadow-border-xs rounded-xl p-4">`
   becomes `DS::Disclosure(variant: :card, open: open, data: ...)`.

While here:

- Update `disclosure.html.erb` to spread `**opts` onto the `<details>`
  element via `tag.details`. Previously opts were captured but never
  applied; the `settings/_section` migration needs `data-controller`
  + `data-auto-open-param-value` to flow through to the rendered
  `<details>`.
- Non-collapsible branch in `settings/_section.html.erb` stays as
  raw `<section>` — different semantics (not expandable), DS::Disclosure
  can't replace because it always renders `<details>`.

API:

  DS::Disclosure.new(
    variant: :card | :card_inset | :default,
    open: bool,
    data: { controller: "...", ... }   # forwarded to <details>
  )

* fix(review): merge caller class in DS::Disclosure + i18n plaid deletion

- DS::Disclosure: extract caller class: from opts and merge via class_names
  before forwarding to tag.details. Prevents the latent duplicate keyword
  arg error when callers pass class: alongside the variant-derived classes.
- plaid_items/_plaid_item: localize "(deletion in progress...)" via
  t('.deletion_in_progress') + add en locale key, matching lunchflow /
  mercury / sophtron / coinstats convention.

* fix(panels): replace text-white and bg-gray-tint-10 with semantic tokens

`text-white` → `text-inverse` on the EnableBanking reauthorize button
(`bg-warning` background); `bg-gray-tint-10` → `bg-container-inset` on
the IndexaCapital item avatar wrapper. Both flagged by sure-design as
non-functional palette tokens.

Pre-existing on main; surfaced by the re-indentation that this PR
applied during the disclosure migration.
2026-05-21 16:25:01 +02:00
Guillem Arias
8cf6a7a4ca refactor(goals): migrate index search field to DS::SearchInput
#1853 just landed on `main` (`8e444ff9`), so the goals index search
input can move off the hand-rolled markup and onto the new
`DS::SearchInput` primitive. Behaviour unchanged — the
`data-goals-filter-target="input"` and
`data-action="input->goals-filter#filter"` hooks pass through via the
component's `data:` option, and the wrapper's
`flex-1 min-w-[200px]` carries through via the component's
`class:` arg.

Drops the broken `focus:ring-gray-500` lookalike (the hand-rolled
class used `focus:ring-alpha-black-100` which is fine, but the new
primitive uses the canonical
`focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900`
pattern from DS::Button — same focus contract everywhere).

Addresses sure-design's DS Drift Patrol finding on #1798.
2026-05-21 16:22:49 +02:00
Guillem Arias
903121f66b Merge origin/main into feat/goals-v2-architecture
Pulls in #1853 (DS::SearchInput), #1855 (DS::Disclosure :card), #1856
(3 provider panels migration) so the goals views can migrate to the
new primitives.
2026-05-21 16:21:29 +02:00
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 Fauste
547dd21bf2 feat(design-system): migrate 3 provider panels to DS::Disclosure :card (#1715 §6) (#1856)
* feat(design-system): DS::Disclosure :card variant + migrate 14 provider items

Resolves part of #1715 §6. The provider-item view templates
(binance, brex, coinbase, coinstats, enable_banking, ibkr,
indexa_capital, kraken, lunchflow, mercury, plaid, simplefin,
snaptrade, sophtron — 14 in total) all hand-rolled the same
`<details open class="group bg-container p-4 shadow-border-xs
rounded-xl">` shell with a custom summary inside and content below.

Extend `DS::Disclosure` with a `:card` variant that bakes the card
chrome onto the `<details>` element itself; the summary becomes
slot-driven via the existing `summary_content` slot. Provider items
keep their custom summary content (logos, brand colors, status copy)
unchanged — they just hand it to the slot instead of writing it
between `<summary>` tags.

API:

  DS::Disclosure.new(variant: :card, open: true) do |d|
    d.with_summary_content do
      <div class="flex items-center gap-2">
        chevron + custom summary markup
      </div>
    end
    body content
  end

While here:

- Drop the no-op `group-open:transform` from the default chevron
  (Tailwind v4 applies `rotate-90` directly).
- Add `motion-safe:transition-transform motion-safe:duration-150`
  to chevron rotation for reduced-motion respect (matches the
  pattern landing in #1841).
- Extract `summary_classes` / `details_classes` helpers so the
  default and card surfaces stay readable side-by-side.

Note: this PR touches `DS::Disclosure` and will textually conflict
with #1841 (focus-ring + reduced-motion polish). Both changes are
compatible — when #1841 merges first, the resolution is just
preserving both edits (the focus-ring classes are already merged
into `summary_classes` here).

* feat(design-system): migrate 3 provider panels to DS::Disclosure :card variant

Resolves the panel slice of #1715 §6. Continuation of the
DS::Disclosure :card variant work — same migration pattern, applied
to the 3 provider-PANEL templates that share the card shape with the
provider-item templates landing on the parent branch.

Migrated `<details class="group bg-container p-4 shadow-border-xs
rounded-xl">` → `DS::Disclosure.new(variant: :card)` in:

- `app/views/settings/providers/_kraken_panel.html.erb` — 1 details
  in the items-each loop.
- `app/views/settings/providers/_mercury_panel.html.erb` — 1 details
  in the items-each loop.
- `app/views/settings/providers/_brex_panel.html.erb` — 2 details:
  one in the items-each loop, one standalone "add connection" panel
  that opened by default when no active items existed. The
  conditional `<%= "open" unless active_items.any? %>` becomes
  `open: active_items.none?` on the `:card` disclosure.

Panels do NOT show a chevron in their summary (different UX from
the per-item rows in #1855), so the migration preserves that — no
chevron inserted.

NOT migrated (intentionally — different shapes):

- `_ibkr_panel.html.erb` — `<details class="group bg-surface-inset
  rounded-xl p-4">`. Uses bg-surface-inset, not bg-container — needs
  a `:card-inset` variant we haven't built. Deferred.
- `_indexa_capital_panel.html.erb` — `<details class="group">` with
  no card chrome. Inline expander; doesn't fit either disclosure
  variant.
- `_snaptrade_panel.html.erb` — same inline pattern as indexa_capital.

* fix(review): use ring-alpha-black-300 focus token in DS::Disclosure

CodeRabbit P2: switch the focus-visible outline from raw
gray-900/white palette values to the alpha-black-300 ring token,
matching the established focus pattern on settings/provider_card.html.erb.
This keeps theme behavior centralized in the design system tokens
instead of branching on theme-dark: in the component.

Applies to both :default and :card summary variants.

* fix(review): stretch DS::Disclosure summary_content to full width

Codex P2 follow-up on the disclosure-migration stack: \`<summary>\` is
\`display: list-item\`, so a flex inner div inside the slot
shrink-wraps to content width — any \`justify-between\` the caller
adds has nothing to distribute, and the right-side admin actions
collapse toward the title across every provider-item partial migrated
to \`DS::Disclosure variant: :card\` in #1855 (and the panels in
#1856 / #1857 / #1858 that inherit this component).

Wrap the slot in \`<div class=\"w-full\">\` so caller-supplied flex
rows stretch across the card. \`:default\` variant is unchanged
(it never uses \`summary_content\`).

* fix(review): stretch :card summary flex row to full width

Codex P2 follow-up on #1856: the migrated kraken / mercury / brex
panel summary rows wrap their content in
\`<div class=\"flex items-center justify-between gap-X\">\`, but a
flex container inside \`<summary>\` (\`display: list-item\`)
shrink-wraps to content size, so \`justify-between\` had nothing to
distribute and the right-side admin actions collapsed toward the
title.

Add \`w-full\` so the flex row stretches across the card. The deeper
component-level fix lands in #1855 (wraps \`summary_content\` in a
\`w-full\` block); this commit makes #1856 self-contained against the
merge order.
2026-05-21 12:57:26 +02:00
Guillem Arias Fauste
78c3331360 feat(design-system): DS::Disclosure :card variant + migrate 14 provider items (#1715 §6) (#1855)
* feat(design-system): DS::Disclosure :card variant + migrate 14 provider items

Resolves part of #1715 §6. The provider-item view templates
(binance, brex, coinbase, coinstats, enable_banking, ibkr,
indexa_capital, kraken, lunchflow, mercury, plaid, simplefin,
snaptrade, sophtron — 14 in total) all hand-rolled the same
`<details open class="group bg-container p-4 shadow-border-xs
rounded-xl">` shell with a custom summary inside and content below.

Extend `DS::Disclosure` with a `:card` variant that bakes the card
chrome onto the `<details>` element itself; the summary becomes
slot-driven via the existing `summary_content` slot. Provider items
keep their custom summary content (logos, brand colors, status copy)
unchanged — they just hand it to the slot instead of writing it
between `<summary>` tags.

API:

  DS::Disclosure.new(variant: :card, open: true) do |d|
    d.with_summary_content do
      <div class="flex items-center gap-2">
        chevron + custom summary markup
      </div>
    end
    body content
  end

While here:

- Drop the no-op `group-open:transform` from the default chevron
  (Tailwind v4 applies `rotate-90` directly).
- Add `motion-safe:transition-transform motion-safe:duration-150`
  to chevron rotation for reduced-motion respect (matches the
  pattern landing in #1841).
- Extract `summary_classes` / `details_classes` helpers so the
  default and card surfaces stay readable side-by-side.

Note: this PR touches `DS::Disclosure` and will textually conflict
with #1841 (focus-ring + reduced-motion polish). Both changes are
compatible — when #1841 merges first, the resolution is just
preserving both edits (the focus-ring classes are already merged
into `summary_classes` here).

* fix(review): use ring-alpha-black-300 focus token in DS::Disclosure

CodeRabbit P2: switch the focus-visible outline from raw
gray-900/white palette values to the alpha-black-300 ring token,
matching the established focus pattern on settings/provider_card.html.erb.
This keeps theme behavior centralized in the design system tokens
instead of branching on theme-dark: in the component.

Applies to both :default and :card summary variants.

* fix(review): stretch DS::Disclosure summary_content to full width

Codex P2 follow-up on the disclosure-migration stack: \`<summary>\` is
\`display: list-item\`, so a flex inner div inside the slot
shrink-wraps to content width — any \`justify-between\` the caller
adds has nothing to distribute, and the right-side admin actions
collapse toward the title across every provider-item partial migrated
to \`DS::Disclosure variant: :card\` in #1855 (and the panels in
#1856 / #1857 / #1858 that inherit this component).

Wrap the slot in \`<div class=\"w-full\">\` so caller-supplied flex
rows stretch across the card. \`:default\` variant is unchanged
(it never uses \`summary_content\`).
2026-05-21 12:56:02 +02:00
Guillem Arias Fauste
8e444ff98b feat(design-system): add DS::SearchInput primitive (closes #1715 §3) (#1853)
* feat(design-system): add DS::SearchInput + migrate 2 broken-focus callsites

Resolves #1715 §3.

Two standalone search-field callsites — `/settings/preferences`
currency filter and `/settings/providers` filter row — had a hand-
rolled markup that ended in `focus:ring-gray-500`. That utility has
no backing token in the design system (`ring-gray-500` isn't in
Tailwind's default + Sure doesn't register a gray ring color), so
the input rendered with zero focus indicator on a bordered
bg-container surface. Keyboard users couldn't tell when the field
was focused.

Introduce `DS::SearchInput` — icon-on-left, bordered, token-backed
focus ring matching the DS::Button pattern landing in #1840
(`outline-2 outline-offset-2 outline-gray-900` with the dark-mode
override). API:

  DS::SearchInput.new(
    name: "...",
    placeholder: "...",
    value: ...,
    aria_label: "...",   # defaults to placeholder
    class: "...",         # passed to the wrapper
    **opts                # spread onto the <input>, e.g. data-*
  )

Migrate the two broken callsites. Three other "search" patterns
stay as-is (out of scope for this PR):

- `form.search_field :search` inside `styled_form_with` blocks
  (accounts/show/_activity.html.erb, UI::Account::ActivityFeed) —
  already routes through StyledFormBuilder's form-field CSS.
- Embedded-dropdown search input inside DS::Select, DS::Menu, and
  the splits/category-select panels — uses a different shape
  (no border, no ring) because the parent panel provides the chrome.
- Category dropdown's combobox search input
  (app/views/category/dropdowns/show.html.erb) — has a custom
  `role=combobox` flow and stays intentionally distinct.

* feat(design-system): add embedded variant to DS::SearchInput, migrate 2 more callsites

Adds `variant: :embedded` to `DS::SearchInput` for search inputs that
live *inside* another DS panel (DS::Select dropdown, splits category
filter, future DS::Popover-hosted filters). No own border / no own
focus ring — the parent panel provides the chrome, so adding ring
+ outline competes with its `focus-within` state.

API:

  DS::SearchInput.new(variant: :embedded, placeholder: "...", data: {...})

The `:standalone` default (from the previous commit) stays unchanged
and remains the right choice for top-of-list filter inputs.

Migrated:

- `app/components/DS/select.html.erb` — the in-dropdown search input
  for `DS::Select.new(searchable: true)`. Was the only remaining
  internal raw <input type="search"> markup in the component.

- `app/views/splits/_category_select.html.erb` — split-transaction
  category picker filter. Same shape as DS::Select's search but
  hand-rolled because the picker isn't a vanilla DS::Select.

Three other search patterns stay out of scope (intentionally, per
the previous commit):

- `form.search_field :search` inside `styled_form_with` — uses
  form-field CSS, different visual contract.
- `app/views/category/dropdowns/show.html.erb` — bespoke
  `role="combobox"` flow with `aria-expanded` / `aria-autocomplete`
  semantics that don't belong in this primitive.

* fix(review): mobile font + embedded variant focus-within ring

- DS::SearchInput: switch text-sm -> text-base sm:text-sm on both
  variants so the input keeps its 16px base size on mobile. iOS
  Safari zooms the viewport when a focused input is below 16px,
  which the unconditional text-sm was triggering on the Settings
  Preferences currency search and Settings Bank Sync provider
  search.

- DS::Select (searchable variant) + splits/_category_select:
  add focus-within:ring-4 focus-within:ring-alpha-black-200
  (with theme-dark variant) on the wrapper around the embedded
  search input. The embedded variant intentionally has no own
  focus ring so it inherits chrome from its parent panel — but
  the two current parent panels were not providing one, so
  keyboard focus on the dropdown search box rendered with no
  visible indicator. Ring matches the .form-field token used
  across the design system.

* fix(merge): repair DS::Select search input merge resolution

The previous merge of main left invalid Ruby inside the DS::SearchInput
`data:` hash:

    aria-label="<%= t("helpers.select.search_placeholder") %>"

This is an ERB string assignment masquerading as a hash entry — it does
not parse and would have raised SyntaxError at render. Two follow-ups:

- Drop the `aria-label` entry entirely. `DS::SearchInput` already
  defaults `aria_label` to `placeholder`, and `placeholder` is set
  on the call, so the resulting <input> already carries
  `aria-label="<%= t(...) %>"`.

- Restore the `input->select#syncTabindex` action that main #1848
  added on the embedded search input. It keeps the roving tabindex
  on the listbox in sync as filtered results change. Original PR
  branch had only `list-filter#filter`; reintegrate both with
  explicit `input->` event prefixes for parity with main.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-21 12:54:54 +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
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
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
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
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
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
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
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
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
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
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
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
Guillem Arias Fauste
482cc3ccbd Merge branch 'main' into feat/goals-v2-architecture 2026-05-18 20:51:38 +02:00
Brendon Scheiber
7411db5689 feat(i18n): add Hungarian translations for strings extracted in #1806 (#1817)
* add missing Hungarian translations for newly extracted strings

Replace hard-coded UI strings with I18n lookups across controllers, models and views (breadcrumbs, dashboard, reports, settings, transactions, balance sheet, MFA status). Update models to use translations for category defaults, account/display names, classification group and period labels; remove a few hardcoded display_name methods. Add and update numerous locale files (English and extensive Hungarian translations, plus model/view/doorkeeper entries) to provide the required keys. These changes centralize copy for localization and prepare the app for Hungarian/English UI text.

* Pluralize account type labels; tidy Crypto model

Update English locale account type labels to use plural forms for consistency (Investment(s), Properties, Vehicles, Other Assets, Credit Cards, Loans, Other Liabilities). Also remove an extra blank line in app/models/crypto.rb to tidy up formatting.

* Back to singular

* fix(i18n): separate singular and group account labels

* Update _accountable_group.html.erb

* Use I18n plural names for account types

Change Accountable#display_name to look up pluralized account type names via I18n (accounts.types_plural.<underscored_class>) with a fallback to the legacy display logic. Add legacy_display_name helper to preserve previous behavior (singular for Depository and Crypto, pluralized otherwise). Add corresponding types_plural entries in English and Hungarian locale files for various account types.

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-05-18 20:49:28 +02:00
Guillem Arias
af647a9cfc feat(beta-gating): beta_gated_nav_item helper auto-marks gated entries
Wraps the conditional + dot wiring into a single call so adding a new
beta nav entry doesn't require remembering to set `beta: true` by hand
or duplicating the `beta_features_enabled?` check. Naming mirrors the
existing `BetaGateable` concern.
2026-05-18 20:43:02 +02:00
Guillem Arias
f7adcac2eb fix(DS::Pill): readable contrast in light mode + drop pill from Goal detail
- Bind CSS `color-scheme` to Sure's `data-theme` attribute so the pill's
  `light-dark()` resolves to the side that matches the active theme. In
  the dark theme it was previously falling back to the light branch.
- Darken light-mode pill text 30% with black on top of the 700 stop so
  the 10–11px uppercase label reads against the violet-50 background.
- Drop the Beta pill from the Goal detail page header. A single goal is
  not the feature; the pill belongs on the feature index, not on each
  record.
2026-05-18 20:27:35 +02:00
Guillem Arias
3479904387 feat(goals): add Beta dot marker on sidebar nav rail
Pass beta: true on gated nav items so the nav_item partial renders a
violet dot-only pill in the top-right of the icon. The doc covers the
dot_only usage; the nav itself was never wired up before merge.
2026-05-18 20:21:23 +02:00
Guillem Arias
5c7babc44e feat(goals): gate Goals v2 behind beta features toggle
Add require_beta_features! to GoalsController and GoalPledgesController,
hide the Goals nav item for non-beta users, and tag index/show headers
with the Beta pill marker. Update controller tests to enable the
preference in setup and assert the redirect for users without access.
2026-05-18 20:13:44 +02:00
Guillem Arias
ac23521c0a Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture 2026-05-18 20:09:48 +02:00
Guillem Arias Fauste
5249842c76 feat: beta features toggle + Beta pill primitive (#1829)
* feat: beta features toggle + Beta pill primitive

Adds the infrastructure for self-service beta opt-in. No call sites yet:
this PR is meant to land first so feature PRs (Goals, etc.) can ship
behind the gate incrementally.

User opts in via a single toggle at the bottom of Settings → Preferences.
The flag persists in the existing `users.preferences` JSONB column under
`beta_features_enabled` — same shape as `dashboard_two_column` and
`show_split_grouped`, so no migration is needed.

Controllers gate a beta feature by adding `before_action
:require_beta_features!` from the new `BetaGateable` concern (included in
ApplicationController). Views use the `beta_features_enabled?` helper to
hide / show nav items, banners, etc. Logged-out callers always return
false.

Ships `DS::BetaPill`, a small inline marker for tagging features as
Beta / Canary in nav, headers, and lists. Five tones (violet by default,
indigo, fuchsia, amber, gray) map to existing Sure color tokens — no raw
hex. Three styles (soft / filled / outline) and two sizes (sm / md) cover
the surfaces in the design handoff. The `dot_only:` mode renders just
the colored dot for use on a collapsed sidebar.

* review: rename to DS::Pill, fix CR/Codex nits, add tests

CodeRabbit + Codex review feedback:

- Rename DS::BetaPill → DS::Pill. The component was already generic in
  shape (tones, styles, sizes); the name was misleading scope. "Beta"
  becomes the default label (still i18n-driven). Goals' StatusPill can
  later refactor onto this primitive without a third pill.
- Localize the default pill label via i18n (`ds.pill.default_label`)
  instead of hard-coding English.
- Add role="img" to the dot-only span so the aria-label is consistently
  exposed to assistive tech.
- Wrap the Preferences toggle row in <label for="…"> so the title and
  description become an honest click target for the toggle (matches the
  cursor-pointer affordance).
- Drop arbitrary Tailwind values (py-[3px], gap-[5px], tracking-[…]) in
  favor of scale tokens. text-[10/11px] stays because the pill is
  intentionally sub-12px (Sure's smallest scale token is text-xs / 12px)
  to read as a marker, not a label.
- Add User#beta_features_enabled? predicate tests covering default-off,
  explicit-true, and non-boolean truthy values.

Won't fix:
- Palette refs (`--color-violet-*` etc.). Sure has no semantic Beta/
  Canary tokens; introducing them in this PR would be a design-system
  change beyond the scope. The component centralizes palette use in one
  `palette` method, matching the existing pattern in
  Goals::StatusPillComponent.

* review: consistent title fallback in full-pill branch

* docs: how to gate a feature behind the beta toggle

* docs: unwrap doc lines to match existing style

* chore(preview): run Cloudflare PR previews on basic instances (#1831)

* fix(preview): use Rails health endpoint for container ping (#1823)

* fix(preview): use Rails health endpoint for container ping

* fix(preview): point container ping to localhost/up

---------

Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com>
2026-05-18 20:07:55 +02:00
Sure Admin (bot)
4fd460d551 Add Actual Budget CSV import flow (#1830)
* Add Actual Budget CSV import flow

* Address Actual import review feedback
2026-05-18 18:38:53 +02:00
Guillem Arias
87817213be ux(goals): fix "0 of N · N reached" KPI weirdness
When every active goal already hit its target, the "Goals on track"
tile read "0 of 2 · 2 reached" — logically correct but emotionally
upside-down. Reached goals aren't being tracked toward pace anymore;
they belong in the trophy column, not in the fraction.

- New `tracked_total` excludes reached and paused goals from the
  denominator. Paused stops the pace clock on purpose; reached has
  already cleared it.
- When `tracked_total` hits zero and at least one goal is reached, the
  tile swaps to a celebratory empty state ("All caught up · N reached")
  instead of trying to render a fraction with no denominator.
- Drop "reached" from the subline when the fraction is calculable. The
  fraction is a needle, "N reached" is a trophy — surfacing them
  together muddied the message. Reached only appears in the all-caught-
  up empty state from here on.

Active-first / reached-last grid order already drops out of the
existing ACTIVE_STATUS_RANK sort (reached defaults to the lowest rank
so it naturally lands after behind / on_track / no_target_date /
paused).
2026-05-18 15:52:14 +02:00
Guillem Arias Fauste
7ddf946647 Merge branch 'main' into feat/goals-v2-architecture
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
2026-05-17 17:08:53 +02:00
Guillem Arias
a4927a3fb8 Merge remote-tracking branch 'origin/feat/goals-v2-architecture' into feat/goals-v2-architecture 2026-05-17 16:57:31 +02:00
Sure Admin (bot)
70fc52769d Add super_admin debug event log (#1816)
* Add super-admin debug event log

* Address debug log review feedback

* Whitelist debug filter params

* Make debug log retention configurable
2026-05-17 16:55:01 +02:00
Guillem Arias
89bae8a59b fix(goals): jjmata review — reconciler guard, chart i18n, pace test
Three issues raised on PR #1798 review:

- ProviderImportAdapter now memoizes account.goal_accounts.exists?
  per-account so a bulk historical import on an unlinked account
  short-circuits the reconciler instead of paying one SELECT per row.
  Linked accounts still hit the per-row reconciler with no change.
- goal_projection_chart_controller.js reads Today / Projected /
  Saved labels via Stimulus values fed from
  goals.show.projection.* locale keys instead of inlining English.
- goal_test.rb now covers Goal#pace with real inflows, asserting
  the 90-day window cutoff plus the Transaction.excluding_pending
  and entries.excluded = false filters.
2026-05-17 16:54:13 +02:00
Guillem Arias
2872f3798e Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture
# Conflicts:
#	app/views/categories/_form.html.erb
2026-05-17 16:21:42 +02:00
Brendon Scheiber
0c126b1674 feat(i18n): extract hardcoded English strings to locale files (#1806)
* Extract hardcoded strings to i18n

Replace numerous hardcoded English strings with I18n lookups (t / I18n.t) across controllers, views, helpers, and components, and convert model validation error messages to symbol keys. Added multiple locale files under config/locales for models and views. This centralizes user-facing notices/alerts, UI text, import/validation messages, and prepares the app for localization and easier translation maintenance.

* Update en.yml

* Update preview-cleanup.yml

* Revert "Update preview-cleanup.yml"

This reverts commit 1ba6d3c34c.

* test: align i18n assertions with translated messages

* Standardize balance error key and tweak locales

Replace SophtronAccount's :requires_balance error key with :no_balance and update related locale strings for sophtron, plaid, and simplefin accounts to use the new key and clearer copy. Also switch the QIF upload redirect notice to use a relative translation key (t('.qif_uploaded')), remove an unused SSO providers help line, and fix a trailing-newline/whitespace issue in the subscriptions locale. These changes standardize validation keys and improve translation consistency and messaging.

---------

Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
2026-05-17 09:52:49 +02:00
Guillem Arias
314113e582 ux(goals): redesign show page — one CTA, calm banners
Header collapses to title + kebab. The status pill and the `Record pledge`
button leave the title row. Status moves into a one-line callout below the
subtitle that doubles as the catch-up demand when behind, the
reach-date when on track, or a prompt for a target date when missing.

`Record pledge` is now the only pledge entry point on the page and lives
under the ring. Behind goals pre-fill it with the catch-up delta.

The standalone catch-up alert card is gone — its title is the callout, its
pace breakdown moves into the projection chart's subtitle, and its CTA
is the ring-adjacent button. The "Adjust target instead" link is
absorbed into the kebab's existing Edit item.

Pending-pledge banner switches from a warning Alert to a neutral
container chip. It is informational state, not a warning. Title carries
the relative pledged-at meta inline; verbose auto-confirms body stays
but in subdued size.

Projection chart drops the today-line pending stub (vertical line +
dashed marker + "+ pending $X" text). That data already lives in the
pending banner above the chart; the duplicate annotation clutters the
today line, the small dashed circle reads as misaligned at small pending
amounts, and the label overlaps the projection trajectory. Shortfall
label gets a paint-order halo so it stays legible across the dashed
projection line.
2026-05-15 14:11:23 +02:00
Guillem Arias
33189c2673 ux(goals): polish detail page + unbreak render
- Fix render-blocker: Money#symbol doesn't exist (use #currency.symbol).
- Sanitize projection_summary so the _html locale renders <strong> markup
  instead of escaping it.
- Switch donut + card ring track to --budget-unused-fill;
  --budget-unallocated-fill resolves to the same gray as bg-surface in
  light mode so the unfilled arc was invisible on the detail page.
- Mobile detail: drop avatar, right-align action buttons, stack
  projection header (subtitle + legend) so the subtitle reads on one
  line; bump legend gap on mobile.
- Nowrap the projected reach-date so e.g. "Jul 2026" stays together.
2026-05-15 13:25:03 +02:00
Guillem Arias
9f29185160 fix(goals): address AI review on PR #1798 (CodeRabbit + Codex)
Correctness:
- GoalPledge#matches? rejects outflows on transfer pledges so a +$200
  purchase no longer satisfies a $200 deposit pledge after .abs
- GoalsController#sync_linked_accounts! saves through the goal so
  currency/depository/family validations actually run on update
- AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and
  reconciler rescues the dedicated class
- SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue
- Assistant::Function::CreateGoal disambiguates duplicate account names
  and returns an absolute URL via mailer host config
- Family#savings_inflow_velocity defensively scopes from the family's
  accounts (was Account.joins(:goal_accounts).where(goal_id: ...))
- GoalPledgesController#set_goal preloads linked_accounts + providers
  to drop the N+1 on any_connected_account?
- Stepper subtitle update walks to the enclosing dialog before
  querySelector so two stepper instances don't fight over one header
- categories/_form.html.erb data-action targets color-icon-picker, not
  the non-existent "category" controller

UX / visual:
- Projection chart drops preserveAspectRatio="none" and pins endDate at
  today for past-due goals so the today marker stays in-domain
- _color_picker / categories form swap non-standard border-1 for border
- Goals index search input uses ring-alpha-black-100 (was raw gray-500)

Refactors:
- Goal#header_summary extracts the multi-line ERB header block
- Goal#catch_up_delta_money sums open_pledges in SQL
- Goal#projection_summary uses I18n.l for the on-track month label
- Account#default_pledge_kind moves the manual/transfer decision out of
  GoalPledgesController
- GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim
  wins is deterministic under non-sequential PKs
- Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent
  use clamp(0..) instead of Float::INFINITY / [x, 0].max
- Goals::StatusPillComponent#label provides a titleize fallback
- Goal projection chart skips the redundant initial _draw and reuses
  the snapped point in the past branch (no double-bisect)
- Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show
  cents while JPY/KRW stay whole-unit
- Demo generator captures the Wedding fund goal in the seed loop
  instead of looking it up by hardcoded name

Tests:
- GoalPledgeTest: outflow rejection
- GoalsControllerTest: cross-currency attachment rejected on update
- SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue
- GoalTest: pledge_action_label_key flips to manual_save without an
  unconditional guard
2026-05-15 00:01:13 +02:00
Guillem Arias
95262c1b6a fix(goals/index): completed goals join the chip-filterable grid
Completed goals previously lived in a dedicated section below the
active grid that was always visible regardless of which chip the
user selected. They were the only state without chip filter
representation.

Fold completed into the main grid in controller-side order:
active goals first (sorted by status rank), completed after
(alphabetical). Drop the separate "Completed" section. The
`data-goal-status="completed"` on each card (from
`Goal#display_status`) makes them filter naturally when the new
`completed` chip is selected.

Archived stays in its own collapsed-by-default `<details>` section
below — the visual-hide-by-default is the point there and a chip
wouldn't preserve that.

`@active_goals` keeps its meaning (active-only) for the KPI strip,
the pending-pledges callout, and the search-visibility check
needs `@grid_goals` so search shows up at six combined cards.

Section heading: "Ongoing" → "Goals". The heading now covers the
combined active + completed list, and "Ongoing" misrepresented
what's below it.
2026-05-14 23:10:51 +02:00
Guillem Arias
82f3d2e0fb fix(goals/show): move Edit into kebab, match house style
User clarification on the "too big edit icon" finding: the header
Edit button itself (not its icon) is what felt wrong. Investigation
showed the wider Sure pattern.

Every other "Edit" affordance in Sure lives inside a DS::Menu kebab:

  - app/views/categories/_category.html.erb
  - app/views/rules/_rule.html.erb
  - app/views/family_merchants/_family_merchant.html.erb
  - app/views/chats/_chat_nav.html.erb
  - app/views/accounts/show/_menu.html.erb
  - app/views/transactions/show.html.erb

Header rows reserve top-level buttons for primary actions (e.g.
"New transaction", "Record pledge"). The goal show page was the
outlier — Edit as an outline button next to Record pledge, which
left two competing CTAs in the header.

Move `menu.with_item(text: t(".edit"), icon: "pencil", ...)` to
the top of the kebab list. Header now has a single primary CTA
(Record pledge, demoted to outline when status is :behind) + the
kebab. Matches every other Sure resource page; eliminates the
"button-too-big" framing entirely without resorting to ad-hoc
icon-size overrides (the `[&>svg]:w-4 [&>svg]:h-4` arbitrary
selector hack would have been a one-off, nobody else uses it).

Avatar pen toggle on the new-goal color picker stays reverted to
its original w-6 h-6 + border-2 form, per the user.
2026-05-14 22:35:02 +02:00