Commit Graph

2674 Commits

Author SHA1 Message Date
dripsmvcp
ab52b2b144 fix(family-sharing): prevent silent data loss when rehoming or removing users (#1896)
* fix(family-sharing): prevent silent data loss when rehoming or removing users

Fixes #1689.

Two destructive paths could strand a pre-existing user's family and accounts:

1. Invitation#accept_for unconditionally overwrote user.family_id, orphaning
   the prior family + its accounts with no user able to reach them.
2. Settings::ProfilesController#destroy then called @user.destroy when an admin
   removed the rehomed member, destroying the only login path back to the
   now-orphaned data.

Add hard-block guards on both paths. accept_for refuses when the invitee
already belongs to a family with accounts; ProfilesController#destroy refuses
when the member owns accounts in another family (legacy state from the old
flow). InvitationsController#create surfaces a specific, actionable flash so
the admin understands why the auto-accept was refused.

No automatic recovery of already-orphaned data — that needs a separate
one-shot script per dosubot's analysis on the issue.

* fix(family-sharing): scope invite orphan-guard to invitee-owned accounts (#1896 review)

Codex flagged (P1) and the maintainer review independently raised that
would_orphan_existing_family? keyed off user.family.accounts.exists? —
any account in the invitee's current family — which wrongly blocked a
non-owner member from leaving a multi-user household.

Rename to would_orphan_owned_accounts? and key off
user.owned_accounts.where.not(family_id: family_id), making the invite
guard symmetric with the destroy-path guard in
Settings::ProfilesController. A member who owns no accounts now orphans
nothing by moving and is free to accept the invitation; an owner is
still blocked.

Add a regression test for the non-owner case and update the existing
tests to give the invitee explicit account ownership.

* Remove extra comments per project conventions

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-27 23:25:46 +02:00
sentry[bot]
12aff3cef7 fix(settings): add nil-safe operator to Current.family.can_manage_subscription? (#2006)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-05-27 23:22:20 +02:00
Blaž Dular
326595ad71 fix(accounts): show activity label instead of category for accounts supporting trades (#1993) 2026-05-27 23:04:42 +02:00
Juan José Mata
1bbc2ea25d Remove Gittensor labeling 2026-05-27 21:28:49 +02:00
Juan José Mata
0342958a32 Create SECURITY.md template for security policy and reporting
Added a security policy document outlining supported versions and vulnerability reporting.

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-27 09:35:10 +02:00
CrossDrain
3e2990a52c feat(ibkr): compute net_market_flows from IBKR equity equity delta and trade flows (#1970)
* feat(ibkr): compute net_market_flows from IBKR equity delta and trade flows

Replace the hardcoded net_market_flows: 0 in HistoricalBalancesSync with an
exact derivation from IBKR's own equity summary data, eliminating any
dependency on third-party security price providers for Period Return.

Formula: nmf = Δnon_cash - net_buy_sell
  - non_cash = IBKR equity total - materializer cash (exact per IBKR)
  - net_buy_sell = sum of trade amounts converted to base currency using
    the stored fx_rate_to_base (IBKR's own FX rate, already on Trade#exchange_rate)

Sets non_cash_adjustments = net_buy_sell so the virtual column identity
(end_non_cash_balance = start + nmf + adjustments) resolves to IBKR's
exact equity figure.

* test(ibkr): add sell-trade and no-trade nmf tests; fix memoization guard

- Add test: sell trades (negative amount) correctly isolate market loss in nmf
- Add test: no-trade scenario produces nmf = full Δnon_cash
- Fix: `return {} unless account` inside ||= exited the method without memoizing;
  restructure to `if account ... else {} end` so the result is always cached

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

* fix(ibkr): exclude dividend/interest trades from net_buy_sell; use historical FX date

Addresses two issues flagged in code review:

- P1: trades with qty=0 (Dividend, Interest) were included in net_buy_sell,
  inflating/deflating nmf on dates with income events. Filter to qty != 0 at
  the SQL level so only buy/sell trades affect the market-flow calculation.

- P2: Money#exchange_to defaulted to Date.current when no custom_rate was
  stored, causing historical nmf to drift as FX rates change over time.
  Pass date: entry.date so the fallback lookup uses the trade's own date.

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

* test(ibkr): cover Money::ConversionError fallback in trade_flows_by_date

Adds a test that stubs Money#exchange_to to raise ConversionError for a
cross-currency trade with no stored exchange_rate, verifying that the
rescue clause falls back to entry.amount and that nmf and
end_non_cash_balance still resolve correctly.

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

* fix(ibkr): log warning when FX conversion falls back to unconverted amount

When Money::ConversionError is raised for a cross-currency trade with no
stored exchange_rate, warn with entry currency, account currency, date,
amount, and entry/account IDs so the silent fallback is visible in logs.
Same-currency ConversionErrors (unexpected but possible) stay silent.

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

* fix(ibkr): skip unconvertible FX trades, redact log, tighten join

- On Money::ConversionError, skip the entry from net_buy_sell rather
  than falling back to the raw amount (which treated e.g. EUR as CHF);
  nmf now absorbs the full Δnon_cash for that date instead of silently
  misstating period return
- Remove entry amount, entry ID, and account ID from the FX warning log
  to avoid exposing financial data in log output
- Consolidate entryable_type guard into the JOIN condition rather than a
  separate WHERE clause
- Add inline comment on the first-day zero case to distinguish intent
  from a bug
- Update ConversionError test to assert skip behavior (nmf=200, not 50)

* fix(ibkr): exclude dates with unconvertible FX trades from balance upsert

* fix(ibkr): skip upsert_all when all balance rows are filtered by failed FX dates

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:48:23 +02:00
Alessio Cappa
bc3e5a824f feat: Add pagination in merchants page (#1965)
* feat: Add pagination in merchants page

* fix: Add separate paginations for family/provider merchants

* refactor: simplify conditions in view
2026-05-26 22:17:00 +02:00
Rene Arredondo
946c4d0391 fix(i18n): use %{product_name} in api_keys usage_instructions (#1505) (#2000) 2026-05-26 20:48:34 +02:00
Alessio Cappa
e0537a45e1 fix: Avoid overlay in provider section on mobile (#1990)
* fix: Avoid overlay in provider section on mobile

* feat: Reduce gap between divs

* fix: keep all the elements inside a dedicated container to avoid accessibility issues with the summary node
2026-05-26 09:56:42 +02:00
Sure Admin (bot)
d8a12ad6be fix(preview): only redeploy on preview-cf label changes (#1980) 2026-05-25 15:31:00 +02:00
dripsmvcp
8f5454ad29 fix(settings): preserve OpenAI form input on validation failure (#1862)
* fix(settings): preserve OpenAI form input on validation failure

Fixes #1824.

The OpenAI settings form auto-submits on blur, so typing the URI base
before the model triggers cross-field validation. The rescue re-renders
the page with values read from Setting.openai_*, which is still blank
because the failed save was rejected — so the user's input disappears
and they see 'OpenAI model is required' with no value to fix.

Stash the submitted uri_base and model on rescue and prefer them over
the saved Setting when rendering, so the user can finish typing the
missing field and re-submit.

* test(settings): cover openai_model preservation on validation fail (#1862)

jjmata asked for symmetric coverage of the model field. Add a test where
the user changes the URI base and clears the model in the same submit:
the cross-field validation fails and the re-rendered model input must
reflect the submitted (cleared) value rather than reverting to the saved
model. Complements the existing uri_base preservation test.
2026-05-25 11:23:52 +02:00
Sure Admin (bot)
89f42497a9 fix: invert non-gittensor label condition (#1960) 2026-05-24 17:36:17 +02:00
Juan José Mata
8c07236f71 Bump version by hand v0.7.1-alpha.11 2026-05-24 16:19:37 +02:00
Guillem Arias Fauste
adea16f694 fix(views): clear Rule 2 + Rule 5 findings from weekly DS drift (#1951) (#1955)
* fix(views): clear Rule 2 + Rule 5 findings from weekly DS drift (#1951)

Token swaps + i18n cleanup across the three files flagged in the
weekly merged-commit drift scan.

**`app/views/admin/users/index.html.erb`**
- `bg-green-100 text-green-800` → `bg-success/10 text-success` (2 callsites — active-subscription badge + super_admin role legend)
- `bg-surface-default` → `bg-surface` (`--color-surface-default` isn't defined; canonical token is `--color-surface`)
- `bg-red-50/30 dark:bg-red-950/20` → `bg-destructive/5` (pending-invitation row highlight; functional token resolves correctly in both themes via `--color-destructive`)
- Hand-rolled destructive button classes (`text-red-600`, `border-red-300`, `hover:bg-red-50`) → functional tokens (`text-destructive`, `border-destructive`, `hover:bg-destructive/10`)
- Drop redundant `default:` args from `t(".roles.member", default: "Member")` and `t(".role_descriptions.member", default: "Basic user access…")` — the locale keys exist in `config/locales/views/admin/users/en.yml`

**`app/views/imports/new.html.erb`**
- `icon_bg_class: "bg-gray-tint-5"` → `"bg-surface-inset"` (`gray-tint-5` isn't a defined utility; `bg-surface-inset` carries the same muted-background intent and theme-swaps correctly)

**`app/views/settings/profiles/show.html.erb`**
- Drop redundant `default:` args from `t(".group_title", default: "Group")`, `t(".group_form_label", default: "Group name")`, and `t(".group_form_input_placeholder", default: "Enter group name")` — all three keys exist in `config/locales/views/settings/en.yml`

**Deferred** to a separate PR (Rule 1 findings on admin/users):
- `<details>` block (lines 54–180) → `DS::Disclosure(:card)` — bigger refactor with custom summary content + Stimulus controller attributes; warrants its own diff.
- Destructive button shell → `DS::Button(:destructive)` — same reason; the class-token swap in this PR clears the immediate violation without changing the form-with structure or visual.

Refs #1951.

* fix(profiles): restore i18n default: args for group_* keys

@jjmata + @codex correctly flagged: `settings.profiles.show.group_title`,
`group_form_label`, and `group_form_input_placeholder` are defined in
en.yml + 4 other locales (de, es, pl, pt-BR), but missing from 8
locales (ca, fr, nb, nl, ro, tr, zh-CN, zh-TW).

With `config.i18n.fallbacks = true` those locales currently fall
back to en values, so end-users see English copy rather than a
translation-missing marker. The `default:` arg makes the fallback
explicit at the call site without depending on the Rails fallback
chain being configured a particular way — restores the original
defensive behavior from before #1955.

Admin/users role keys keep their `default:` removal — verified that
`roles.member` and `role_descriptions.member` exist in all 8
admin/users locales (`grep -c "^\s*member:"` returns 2 for every
locale file).
2026-05-24 16:05:14 +02:00
sentry[bot]
06518b49a1 fix(trades): prevent MissingTemplate for Turbo Stream requests on update/create failure (#1893)
* fix(trades): prevent MissingTemplate for Turbo Stream requests on update/create failure

* Linter noise

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-24 15:51:18 +02:00
sentry[bot]
2ce875f57f fix(messages): handle blank content submission gracefully (#1938)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-05-24 15:50:47 +02:00
sentry[bot]
5520bacbb8 fix(i18n): standardize product name interpolation in import mapping descriptions (#1956)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-05-24 15:37:13 +02:00
sentry[bot]
c93193cfbc fix(locale): Handle blank locale submission gracefully (#1876)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-05-24 15:13:49 +02:00
Sure Admin (bot)
c7c63a50a7 Add PR workflow for not-gittensor labeling (#1957) 2026-05-24 14:55:08 +02:00
Abhinav Dhiman
0988e2d9d6 perf: use jemalloc as the default allocator (#1910)
* feat(docker): add jemalloc to reduce memory fragmentation

Install libjemalloc2 in the base image and preload it via LD_PRELOAD in
docker-entrypoint when available. Reduces RSS growth from glibc's default
allocator fragmentation under Rails workloads.

* feat(docker): add DISABLE_JEMALLOC env var + preserve existing LD_PRELOAD

* feat(docker): add jemalloc status logging to entrypoint

* refactor(docker): simplify jemalloc logging to warn-only when disabled/missing
2026-05-24 14:02:50 +02:00
Josh
ca895416a4 chore(helm): bump pipelock to 2.5.0 and surface 2.5 config (#1913)
* chore(helm): bump pipelock to 2.5.0 and surface 2.5 config

Bumps pipelock.image.tag from 2.2.0 to 2.5.0 and exposes the most
relevant 2.5 features as structured Helm values:

- pipelock.requestBodyScanning: scan outbound bodies and sensitive
  headers for prompt-injection and DLP payloads. Disabled by default;
  roll out with action=warn before flipping to block.
- pipelock.healthWatchdog: structured config for the wedge-detection
  watchdog with an exposeSubsystems toggle for /health detail.
- pipelock.mcpToolPolicy.rules: structured values for rendering
  mcp_tool_policy.rules including redirect-profile references.

Also fixes a latent config-validation regression: pipelock 2.x rejects
an enabled mcp_tool_policy with no rules, but the chart previously
defaulted to enabled=true with an empty rules list, which hard-fails
'pipelock check'. The default is now enabled=false; operators must
explicitly enable and provide at least one rule.

Refreshes README, CHANGELOG, docs/hosting/pipelock.md, docs/hosting/ai.md,
compose example pin comment, and pipelock.example.yaml to call out 2.5
highlights (Audit Packet v0 verifiers, SPIFFE-strict envelopes, scanner
attribution on MCP block receipts, pipelock doctor). Also fixes a stale
docs/hosting/mcp.md reference to the removed compose.example.pipelock.yml.

* chore(helm): fail helm template when mcp_tool_policy enabled with no rules

Adds a guard in asserts.tpl so an operator who sets
pipelock.mcpToolPolicy.enabled=true without populating
pipelock.mcpToolPolicy.rules gets a clear render-time error instead
of a container crash-loop with the pipelock validation message.

Per CodeRabbit feedback on #1913.

* Versions

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-24 13:50:44 +02:00
dripsmvcp
98ca1608f4 fix(enable_banking): match bank list search against BIC, not just name (#1874)
* fix(enable_banking): match bank list search against BIC, not just name

Bank-search filter on the Enable Banking bank-selection modal only indexed
`aspsp[:name]`, so users searching by BIC code (e.g. `INGDDEFF`) got no
results even when the bank was rendered in the list. Switch the per-item
data attribute to a `name + BIC` haystack and read from it in the Stimulus
controller, so either token matches.

Refs #1814

* style(bank_search): apply Biome formatting to forEach callback (#1874 review)
2026-05-24 13:43:36 +02:00
arumaio
eca8c6ce1f fix : account destroyed cascade transfer destruction then … (#1795)
* fix: cascade destroy transfers and reset transaction kind on account destruction.

* Add rescue no method to transfer transaction reset

---------

Co-authored-by: arumaio <aruma.pro+git@protonmail.com>
2026-05-24 13:27:27 +02:00
Jake Armstrong
51271d9810 Prevent Brand Fetch client ID autofill (#1914) 2026-05-24 12:38:13 +02:00
Guillem Arias Fauste
3d182bc67a refactor(transactions): migrate transfer_match badges to DS::Pill (#1939)
Follow-up to #1917 — the responsive label-swap pair in
`_transfer_match.html.erb` was deferred because DS::Pill has no
caller-controlled `class:` arg yet. Wrapping each `DS::Pill` in a
`<span>` with the responsive visibility classes (`hidden lg:inline` /
`inline lg:hidden`) gets the same effect without expanding the
component API — the parent span's `display` controls visibility, the
child pill keeps its own `inline-flex` chrome when visible.

Closes the last open callsite from #1917's deferred-list. Same tone
(`:neutral`) and shape (`marker: false` rounded-full) as the other
neutral status badges migrated in PR B.
2026-05-24 12:16:03 +02:00
Mark Hendriksen
0497b1d7c1 Use date comparisons for interval thresholds (#1923)
* Use date comparisons for interval thresholds

Replace hard-coded day counts in Period#interval with direct date comparisons (end_date > start_date + 5.years and + 1.year) for clearer intent and to avoid magic numbers; updated inline comments. No behavioral change intended aside from improved readability.

* Use advance(years:) for year-based comparisons

Replace start_date + N.years with start_date.advance(years: N) to apply calendar-year semantics (respecting leap years/month boundaries). Update comments to clarify 'calendar years' and the resulting interval choices (monthly for >5 years, weekly for >1 year). Intent is to make the period interval calculation more correct for calendar-aware date comparisons.
2026-05-23 09:41:57 +02:00
Guillem Arias Fauste
9182346c6c fix(admin/users): use parent space-y-6 for sibling section spacing (#1934)
The admin users page wraps four top-level sibling sections inside a
single `bg-container rounded-xl shadow-border-xs p-4` card:

  1. description paragraph
  2. filter form
  3. trials-expiring summary grid
  4. families/groups list
  5. role descriptions (`settings_section` collapsible → DS::Disclosure :card)

The first three carried their own `mb-6`; the families list and the
role descriptions section had no margin at all, so the families card
sat flush against the role-descriptions card with zero gap — clearly
broken next to the well-spaced upper sections.

Apply spacing at the **layout** level: hoist `space-y-6` onto the
outer container and drop the per-child `mb-6`. All five siblings now
get a consistent 24px gap.

No other admin or settings pages match this exact pattern (single
outer card + multiple sibling sections without parent space-y) — the
settings layout already wraps `<%= yield %>` in `space-y-4`, and other
pages with outer cards (`api_keys/show`, `llm_usages/show`, etc.)
either rely on that layout or carry their own internal `space-y-N`.
2026-05-23 09:25:48 +02:00
Guillem Arias Fauste
ea51612ac7 refactor(views): migrate 6 residual inline alerts to DS::Alert (#1933)
* refactor(views): migrate 6 residual inline alerts to DS::Alert

PR #1731 extended DS::Alert and migrated 9 inline alert blocks. Six
hand-rolled alert blocks slipped through that sweep and stayed on raw
palette tokens with no `theme-dark:` variants:

- `app/views/settings/llm_usages/show.html.erb` — "About Cost Estimates"
  blue info block. Most visible offender: `bg-blue-50 border border-blue-200`
  + `text-blue-900 / text-blue-700 / text-blue-600` rendered as a bright
  white-blue island in dark mode (the bug spotted on the LLM usage page).
- `app/views/accounts/confirm_unlink.html.erb` — yellow warning with
  bullet list.
- `app/views/oidc_accounts/new_user.html.erb` — blue info heading.
- `app/views/oidc_accounts/link.html.erb` — two blocks (yellow verify
  warning + blue create info). Also flips the file's pre-existing
  `text-gray-600` hint paragraph to `text-secondary` (caught by the
  `DeprecatedClasses` erb_lint rule on save).
- `app/views/rules/confirm.html.erb` — AI cost notice.
- `app/views/rules/confirm_all.html.erb` — AI cost notice.

All six migrate to `DS::Alert.new(title:, variant:)` (with a block content
slot for the rich/conditional bodies). DS::Alert resolves `bg-info/10`,
`border-info/20`, etc. from the `@theme` semantic tokens, so dark mode
now renders a subtle blue/yellow tint over the page surface instead of
a hardcoded light-mode pill.

Out of scope (left as-is, not alert-shaped):

- `app/views/assistant_messages/_tool_calls.html.erb` — a tool-call
  display panel (not an alert; needs its own token sweep).
- `app/views/import/rows/_form.html.erb` — inline cell-error tooltip
  (`bg-red-50 border border-red-200`) — also not alert-shaped; a future
  PR can swap it to `bg-destructive/10 border-destructive-subtle` once
  #1932 lands.

Surfaced while scanning DS drift for the LLM usage page bug. Tracking
issue: #1715 (closed but conceptually relevant) / #1911 (active drift
patrol).

* fix(oidc): keep alert description in <p>, retarget tests for DS::Alert title

CI on #1933 caught three test failures introduced by migrating the
two OIDC link alerts and the verify-redirect copy from hand-rolled
`<h3>` / `<p>` markup to `DS::Alert`:

1. `OidcAccountsControllerTest#test_should_show_create_account_option_for_new_user`
2. `OidcAccountsControllerTest#test_does_not_show_create_account_button_when_JIT_link-only_mode`
3. `SessionsControllerTest#test_redirects_to_account_linking_when_no_OIDC_identity_exists`

DS::Alert renders its `title:` slot as a `<p>` (semantically the alert
heading lives on the container's `aria-labelledby`, not on a heading
tag) and renders block / message content directly inside a `<div>`,
not a `<p>`. The pre-migration markup used `<h3>` for the heading and
`<p class="...text-blue-700">` for the description, so the tests
above asserted those specific tags.

Two fixes:

- `app/views/oidc_accounts/link.html.erb` — wrap the html_safe
  description bodies in explicit `<p>` tags inside the DS::Alert
  block. Restores the `<p>` element the session-redirect test asserts
  on, and keeps the description as a semantic paragraph rather than
  a bare text node inside the alert container.
- `test/controllers/oidc_accounts_controller_test.rb` — flip the two
  `assert_select "h3", text: "Create New Account"` calls to match the
  DS::Alert title `<p>`. The test was asserting an implementation
  detail of the pre-migration markup; switching to the new tag keeps
  the assertion meaningful (the heading text still has to render)
  without re-introducing an `<h3>` outside of DS::Alert.

* fix(test): match Create New Account title with regex (sr-only "Info:" prefix)

DS::Alert prepends `<span class="sr-only">Info:</span>` inside the
title `<p>`, so the full text content is "Info: Create New Account",
not "Create New Account". `assert_select "p", text: "Create New Account"`
requires an exact text match and rejected the prefixed string. Switch
to a regex match — keeps the heading-text assertion meaningful without
coupling to the screen-reader prefix.
2026-05-23 09:23:30 +02:00
Guillem Arias Fauste
f0e270f578 fix(design-system): restore dark-mode contrast on Toggle + destructive borders (#1932)
Two regressions from the recent token sweep, both producing low-contrast
results in dark mode.

## DS::Toggle off-track

PR #1843 (DS::Toggle a11y + token swaps) replaced the raw
`bg-gray-100 theme-dark:bg-gray-700` off-track with `bg-surface-inset`
for semantic alignment. `bg-surface-inset` resolves to gray-800 in
dark mode, but the toggle typically sits inside `bg-container`
(gray-900). The contrast ratio dropped from ~2.45:1 (gray-700 vs
gray-900) to ~1.5:1 (gray-800 vs gray-900) — visibly worse than the
pre-#1843 baseline and below WCAG 1.4.11 (3:1 for UI components).

Most visible inside the transaction-edit modal SETTINGS section
(`Exclude`, `One-time Expense`) where the off-state switches nearly
vanished into the modal chrome.

Introduce `--color-toggle-track` (light: gray-100, dark: gray-700) and
swap `bg-surface-inset` → `bg-toggle-track` in DS::Toggle. Restores the
pre-#1843 off-track contrast while keeping a semantic token (instead
of the raw palette references the migration was trying to remove).

## border-destructive subtle borders

PR #1849 (single-color tokens to @theme) flagged that
`border-destructive/N` rendered the wrong shade (the `@utility
border-destructive` block defined red-500 light, while
`--color-destructive` in `@theme` is red-600 — `/N` resolves from
@theme), and swapped a couple of callsites to solid `border-destructive`.
Solid renders red-500/red-400 at full saturation in both modes, which
reads as a loud error border on contexts that were meant to be subtle
(left-rule on the provider-sync "view error details" pane, error-message
box in SimpleFIN settings, alert-component border, provider connection
error rows).

Two callsites (`DS::Alert`, settings/providers/_connection_row) still
carried the broken `border-destructive/20` / `/25` modifier — same
off-shade footgun #1849 was meant to retire.

Introduce `--color-destructive-subtle` (light: red-200, dark: red-800)
and swap the four subtle-by-intent callsites to `border-destructive-subtle`:

- app/components/DS/alert.rb (destructive variant)
- app/views/settings/providers/_connection_row.html.erb (err status)
- app/components/provider_sync_summary.html.erb (error-details left rule)
- app/views/simplefin_items/edit.html.erb (error-message box)

The handful of intentionally-loud `border-destructive` callsites
(split-transaction over-allocation, blank-name account labels, etc.)
keep the solid token.

Regenerated `_generated.css` via `npm run tokens:build`.
2026-05-23 09:21:46 +02:00
Guillem Arias Fauste
cc8e2abf18 fix(design-system): DS::Menu add :icon_sm variant for dense action lists (#1930)
PR #1840 bumped DS::Button icon-only `:md` size from `w-9 h-9` (36×36)
to `w-11 h-11` (44×44) for WCAG 2.5.5 enhanced touch target. DS::Menu's
`:icon` variant uses DS::Button at the default `:md` size, so every
row-level "..." action-list trigger grew from 36×36 to 44×44.

For dense lists where each row has a trigger — most visibly the
transaction category dropdown (`category/dropdowns/_row.html.erb`) —
the per-row height bump (+8px) compounds: a 5-category panel that
used to fit in ~220px now wants ~260px, the badges look smaller
relative to the row chrome, and the overall density that made the
dropdown scannable regresses visibly.

Add an `:icon_sm` variant that renders the trigger as DS::Button at
`size: :sm` (32×32). Meets WCAG 2.5.8 AA (24×24) — appropriate for
compact in-row triggers where 44×44 isn't required. Standalone
toolbar / row-action `...` triggers should keep `:icon` for AAA.

Migrate `category/dropdowns/_row.html.erb` to `:icon_sm` to restore
the pre-#1840 dropdown density.
2026-05-23 09:18:16 +02:00
Guillem Arias Fauste
c8b1d8cf92 fix(design-system): DS::Disclosure :default variant summary_content layout (#1929)
PRs #1855, #1857, #1858 (DS::Disclosure :card/:card_inset/:inline
variants) introduced a `<div class="w-full">` wrapper around
`summary_content`. The wrapper is required for non-default variants —
their `<summary>` is `display: list-item` (no flex), so a caller's
inner flex+justify-between div would shrink-wrap to content width.

But for the `:default` variant, `<summary>` is already
`flex items-center justify-between`. Wrapping caller siblings in a
single `w-full` block collapses them into one flex child, killing the
justify-between distribution. This regressed the only default-variant
summary_content caller — `accounts/_accountable_group.html.erb` (the
homepage account sidebar) — where the group name and total/sparkline
divs no longer aligned across the row.

Render `summary_content` bare for `:default` (summary is the flex
container) and keep the `w-full` wrapper for `:card`, `:card_inset`,
`:inline`.
2026-05-23 09:06:17 +02:00
Guillem Arias Fauste
e15349d57e refactor(misc): migrate misc badges to DS::Pill (#1751 PR D) (#1919)
* refactor(transactions): migrate 5 transaction badges to DS::Pill (#1751 PR B)

Migrates the hand-rolled "Pending" / "Review recommended" / "Potential
duplicate" / "Split" badges across the transaction views to the
extended DS::Pill primitive from #1902.

**Visual contract for badge mode**

In #1902 the badge mode (`marker: false`) used `rounded-md` (chip shape)
because the marker mode does. But every existing pill / status badge
in the codebase uses `rounded-full` — see
`settings/providers/_status_pill.html.erb`,
`settings/providers/_maturity_badge.html.erb`, and the inline
transaction badges this PR is migrating. To keep the visual contract
consistent, this PR shifts `DS::Pill`'s badge mode to `rounded-full`
(marker mode stays `rounded-md`, unchanged from #1829). The shape
distinction now reads: markers are tags, badges are pills.

**Callsites migrated** (5):

- `app/views/transactions/_transaction.html.erb` — Pending,
  Review-recommended, Possible-duplicate, Split badges
- `app/views/transactions/_header.html.erb` — Pending badge
- `app/views/transactions/_split_parent_row.html.erb` — Split badge

**Tone mapping**

| Badge | Tone | Notes |
|---|---|---|
| Pending | `:neutral` | unchanged copy/icon, gains subtle DS-controlled bg |
| Review recommended | `:neutral` | matches existing `bg-surface-inset` look |
| Possible duplicate | `:warning` | DS semantic alias for the existing `text-warning` |
| Split | `:neutral` | matches existing `bg-surface-inset` look |

**Deferred to follow-up PRs**

- `app/views/transactions/_transfer_match.html.erb` — uses two
  responsive-visibility variants (`hidden lg:inline-flex` for long
  copy, `inline-flex lg:hidden` for short). DS::Pill currently has no
  `class:` arg for caller-controlled wrapper classes; deferring until
  that lands.
- `app/views/transactions/searches/filters/_badge.html.erb` — has a
  close button alongside the label (`button_to clear_filter_*`) and
  uses `rounded-3xl p-1.5` instead of a true pill. Closer to a
  removable filter chip — better fit for a separate `DS::FilterChip`
  primitive than for `DS::Pill`.

Refs #1751.

* refactor(misc): migrate misc badges to DS::Pill (#1751 PR D)

Replaces five misc badge callsites with `DS::Pill` (badge mode:
`marker: false`, `show_dot: false`) so the long-tail badges share the
same shape, padding, and dark-mode tokens as the rest of the design
system. No raw palette classes remain in the migrated files.

Migrated:
- app/views/shared/_badge.html.erb — converted to a thin shim that
  renders `DS::Pill`; preserves the block-content API and the
  `pulse: true` option (wraps the pill in `animate-pulse`). Maps
  `success`/`error`/`warning`/default → `:success`/`:error`/`:warning`/`:neutral`.
- app/views/accounts/_tax_treatment_badge.html.erb — maps tax
  treatments to DS tones: `:tax_exempt → :green`,
  `:tax_deferred → :indigo` (was raw blue-500/10),
  `:tax_advantaged → :violet` (was raw purple-500/10), default → `:neutral`.
- app/views/reports/_investment_performance.html.erb (line ~121,
  inline twin of the tax-treatment badge) — uses the same mapping via
  a new `tax_treatment_pill_tone` helper.
- app/helpers/reports_helper.rb — replaces `tax_treatment_badge_classes`
  with `tax_treatment_pill_tone` (the old helper had no other callers).
- app/views/import/qif_category_selections/show.html.erb (~line 86) —
  inline split badge → `tone: :warning`.
- app/views/investment_activity/_badge.html.erb — fixed activity
  enum mapped to DS tones: Buy/Reinvestment → :indigo,
  Sell → :red, Dividend/Interest → :green, Contribution → :violet,
  Withdrawal → :amber, others → :gray.

Skipped (true mismatches, not extendable without changing DS::Pill):
- app/views/shared/_color_badge.html.erb — takes an arbitrary
  user-supplied color via `color-mix(in oklab, #{color} ...)`. DS::Pill
  only supports the fixed tone enum, so this would lose information.
- app/views/categories/_badge.html.erb — same reason; renders
  `category.color` (arbitrary hex per record).
- app/views/investment_activity/_quick_edit_badge.html.erb — interactive
  button with a Stimulus controller, click action, hover state, and
  dropdown anchor. DS::Pill renders a `<span>`; converting would
  destroy the interactive surface.

Stack: based on `feat/ds-pill-transactions-1751-b` (PR #1917), which
ships the `marker: false` → `rounded-full` badge shape this PR depends on.

Refs #1751.
2026-05-23 08:55:39 +02:00
Guillem Arias Fauste
4bb326fee5 docs(ds-toggle): warn against external hidden_field_tag with same name (#1925)
DS::Toggle already renders a paired hidden field for the off-state
value. Adding an external `hidden_field_tag` with the same `name` in a
caller view causes ID/label collisions (the auto-generated id matches
the checkbox id, so `<label for=...>` targets the hidden field) and
sends duplicate params.

Inline ERB comment so the warning surfaces wherever the component is
read or copied.
2026-05-23 08:53:56 +02:00
Guillem Arias Fauste
2ce8e858b6 refactor(providers): migrate provider badges to DS::Pill (#1751 PR C) (#1918)
* refactor(transactions): migrate 5 transaction badges to DS::Pill (#1751 PR B)

Migrates the hand-rolled "Pending" / "Review recommended" / "Potential
duplicate" / "Split" badges across the transaction views to the
extended DS::Pill primitive from #1902.

**Visual contract for badge mode**

In #1902 the badge mode (`marker: false`) used `rounded-md` (chip shape)
because the marker mode does. But every existing pill / status badge
in the codebase uses `rounded-full` — see
`settings/providers/_status_pill.html.erb`,
`settings/providers/_maturity_badge.html.erb`, and the inline
transaction badges this PR is migrating. To keep the visual contract
consistent, this PR shifts `DS::Pill`'s badge mode to `rounded-full`
(marker mode stays `rounded-md`, unchanged from #1829). The shape
distinction now reads: markers are tags, badges are pills.

**Callsites migrated** (5):

- `app/views/transactions/_transaction.html.erb` — Pending,
  Review-recommended, Possible-duplicate, Split badges
- `app/views/transactions/_header.html.erb` — Pending badge
- `app/views/transactions/_split_parent_row.html.erb` — Split badge

**Tone mapping**

| Badge | Tone | Notes |
|---|---|---|
| Pending | `:neutral` | unchanged copy/icon, gains subtle DS-controlled bg |
| Review recommended | `:neutral` | matches existing `bg-surface-inset` look |
| Possible duplicate | `:warning` | DS semantic alias for the existing `text-warning` |
| Split | `:neutral` | matches existing `bg-surface-inset` look |

**Deferred to follow-up PRs**

- `app/views/transactions/_transfer_match.html.erb` — uses two
  responsive-visibility variants (`hidden lg:inline-flex` for long
  copy, `inline-flex lg:hidden` for short). DS::Pill currently has no
  `class:` arg for caller-controlled wrapper classes; deferring until
  that lands.
- `app/views/transactions/searches/filters/_badge.html.erb` — has a
  close button alongside the label (`button_to clear_filter_*`) and
  uses `rounded-3xl p-1.5` instead of a true pill. Closer to a
  removable filter chip — better fit for a separate `DS::FilterChip`
  primitive than for `DS::Pill`.

Refs #1751.

* refactor(providers): migrate provider badges to DS::Pill (#1751 PR C)

Migrates the provider-bucket pill/badge callsites to the extended
DS::Pill primitive (badge mode, rounded-full) from #1917.

Callsites migrated (3):

- app/views/settings/providers/_status_pill.html.erb — provider
  connection status pill. Status → tone mapping:
  :ok → :success, :warn → :warning, :err → :error,
  else → :neutral.
- app/views/settings/providers/_maturity_badge.html.erb — alpha/beta
  label. Tone :neutral, no dot.
- app/views/sophtron_items/_sophtron_item.html.erb (line 27) —
  "manual sync" warning. Tone :warning, no dot.

The settings/providers/_status_pill partial wraps DS::Pill rather
than being deleted, since _connection_row still calls it via
`render "settings/providers/status_pill", status: status` — keeping
the partial preserves the seam without a wider refactor.

Dead code removed: SettingsHelper#status_pill_classes (no remaining
callers after the migration).

Skipped:

- app/views/simplefin_items/_activity_badge.html.erb — not actually
  a pill/badge. It renders <p> text with `text-warning` plus an
  inline icon below the heading; no rounded-full shape and no chip
  semantics. Migrating it would change the layout, not consolidate
  a pill pattern.

Refs #1751. Stacks on #1917.
2026-05-23 08:52:04 +02:00
Guillem Arias Fauste
20844923e6 refactor(transactions): migrate 5 transaction badges to DS::Pill (#1751 PR B) (#1917)
Migrates the hand-rolled "Pending" / "Review recommended" / "Potential
duplicate" / "Split" badges across the transaction views to the
extended DS::Pill primitive from #1902.

**Visual contract for badge mode**

In #1902 the badge mode (`marker: false`) used `rounded-md` (chip shape)
because the marker mode does. But every existing pill / status badge
in the codebase uses `rounded-full` — see
`settings/providers/_status_pill.html.erb`,
`settings/providers/_maturity_badge.html.erb`, and the inline
transaction badges this PR is migrating. To keep the visual contract
consistent, this PR shifts `DS::Pill`'s badge mode to `rounded-full`
(marker mode stays `rounded-md`, unchanged from #1829). The shape
distinction now reads: markers are tags, badges are pills.

**Callsites migrated** (5):

- `app/views/transactions/_transaction.html.erb` — Pending,
  Review-recommended, Possible-duplicate, Split badges
- `app/views/transactions/_header.html.erb` — Pending badge
- `app/views/transactions/_split_parent_row.html.erb` — Split badge

**Tone mapping**

| Badge | Tone | Notes |
|---|---|---|
| Pending | `:neutral` | unchanged copy/icon, gains subtle DS-controlled bg |
| Review recommended | `:neutral` | matches existing `bg-surface-inset` look |
| Possible duplicate | `:warning` | DS semantic alias for the existing `text-warning` |
| Split | `:neutral` | matches existing `bg-surface-inset` look |

**Deferred to follow-up PRs**

- `app/views/transactions/_transfer_match.html.erb` — uses two
  responsive-visibility variants (`hidden lg:inline-flex` for long
  copy, `inline-flex lg:hidden` for short). DS::Pill currently has no
  `class:` arg for caller-controlled wrapper classes; deferring until
  that lands.
- `app/views/transactions/searches/filters/_badge.html.erb` — has a
  close button alongside the label (`button_to clear_filter_*`) and
  uses `rounded-3xl p-1.5` instead of a true pill. Closer to a
  removable filter chip — better fit for a separate `DS::FilterChip`
  primitive than for `DS::Pill`.

Refs #1751.
2026-05-23 08:46:55 +02:00
Guillem Arias Fauste
814505c5ea fix(settings/debugs): migrate in-cell <details> to DS::Disclosure :inline (#1916)
#1858's :inline variant landed (commit 8de14ed2), unblocking the third
sure-design drift finding on this file (#1895 / #1898).

The :inline variant is the right shape for an in-table-cell metadata
expander — no surface, no padding, no shadow; the summary reads as plain
text-link copy. The bot recommended this exact variant when filing the
issues; previous PR (#1903) covered the two token findings but deferred
the <details> migration until the variant was available.

Closes #1895. Closes #1898.
2026-05-23 08:45:45 +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.
v0.7.1-alpha.10
2026-05-22 02:17:32 +02:00
Guillem Arias Fauste
09058b0cc6 feat(design-system): extend DS::Pill with badge mode + semantic tones (#1751 PR A) (#1902)
* feat(design-system): extend DS::Pill with badge mode + semantic tones (#1751)

Adds two extensions to the existing `DS::Pill` (originally landed as a
stage marker primitive in #1829) so it can also serve as the shared
status / category badge across the app — the use case tracked by #1751.

**Badge mode (`marker: false`)**

The original `DS::Pill` was intentionally sub-12px (text-[10px] /
text-[11px]) + uppercase + tracking-wide so it reads as a marker
(`Beta`, `Canary`, `NEW`), not a label. That shape is wrong for
status badges where the surrounding context is regular UI copy and the
pill needs to feel like a chip (`Pending`, `Active`, `Past due`,
`Failed`).

The new `marker: false` flag drops the uppercase + arbitrary
sub-12px text and snaps the chrome to the DS text scale:

- `marker: false, size: :sm` → `text-xs` (12px), normal case
- `marker: false, size: :md` → `text-sm` (14px), normal case
- `marker: true` (default) → existing #1829 behavior, unchanged

**Semantic tone aliases**

Status badges read more naturally with semantic tone names than with
the underlying palette colors:

| Alias | Resolves to |
|---|---|
| `:success` | `:green` |
| `:warning` | `:amber` |
| `:error` / `:destructive` | `:red` (new tone, added here) |
| `:info` | `:indigo` |
| `:neutral` | `:gray` |

Visual-name tones (`:violet`, `:indigo`, `:fuchsia`, `:amber`,
`:green`, `:gray`, `:red`) still work as before — semantic aliases
resolve through `SEMANTIC_TONE_ALIASES` at component init time, so
the callsite can pick whichever name reads better. Unknown tones
still fall back to `:violet` (existing behavior).

**Red palette**

Adds the `:red` tone (palette already present in
`design/tokens/sure.tokens.json` — `red-50/100/200/500/700/tint-10`).
Needed for `:error` / `:destructive` status badges.

**Icon slot**

Adds an `icon:` option (already documented in the component's
doc-comment as planned). When set, the Lucide glyph replaces the
colored dot inside the pill — useful for status badges that read
better with a glyph (`circle-check`, `triangle-alert`,
`loader`, etc.) than the generic dot.

**Scope**

API + tests + Lookbook preview only. No callsite migrations in this
PR — that's the next slice of #1751, done as separate per-bucket PRs
(transaction badges, provider badges, misc) to keep diffs small.

DS::Pill currently has no in-app callsites (#1829 shipped the
primitive ahead of consumers), so this is a pure-additive change.
Existing API is fully backwards-compatible — `marker:` defaults to
`true`, so without that flag the pill renders exactly as it does
today.

* fix(test): use assert_no_selector for dot-suppression assertion

`refute_selector ..., count: 1` only fails when there are exactly 1
matches — it would silently pass for 0 OR 2+. The intent is "no dots
should render when an icon is set"; `assert_no_selector` strictly
asserts zero matches.

Flagged by coderabbit on #1902.
2026-05-22 02:16:33 +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 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
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
sentry[bot]
1f7970010a fix(destroy): prevent UnknownAttributeError in DestroyJob rescue (#1852)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-05-20 21:19:10 +02:00
Michal Tajchert
e21ab9819f feat(dashboard): zoom into cashflow sankey categories (#1807)
* feat(dashboard): zoom into cashflow sankey categories

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

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

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

* refactor(dashboard): extract cashflow sankey chart partial

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

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

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

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

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

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

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

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

---------

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

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

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

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

* fix(helm): guard extraEnv index against nil

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

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

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

* fix issue - net_category_totals cache is never populated - suggested by coderabbitAI
2026-05-20 20:28:26 +02:00
sentry[bot]
ba87a4f0f3 fix(imports): prevent MissingTemplate for JSON requests in index (#1875)
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-05-20 20:23:03 +02:00