Commit Graph

2836 Commits

Author SHA1 Message Date
ghost
94d2ee908d fix(jobs): enqueue jobs after transaction commit to fix SyncJob deserialization race (#2354)
* fix(jobs): enqueue jobs after transaction commit to fix SyncJob deserialization race

Syncable#sync_later creates a Sync row and enqueues SyncJob inside the
same transaction. Rails 7.2 deferred such enqueues until commit by
default; Rails 8.0 changed the default to enqueue immediately and 8.1
left the global config toggle non-functional, so a worker could dequeue
the job before COMMIT, fail to resolve the Sync GlobalID
(ActiveJob::DeserializationError), and have it silently dropped by
discard_on -- surfacing as stuck syncs after the Rails 8.1 upgrade.

Set enqueue_after_transaction_commit = true on ApplicationJob (the
Rails 8.2 default, inherited by every job) and drop the dead :never
symbol override on DestroyJob. Add regression and invariant tests.

* test(jobs): use OpenStruct for DestroyJob failure case

Replace the bare mock and the respond_to? expectation (an implementation
detail of how DestroyJob probes the model) with an OpenStruct that genuinely
responds to scheduled_for_deletion. Keeps only the command-facing assertions:
destroy raises and update! is called with scheduled_for_deletion: false.
Matches the repo convention of preferring OpenStruct for mock instances.

* test(snaptrade): assert connection-cleanup enqueue defers until commit

SnaptradeAccount#after_destroy enqueues SnaptradeConnectionCleanupJob, which
references the item/account by id. Before the ApplicationJob fix, Rails 8.1
enqueued it immediately inside the destroy transaction, so a worker could run
before COMMIT, see the not-yet-deleted row in its shared-authorization guard,
skip the provider call, and leak the SnapTrade connection. This regression
test destroys an account inside a transaction and asserts the job is not
enqueued until the transaction commits.
2026-06-15 21:43:08 +02:00
Guillem Arias Fauste
c29380ce57 feat(dashboard): masonry packing + per-widget size controls (#2328)
* feat(dashboard): masonry packing + per-widget size controls

In two-column mode the dashboard used a row-based CSS grid, so cards
stretched to equal row height and left dead space (e.g. the Net Worth
chart padded out to match the tall Balance Sheet table). Replace the
row-based layout with masonry packing and add per-widget size guardrails.

- Masonry: CSS grid with computed row-spans + grid-auto-flow: dense, driven
  by a dashboard-masonry Stimulus controller (ResizeObserver + turbo:frame-load).
  The DOM stays a single flat list, so drag/keyboard reorder is unaffected.
  Active only in multi-column mode; single column falls back to normal flow.
- Internal sizing: the net worth chart height is now driven by a
  --dash-widget-h CSS var (fixes an inert flex-1) so the card no longer pads
  out below the chart.
- Guardrails: per-widget layout metadata (col_span, grow, min_height,
  width_toggle) in PagesController, with per-user overrides persisted under
  preferences["dashboard_section_layout"], deep-merged so width and height coexist.
- Size menu: a hover control on size-capable cards — Width (Half/Full) for the
  cashflow sankey and net worth chart, Height (Compact/Auto/Tall) for grow
  widgets. The sankey defaults to full width.

Adds model + controller tests for preference persistence and i18n keys.

* refactor(dashboard): redesign size menu with segmented controls

The size menu used a plain radio list and a diagonal maximize-2 trigger that
collided with the cashflow sankey's modal-expand button. Replace it with a
layout-config popover: a sliders-horizontal trigger plus two labeled axis
groups (Width, Height), each rendered as a DS::SegmentedControl with the
active option filled. Clearer, more compact, and it reads as a card-layout
control. The widget-size controller now mirrors the segmented control's
active-class + aria-pressed contract.

* feat(dashboard): expose width toggle on balance sheet and investments

Tables benefit from horizontal room, so give Balance Sheet and Investments
the same Width (Half/Full) control as the sankey and net worth chart. Height
presets stay chart-only — a table sized to a fixed height would just add
whitespace or force scrolling. The Outflows donut is intentionally left out
(full width is mostly whitespace for a donut).

* fix(dashboard): address review feedback on size controls

- Only apply the full-width col-span and show the Width control when the
  two-column layout is enabled. A full widget previously leaked
  2xl:col-span-2 into the single-column grid, creating an implicit second
  column at 2xl widths and breaking the single-column preference. This also
  keeps the Width control coherent with the Appearance two-column setting
  (it now appears only where it does something). [Codex]
- Stop size-menu keydowns from bubbling to the section reorder handler, so
  keyboard users can open the menu and pick options without entering
  grab/reorder mode. [Codex]
- Harden preferences params: ignore a malformed (non-hash)
  dashboard_section_layout / collapsed_sections instead of raising a 500,
  and require section_order to be an array. [CodeRabbit]
- Localize the dashboard sections aria-label. [CodeRabbit]

* chore(settings): mention per-widget size controls in two-column copy

Surface the new per-widget width/height controls in the Appearance
"Two-column layout" description so the capability is discoverable.

* test(dashboard): assert non-mutation for malformed layout input

Addresses review feedback: asserting assert_nil made the test depend on
the fixture happening to have no dashboard height for net_worth_chart.
Capture the pre-PATCH value and assert it is unchanged, so the test
stays valid (malformed input ignored) even if the fixture later gets a
default height.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-15 20:34:34 +02:00
Sure Admin (bot)
08bfd43775 fix: preserve CoinStats balances when wallet sync data is missing (#2132)
* test: cover CoinStats missing-wallet balance preservation

* fix: preserve CoinStats balances when wallet data is missing

* chore: route CoinStats sync warnings to debug log

* docs: make debug log guidance timeless

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-15 20:32:59 +02:00
Orange🍊
43b12311e6 chore(i18n): complete zh-CN locale coverage (#2283)
* chore(i18n): complete zh-CN locale coverage

Translate all 903 missing zh-CN keys, following the per-module locale
file structure mirroring the en baseline:

- New locale files: goals, goal_pledges, akahu_items (views), and
  goal, goal_pledge, merchant_import (models)
- Fill remaining gaps across imports, settings/hostings, components,
  transfers, transactions, categories, and 30+ other modules
- Fix interpolation mismatches against the en baseline (%{moniker},
  %{product_name}, %{action_verb}, %{count} in pluralized one-keys)
- Quote the "on" key in valuations so it parses as a string key
  instead of a YAML boolean, matching en
- Add zh-CN date formats month_year / short_month_year used by
  reports and budgets

Brand names, protocol names, and product-specific terms (Plaid, QIF,
YNAB, PSD2, HSA, API keys) are intentionally kept in English.

Verification:
- i18n-tasks missing -l zh-CN: 0 (the 4 remaining entries are
  i18n-tasks scanner artifacts also reported for the en baseline)
- i18n-tasks check-consistent-interpolations: clean for zh-CN
- bin/rails test: 4875 runs, 0 failures
- bin/rubocop: no offenses

* chore(i18n): address review feedback on zh-CN terminology

- Unify Lunch Flow branding (two words) and use API 密钥 consistently
  in lunchflow_items
- Use API 密钥 instead of API Key in settings/api_keys create/destroy
  messages, matching the rest of the file
- Use 循环 for recurring transfers, matching the recurring wording
  used by transactions

* fix(i18n): keep zh-CN user family labels literal to avoid moniker interpolation error

Unlike Account, User has no human_attribute_name override injecting
the moniker option, so %{moniker} in user attribute labels raises
I18n::MissingInterpolationArgument whenever errors.full_messages
formats a family/family_id validation error.

Keep the labels literal (家庭), consistent with de/es/fr/nb/nl/pt-BR/
ro/tr/zh-TW which also use literal words here.

Note: en/ca/hu/pl/vi carry the same latent issue with %{moniker} in
user attribute labels; left untouched as fixing the en baseline is
out of scope for this locale PR.

* fix(i18n): 补齐运行时作用域翻译键

* fix(i18n): 移除失效目标切换文案
2026-06-15 20:24:14 +02:00
Guillem Arias Fauste
beec50b4a4 fix(ds): migrate goals color picker back to DS::Disclosure (#2350)
Commit 5d0eb7f4 replaced the color picker's DS::Disclosure with a raw
<details> because DS::Disclosure wraps its body in an mt-2 div, and that
normal-flow margin shoved the form down ~8px whenever the absolutely-
positioned picker popover opened. DS Drift Patrol flagged the raw
<details> in #2272 and #2316.

Add a body_class: option to DS::Disclosure (default mt-2) so callers can
drop the body margin, and migrate the color picker back onto the
component with body_class: nil. The summary's accessible name moves from
aria-label to an sr-only span in the summary content (verified: the
summary still reads as "Choose color and icon").
2026-06-15 20:13:15 +02:00
Guillem Arias Fauste
2d3d974466 fix(ds): canonical separators and destructive tokens in usage/rules tables (#2332)
The LLM usage table (Settings → AI usage) and the rules recent-runs table
used hardcoded color classes instead of design-system tokens:

- `divide-gray-100` separators — a fixed light gray with no dark-theme
  variant, so the row dividers render wrong in dark mode.
- Raw reds for failed rows (`bg-red-50`/`bg-red-950`, `text-red-500/600`).

Swap to the canonical tokens used by every other table (settings/debugs,
admin/users, …):

- divide-gray-100 -> divide-alpha-black-200 theme-dark:divide-alpha-white-200
- bg-red-50 / bg-red-950/30 -> bg-red-tint-5 / bg-red-tint-10
- text-red-* -> text-destructive (via the icon helper's color: param)

Token-only; no structural or behavior change.

Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com>
2026-06-15 20:11:36 +02:00
Sure Admin (bot)
89eb441145 fix(sync): discover nightly provider items reflectively (#2334) 2026-06-15 20:09:39 +02:00
Augusto Xavier
8dd789e641 chore(i18n): remove dead transfers.form.* exchange-rate keys (#2293)
The transfer and transaction forms render the exchange-rate tab UI via
shared.exchange_rate_tabs.* exclusively, so transfers.form.calculate_rate_tab,
convert_tab, exchange_rate, and exchange_rate_help are never looked up. Remove
these four dead keys from every locale file that carries them (en, fr, es, ca,
hu, vi). The live siblings (exchange_rate_display, destination_amount, etc.) are
left intact. No view or code references change.

Fixes #1508
2026-06-15 10:14:18 +02:00
Juan José Mata
51c826ca2c Bump versions by hand 2026-06-15 09:32:09 +03:00
Guillem Arias Fauste
2e384eb833 fix(ds): shrink dialog close button to size sm (#2309)
The dialog close button rendered as a :md icon button (44x44px with a
20px glyph) — noticeably larger than the dialog's own action buttons
(36px tall) and visually heavy next to the title. Pass size: :sm so the
close control is 32x32px with a 16px glyph, matching the action row's
weight. 32px still clears the WCAG 2.5.8 (AA) 24px minimum target.
2026-06-15 08:06:12 +02:00
dependabot[bot]
b0b0dc866d chore(deps): bump css_parser from 1.21.1 to 1.22.0 (#2336)
Bumps [css_parser](https://github.com/premailer/css_parser) from 1.21.1 to 1.22.0.
- [Changelog](https://github.com/premailer/css_parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/premailer/css_parser/compare/v1.21.1...v1.22.0)

---
updated-dependencies:
- dependency-name: css_parser
  dependency-version: 1.22.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 22:19:27 +02:00
dependabot[bot]
5d9367f33a chore(deps): bump yard from 0.9.37 to 0.9.42 (#2335)
Bumps [yard](https://yardoc.org) from 0.9.37 to 0.9.42.

---
updated-dependencies:
- dependency-name: yard
  dependency-version: 0.9.42
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 22:18:52 +02:00
Guillem Arias Fauste
5937195df0 fix(chat): clear assistant bubble on destroy so the chat doesn't hang on Thinking (#2315)
* fix(chat): clear the assistant message bubble when a turn is destroyed

When an assistant turn fails before any text streams (e.g. a provider
auth/model/network error on the first call), Assistant::Builtin#respond_to
destroys the still-pending message. Message only broadcast on create and
update, never on destroy, so the rendered 'Thinking…' bubble was never
removed — the chat appeared stuck thinking forever even though the job
had already errored (and appended an error via chat#add_error below it).

Add after_destroy_commit broadcast_remove_to so a destroyed message is
removed from the page.

* refactor(chat): trim destroy-broadcast comment to one line

Project convention asks for comments only when the why is non-obvious; the
behaviour is already covered by the commit/PR description. Per review feedback.

---------

Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com>
2026-06-14 22:09:02 +02:00
Guillem Arias Fauste
5608f2b3fa fix(settings): give the MCP copy button success feedback (#2314)
* fix(settings): give the MCP copy button success feedback

The MCP server URL Copy button copied to the clipboard but showed no
feedback. It is a DS::Button (single icon), but clipboard_controller's
showSuccess() unconditionally toggled iconDefault/iconSuccess targets —
which that markup does not have — so it threw right after the copy and
the user saw nothing.

Guard the icon-swap path (still used by invite codes, MFA and profiles)
and add a fallback that briefly flips the button's own label to Copied!
via a new copiedText value. Wire it up on the MCP page.

* fix(settings): capture copy button before async clipboard resolve

event.currentTarget is null by the time the writeText().then() callback
runs (it's only valid during event dispatch), so showSuccess received
null and the label never flipped. Capture the button synchronously in
copy() and pass it through. Verified in-browser: Copy -> Copied! -> Copy.

* refactor(clipboard): unify feedback reset delay, harden label lookup

Extract a shared RESET_DELAY_MS so the icon-swap and label-flash paths last the
same duration when both copy buttons render on one page. Scope the label lookup
to span.truncate (the DS::Button text node) so it ignores any future icon span.
Per review feedback.

---------

Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com>
2026-06-14 22:08:13 +02:00
Guillem Arias Fauste
635938ec7b fix(ds): normalize legacy tooltip spacing to one recipe (#2311)
The four inline (non-DS::Tooltip) tooltips had drifted: three used
p-2 rounded w-64, one used p-3 rounded w-72 with shadow-lg, and all used
rounded (4px) where DS::Tooltip uses rounded-md (6px). Unify them on
p-2 rounded-md w-64 — radius now matches DS::Tooltip and the lone
p-3/w-72/shadow-lg outlier is gone, so the dark tooltips read
consistently.
2026-06-14 22:04:11 +02:00
Guillem Arias Fauste
84547f766c fix(dashboard): align sankey zoom-out button with section header (#2313)
The cashflow sankey zoom-out button sat in a bare flex justify-start
row. Because the dashboard section body has no horizontal padding (just
py-4), the button rendered flush against the card's left edge — 16px
left of the section header title and out of line with the rest of the
widget. Add px-4 to the button row so it aligns with the header,
matching the _net_worth_chart widget's px-4 header row.
2026-06-14 22:02:35 +02:00
Guillem Arias Fauste
215864fdd9 fix(dashboard): apply two-column layout at xl, not 2xl (#2310)
The dashboard two-column layout preference only added 2xl:grid-cols-2
(>=1536px), but the setting copy promises two columns on large screens.
On any display narrower than 1536px (most laptops, ~1280-1440px) the
toggle did nothing. Lower the breakpoint to xl (>=1280px) so it engages
on the screens users actually have while keeping widgets wide enough to
stay usable.
2026-06-14 22:01:05 +02:00
Juan José Mata
6ebead4690 Bump version by hand 2026-06-14 22:59:38 +03:00
Guillem Arias Fauste
bc0dcdd41b fix(ds): neutral text for goals status callout (#2312)
The goals status callout colored its entire body (icon, label and
context) with text-warning / text-success / text-secondary, so a
behind goal rendered as all-yellow text. That diverges from the
DS::Alert recipe, where tinted boxes keep neutral body text
(text-primary) and only the icon carries the status color. Drop the
text-* tokens from the container, add text-primary, and move the
warning/success color onto the icon via color:.
2026-06-14 21:50:11 +02:00
ghost
88343002d1 chore(deps): upgrade Rails 7.2 → 8.1 (#2301)
* chore(deps): upgrade Rails 7.2 → 8.1

Rails 7.2 reaches end of life on 2026-08-09. Bump the framework to the
current 8.1.x line.

- Gemfile: rails "~> 8.0" (resolves 8.1.3); bundle update rails pulls the
  Rails 8 framework gems plus the bumps it requires — ViewComponent
  3.23 → 4.x (Rails 8 support), rails-i18n 7 → 8, rswag, and transitive deps.
- app/models/transfer.rb: make Transfer#date nil-safe
  (inflow_transaction&.entry&.date). Rails 8's date_field evaluates the
  field default on a new/unpersisted Transfer (the new-transfer form), where
  the association is nil; without this, TransfersController#new raises
  "undefined method 'entry' for nil". Matches the &. pattern already used in
  Transfer#sync_account_later.

Framework behavioral defaults are unchanged (config.load_defaults stays as-is).

Validated on Rails 8.1.3: zeitwerk:check passes, full suite green
(4904 runs, 0 failures, 0 errors), rubocop and brakeman clean.

* fix(rails8): style textarea + deterministic property edit system test

The Rails 8 gem bump kept config.load_defaults at 7.2, but Rails 8 renamed
two ActionView::Helpers::FormBuilder field helpers regardless of defaults:
:text_area → :textarea and :check_box → :checkbox. StyledFormBuilder builds
its styled helpers from `field_helpers`, so `form.text_area` (e.g. the
account "Notes" field) silently fell through to the unstyled base helper and
rendered without a label — failing 8 system tests with
`Unable to find field "Notes"`.

- app/helpers/styled_form_builder.rb: exclude both spellings of the
  non-text helpers (:check_box and :checkbox) and alias the legacy
  `text_area` to the Rails 8 `textarea` so existing call sites stay styled.
  Harmless on Rails 7.2 (old names present instead).

- test/system/property_test.rb: open the property edit dialog via the
  account menu with a retry. The account page issues a Turbo morph refresh
  shortly after load (turbo_refreshes_with :morph + a family-stream
  broadcast); opening the modal while that refresh is in flight let the
  morph re-render the page and wipe the just-loaded #modal turbo-frame.
  Rails 8 timing made the race deterministic. Retrying once the refresh has
  settled makes the test stable (confirmed via Turbo frame-load vs
  full-page morph event traces; 3x green in isolation).

- config/brakeman.ignore: the added comment block shifted the pre-existing
  (already-ignored, Weak) class_eval Dangerous Eval warning from line 5 -> 10,
  changing its fingerprint. Re-point the existing suppression to the new
  fingerprint/line so scan_ruby stays green.

Validated on Rails 8.1.3: full system suite green
(92 runs, 355 assertions, 0 failures, 0 errors), rubocop clean,
brakeman 0 warnings, CodeRabbit no findings.

* chore(deps): pin rails to the 8.1 minor line (~> 8.1.0)

Tighten the constraint from `~> 8.0` to `~> 8.1.0` (>= 8.1.0, < 8.2) so a
future `bundle update rails` tracks the 8.1.x line rather than silently
jumping to 8.2 when it ships. Matches the upgrade plan's stated intent
(target 8.1.x for the EOL runway) and a review note on #2301.

No resolved-version changes: bundle install keeps rails at 8.1.3 and every
other locked gem unchanged — only the Gemfile.lock DEPENDENCIES constraint
line moves. zeitwerk:check still passes; the already-green unit/system
suites ran on this exact resolved tree.

* chore(rails8): adopt Rails 8.1 framework defaults (config.load_defaults 8.1)

The gem bump above kept config.load_defaults at 7.2 so the change set could be
reasoned about in stages; this finalizes the upgrade by adopting the modern
framework defaults now that the suite is green on Rails 8.1.

Rails 8.0 added no new framework defaults (there is no new_framework_defaults_8_0
template), so 7.2 -> 8.1 is the single meaningful step. No incremental
new_framework_defaults_8_1.rb opt-in file is needed: the full suites pass with all
8.1 defaults enabled at once.

The 8.1 defaults this turns on include action_on_path_relative_redirect=:raise
(open-redirect hardening), raise_on_missing_required_finder_order_columns,
escape_json_responses=false / escape_js_separators_in_json=false (JSON perf), and
Ruby-parser template-dependency tracking.

Validated with no application code changes: bin/rails test 4904/0/0,
bin/rails test:system 92/0/0, rubocop + brakeman clean.

* chore(ci): restore brakeman CheckEOLRails now that the app is on Rails 8.1

config/brakeman.yml existed only to skip brakeman's CheckEOLRails. That check
fires on the calendar (it warns 60 days before a framework's EOL and escalates
as the date nears), so Rails 7.2's 2026-08-09 EOL turned `bin/brakeman` red
(exit 3) on every branch and on main regardless of the diff. The skip carried a
TODO to remove it once Sure upgraded off 7.2.

This PR puts the app on Rails 8.1 (EOL well in the future), so the skip is
obsolete; remove the file (its sole content was the skip) in the same change that
makes it unnecessary -- no stale-config window. brakeman auto-loads the file when
present and falls back to defaults when absent, and nothing references it
explicitly. CheckEOLRuby was already enabled and is unchanged; config/brakeman.ignore
is untouched.

Validated on Rails 8.1: bin/brakeman runs EOLRails + EOLRuby, 0 warnings,
0 errors, exit 0.
2026-06-14 21:48:14 +02:00
ghost
e38632632c feat(mobile): standardize money typography and semantic amount color (#2331)
* feat(mobile): standardize money typography and semantic amount color

Add a brightness-aware SureColors theme extension and a MoneyText/SureMoney
primitive (semantic success/destructive/subdued tokens + tabular figures for
column-aligned digits), then migrate the transaction lists and balance cards
off raw Colors.green/red/grey.

Step 2 of the mobile design-system sequence (#2235), after #2237's theme
foundation. Primitive-first: screens consume shared tokens/typography.

* fix: review feedback — brightness-aware token fallback, de-flake Setting tests, Pipelock localhost FP

- SureColors.of falls back to the palette matching the active brightness (not
  always light) when the extension is missing, so dark surfaces stay correct.
- Clear the rails-settings-cached cache before each test; its in-memory cache
  survives the per-test transaction rollback, leaking Setting.* across tests and
  flaking Settings::HostingsControllerTest (stale empty string vs nil).
  Full unit suite: 4952 runs, 0 failures.
- Suppress the localhost test-DB DATABASE_URL false positive with line-level
  `# pipelock:ignore` in ci.yml + llm-evals.yml instead of excluding whole files,
  so those workflows stay scanned for real secrets.
2026-06-14 21:37:30 +02:00
Guillem Arias Fauste
c701479aee fix(ds): goals — uniform New-goal -> grid gap (mb-3 -> mb-4) (#2288)
The New-goal action row used mb-3 while the search row below it uses mb-4. When
search is hidden, the New-goal row is the last element before the grid, so the
gap-to-grid was mb-3; with search it was mb-4 -- inconsistent depending on
state. Bump to mb-4 so the gap is uniform either way.

(Audit Group-2 spacing: the other candidates did not hold up on inspection --
providers already has a space-y-4 gap, budgets' 'doubled' gap is correct
sequential spacing for 3 elements, dashboard's empty-state gap is unverifiable
without an account-less family. So this is the only real one.)
2026-06-13 18:44:54 +02:00
Guillem Arias Fauste
007f84db1a refactor(settings): debugs page onto settings_section (#2289)
Consolidate the bespoke header card + filter card into one settings_section
(title + subtitle) -- the canonical surface + an h2 (was a second
<h1 font-semibold> below the layout's page-title h1, a heading-level + weight
break). The log table stays an edge-to-edge bg-container card on purpose
(settings_section's p-4 would inset it and float the thead).

Left as follow-ups: the filter inputs' 10x repeated class strings (shared
partial) and the bespoke empty state -> DS::EmptyState (needs #2143 on main).

Page is super-admin-gated (Admin::BaseController), not renderable in the demo;
verified via erb_lint + headless ERB compile + no-stray-markup grep.
2026-06-13 18:37:22 +02:00
Guillem Arias Fauste
a249f9bda0 fix(ds): route mercury/ibkr provider panels onto sibling tokens (#2290)
Two literal-color outliers in app/views/settings/providers/, both fixed by
matching the sibling panels in the same directory:

- _mercury_panel: the per-item initial avatar used bg-blue-600/10 + text-blue-600.
  Every other settings/providers panel renders this generic avatar neutral
  (akahu/brex use bg-surface|bg-container-inset + text-primary). The literal blue
  was also a dark-mode contrast risk. -> bg-surface + text-primary.
- _ibkr_panel: the 'not configured' status dot used a literal bg-gray-400 while
  the sibling brex panel's equivalent dot uses the bg-surface-inset token (paired
  with bg-success for the configured state). -> bg-surface-inset.

Token-only swaps, theme-safe, no layout change.
2026-06-13 18:36:09 +02:00
Juan José Mata
a9a855d794 Fix Railway deployment link in README
Updated the Railway deployment link in the README.

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-13 18:09:41 +02:00
thebandit
64b4c0fee4 Add support for dividend, deposit, withdrawal, and interest trade types to Trades API (#1761)
* Update trades api with support for additional types

* rubocop fixes

* fix missing amount validation for interest type

* define missing schema reference

* fix test api_headers to use display_key per guidelines

* expand test coverage

* replaced duplicate JSON response blocks with helper method

* Add DB assertions to linked transfer test and fix invalid date test

* update brakeman.ignore fingerpint for refactored code

* Update the Brakeman ignore note to document validation for newly permitted keys

* fix API key auth in Minitest test to follow correct pattern

* update required in trades rswag spec to match the minimum fields that apply to all types

* extract dividend handling from build_investment_trade_params to dedicated method

* adjust response format to use the existing jbuilder views for Transfers and Transactions

* normalize type before passing to create form

* validate amount as a positive numeric value + tests

* rubocop fixes

* Add missing Trades API test coverage and docs

- Add Minitest tests for withdrawal (422, transfer linking), interest
  (explicit ticker), and dividend update
- Add rswag 401/403/404/422 response docs for create, update, destroy
- Regenerate docs/api/openapi.yaml

* Update Security.find line reference in brakeman.ignore note

* Mark TransactionResponse account_type as nullable in rswag docs
2026-06-13 11:55:16 +02:00
ghost
f7c633ef20 fix(preview): bind :3000 instantly and bound diagnostics posts (#2286)
* fix(preview): bind :3000 instantly and bound diagnostics posts

The trusted preview deploy chain now succeeds end to end (after #2124,
#2207, #2217), but the preview container itself dies on Cloudflare with
"Container crashed while checking for ports" and no entrypoint
diagnostics ever arrive (run 27186150190).

Two compounding causes, both reproduced/measured locally against the
pinned @cloudflare/containers 0.3.3 behavior:

1. The port window is unwinnable. The library waits a hardcoded ~20s for
   the container port, but the entrypoint only binds :3000 after redis,
   postgres, and rails db:prepare complete -- measured at 69s under a
   basic instance's 1/4 vCPU. Fix: bind :3000 within ~1s via a tiny Ruby
   placeholder responder (static 503 + meta-refresh, input ignored),
   verified with a TCP connect poll, and released just before the real
   server starts. The worker still gates previewReady on the real Rails
   /up probe and sample data, so readiness semantics are unchanged.

2. Diagnostics could stall boot and never deliver. emit_status used curl
   with no timeout against the worker, whose Durable Object can be
   unresponsive while it waits for this container's port (observed as
   15s status timeouts in the failed run) -- so boot could deadlock
   against the port check, and failure detail (#2217) never reached the
   diagnostics artifact. Fix: --connect-timeout 2 --max-time 5 on all
   posts, progress events fire-and-forget in the background, and failure
   paths flush synchronously before exit.

Validated locally at Cloudflare basic limits (1 GiB / 0.25 vCPU): first
:3000 response in 1s, full boot to Rails /up=200 at ~76s with no OOM,
and a forced postgres failure exits 1 in 4s with the failure event
flushed. With the port window satisfied, the next real failure will
finally surface postgres detail in _container_status and CI artifacts.

* fix(preview): never read from placeholder clients

Superagent flagged on PR #2286 that the single-threaded :3000 placeholder
blocked on client.readpartial, so one connection that sends nothing -- such
as a bare TCP port probe, which is exactly what the Cloudflare port check
performs -- would wedge the accept loop and starve every later probe.

The response is static, so drop the read entirely: each connection is
written the 503 warming page and closed immediately, making the loop
effectively non-blocking per client.

Regression-tested by holding three idle TCP connections open while HTTP
probes still answered 503 immediately; full boot under basic-instance
limits (1 GiB / 0.25 vCPU) still reaches Rails /up=200 with the placeholder
answering at 1s.

* fix(preview): stop postgres crashing on Cloudflare's small /dev/shm

This is the actual root cause of the preview container dying on Cloudflare
with "Container crashed while checking for ports" (run 27341808838) and the
maintainer's "dies immediately after postgres-start" (#2217).

Reproduced locally: running the preview image with a tiny /dev/shm and a
WRITABLE root (the realistic Cloudflare model) crashes postgres on startup
with:

  FATAL: could not resize shared memory segment "/PostgreSQL..." to
         1048576 bytes: No space left on device

PostgreSQL 17 defaults to dynamic_shared_memory_type = posix, which
allocates dynamic shared memory in /dev/shm. Cloudflare Containers provide
only a tiny /dev/shm, so postgres FATALs before the entrypoint can bind a
port, and the container exits -> the supervisor reports it crashed during
the port check. Local Docker hid this because its default /dev/shm is 64MB.
Memory was ruled out (boots fine at -m 512m); it is specifically /dev/shm.

Fix: set dynamic_shared_memory_type = mmap so DSM is file-backed in the
data directory instead of /dev/shm. The build comments out the default
posix line, appends mmap, verifies the result, and fails hard if
postgresql.conf is missing so this critical setting cannot silently regress.

Also log fail_preview reasons to stderr so the real failure is captured by
Cloudflare container observability even when the HTTP diagnostics channel is
blocked by the worker's port wait.

Verified at Cloudflare basic-equivalent limits (1 GiB / 0.25 vCPU,
/dev/shm 64k): before, exit 1 right after "Starting PostgreSQL..."; after,
"PostgreSQL is ready" -> Rails /up=200 (~97s), placeholder answering :3000
within 1s. Normal-resource boot still reaches /up=200 in ~9s.

* fix(preview): use standard-1 instance and widen CI readiness poll

Two changes the preview needs to actually deploy and be reported ready on
Cloudflare, both validated by deploying to a real CF account.

- instance_type basic -> standard-1. The container runs postgres + redis +
  puma AND generates the full demo dataset (Demo::Generator, ~12 years of
  transactions), which peaks just over basic's 1 GiB and OOM-kills the
  container (exit 137) before demo-data-ready. standard-1 (1/2 vCPU, 4 GiB)
  completes it. Measured on real Cloudflare: rails ready ~46s, demo data
  ~149s, peak well under 4 GiB, no OOM.

- "Collect preview diagnostics" poll budget 40 -> 100 (~128s -> ~350s), with
  the matching guard in bin/preview_deploy_security_check.rb. A real CF
  standard-1 run reached previewReady at ~195s, so the old 40-poll budget
  would have failed a working preview before it finished warming up. The
  loop still breaks early on previewReady/previewFailed.

* fix(preview): apply DSM override to the cluster the entrypoint starts

The build selected postgresql.conf via `find ... | head -1`, which picks an
arbitrary cluster, while the entrypoint starts the highest-version cluster
(ls /etc/postgresql | sort -V | tail -1). Today only PG17 is installed so they
coincide, but if a second major version were ever present the override could
land on a cluster that never runs, silently reintroducing the /dev/shm crash.

Derive PG_CONF from the same highest-version cluster the entrypoint starts so
the dynamic_shared_memory_type=mmap override always applies to the active
cluster. Verified: build edits /etc/postgresql/17/main/postgresql.conf, and at
runtime under a tiny /dev/shm postgres starts with SHOW
dynamic_shared_memory_type = mmap.

* fix(preview): apply pg_hba trust rules to the cluster the entrypoint starts

Same latent issue as the dynamic_shared_memory_type override: the build wrote
the local `trust` rules to `find ... | head -1` (arbitrary cluster), while the
entrypoint starts the highest-version cluster. With a single PG17 they coincide,
but a second major version would send the trust rules to a cluster that never
runs, breaking the entrypoint's trust-auth db setup.

Derive PG_HBA from the same highest-version cluster (ls /etc/postgresql |
sort -V | tail -1) and fail the build if it's missing. Verified under a tiny
/dev/shm: postgres starts and CREATE ROLE/CREATE DATABASE succeed via trust.
2026-06-12 10:40:35 +02:00
Will Wilson
d908560ed9 feat(cashflow): deep-link category labels to filtered transactions (#2083)
* feat(cashflow): deep-link category labels to filtered transactions

Clicking a category's text label in the dashboard cashflow Sankey chart
now navigates to the transactions page filtered by that category and the
cashflow's active period date range. The colored node bar keeps its
existing zoom-into-subcategories behavior; structural nodes (Cash Flow,
Surplus) do not navigate.

URL-building lives in a pure, unit-tested utils/transactions_filter_url
module (mirroring utils/sankey_zoom) pinned in the importmap. Period dates
are threaded from the dashboard view into the Stimulus controller via data
values. This matches the existing donut chart's click-to-filter behavior.

* Update app/javascript/controllers/sankey_chart_controller.js

Co-authored-by: Guillem Arias Fauste <gariasf@proton.me>
Signed-off-by: Will Wilson <will@willwilson.uk>

---------

Signed-off-by: Will Wilson <will@willwilson.uk>
Co-authored-by: Guillem Arias Fauste <gariasf@proton.me>
2026-06-11 21:21:56 +02:00
BeltaKoda
749d54dd96 Fix Plaid sync failure for loan subtypes missing from Loan::SUBTYPES (#2298)
PlaidAccount::TypeMappable maps the Plaid loan subtypes "home equity",
"line of credit", and "business" to home_equity, line_of_credit, and
business — but Loan::SUBTYPES never defined them. Linking any such
account (e.g. a HELOC reported by the institution as loan/"line of
credit") makes the item's sync fail with:

    Validation failed: Accountable subtype is not included in the list

and, because Link itself succeeded, the failure is silent in the UI
(same UX gap as #1792).

Add the three subtypes to Loan::SUBTYPES, and add a regression test
asserting every subtype emitted by TYPE_MAPPING is valid for its
accountable so the mapper and models can't drift apart again.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:26:40 +02:00
Sure Admin (bot)
65efcbab59 fix(mobile): regenerate Sure token destructive color (#2296) 2026-06-11 16:57:00 +02:00
dripsmvcp
1157ea8f20 fix(sharing): scope import account selects to accessible_by (#1803) (#2194)
* fix(sharing): scope import account selects to accessible_by (#1803)

Three CSV / QIF account selects in import/uploads/show.html.erb and one
PDF-import account select in imports/_pdf_import.html.erb pulled their
options from `@import.family.accounts`. That listed every account in
the family — including the family admin's unshared personal accounts —
in the dropdown shown to any member running an import. Swap each call
site to `Current.user.accessible_accounts` (owned + explicitly shared
accounts only), matching the existing scoping used by the dashboard
sidebar, transactions controller, transfers controller, etc.

Adds a regression test that signs in as family_member and asserts the
unshared-account names from the dylan_family fixtures never appear in
the rendered upload page.

* test(import): scope leak assertions to account select (#2194 CodeRabbit)

CodeRabbit nitpick: assert_match on response.body could pass/fail on
text outside the account dropdown (sidebar, breadcrumb, error message,
etc.) and gave false confidence in the refute_match exclusions. Switch
to assert_select 'select[name="import[account_id]"] option', text: …
so the assertions only see the option nodes the leak test actually
cares about.

* test(import): cover PDF account-select scoping; pluck PDF partial (#2194 review)

jjmata: the _pdf_import.html.erb scoping change was not covered by the
existing test (which only hit /import/uploads). Add a regression test
hitting GET /imports/:id with a PdfImport fixture, asserting the
account dropdown options match accessible accounts only.

Also swap the PDF partial's accounts.map { |a| [a.name, a.id] } for
.pluck(:name, :id) to match the .pluck pattern the other three CSV/QIF
selects already use.

* test(import): stub pdf_uploaded? on PDF leak test (#2194 ci)

The new regression test hit ImportsController#show which redirects to
the upload page when @import.pdf_uploaded? is false. The pdf_with_rows
fixture has neither a pdf_file attached nor a statement, so the
redirect fired before the partial under test ever rendered, failing
with 302 in CI. Stub PdfImport#pdf_uploaded? to true so the test
exercises the account-select scoping path it was written to cover.

* fix(import): scope PDF form to :import so field names match (#2194 ci)

The PDF-import account-select form was `form_with model: import` with
no explicit scope. Because the model is a PdfImport, Rails derived the
param namespace from the class name, so the rendered field was named
`pdf_import[account_id]` — not `import[account_id]`. The
ImportsController#update action accepts either via
`params.dig(:pdf_import, :account_id) || params.dig(:import,
:account_id)` so live submissions still worked, but the regression
test added in 0685bbdf asserted on `select[name="import[account_id]"]`
and matched zero options.

Add `scope: :import` to align the rendered name with both the test
selector and the convention used by the CSV/QIF forms on the upload
page (which all use `scope: :import`).
2026-06-11 16:56:35 +02:00
Rene Arredondo
0e1b3f8396 fix(enable-banking): tolerate any 422 on PDNG fetch (#1805) (#1889)
* fix(enable-banking): tolerate any 422 on PDNG fetch (#1805)

* fix(enable-banking): fall back to string key when reading PDNG error
2026-06-11 16:48:40 +02:00
Will Wilson
2075c8c41c feat(mcp): OAuth 2.1 auth for MCP — connect Claude.ai with your Sure login (#2234)
* feat(mcp): add OAuth well-known discovery endpoints (RFC 8414 + RFC 9728)

Serves /.well-known/oauth-protected-resource (RFC 9728) and
/.well-known/oauth-authorization-server (RFC 8414) so MCP clients
can auto-discover the authorization server. Both endpoints are
unauthenticated and respect APP_URL for reverse-proxy deployments.

* feat(mcp): add dynamic client registration endpoint (RFC 7591)

POST /register creates a public Doorkeeper::Application on demand so
MCP clients (e.g. Claude.ai) can self-register without manual setup.
Validates redirect_uris (including blank entries), falls back to
"MCP Client" name, returns no client_secret (public client, PKCE only).
Rate-limited to 10 registrations/min/IP via Rack::Attack.

* feat(mcp): authenticate via Doorkeeper OAuth2, keep MCP_API_TOKEN as fallback

MCP endpoint now accepts OAuth2 Bearer tokens issued by Doorkeeper.
Falls back to the existing MCP_API_TOKEN env-var flow so self-hosted
deployments are not broken. Requires MCP_OAUTH_ENABLED or MCP_API_TOKEN
to be set — the endpoint returns 503 otherwise.

- OauthBase concern provides APP_URL-aware configured_base_url (trailing
  slash stripped to prevent double-slash URLs)
- Bearer scheme parsed case-insensitively (RFC 7235)
- Only read_write scope accepted — read scope would allow mutating tools
  (CreateGoal, ImportBankStatement), so read-only tokens are rejected
- Deactivated users rejected even with a valid Doorkeeper token
- WWW-Authenticate header on 401 points to RFC 9728 resource metadata
- SHA-256 digest used for constant-time env-var comparison
- Rack::Attack throttle added for POST /register
- Routes wired: /.well-known/*, /register, use_doorkeeper

* fix(mcp): disable Turbo on OAuth consent form for external redirect URIs

Turbo was intercepting the authorization form POST and XHR-fetching
the redirect_uri (e.g. https://claude.ai/api/mcp/auth_callback),
which CORS blocks. Extend the existing turbo_disabled guard to cover
any redirect_uri that doesn't originate from the app itself.

* feat(mcp): add Settings::McpController with connected clients view

- Settings > MCP page (under Advanced) shows the MCP server URL with
  copy button and step-by-step instructions for connecting Claude.ai
- Lists active non-mobile OAuth tokens with app name and revoke action;
  mobile device tokens are excluded to prevent accidental disconnection
- Removes the MCP_OAUTH_ENABLED env-var gate — OAuth auth is always
  available since Doorkeeper handles consent; MCP_API_TOKEN remains
  as a self-hosted fallback

* fix(mcp): remove client_credentials from grant_types_supported metadata

Only authorization_code is supported by the registration endpoint.
Advertising client_credentials was misleading — a client that reads
the metadata and attempts that flow would get an application with the
wrong grant type.
2026-06-11 16:19:58 +02:00
ghost
360989c3a9 feat(mobile): align theme foundation with Sure tokens (#2237)
* feat(mobile): align theme foundation with Sure tokens

Generate typed Flutter token constants from the canonical Sure token JSON and route the app through shared light/dark ThemeData construction. This keeps the first mobile design-system step scoped to foundation wiring and regression tests without restyling screens directly.

* fix(mobile): define Sure error container colors

Populate the manual Flutter ColorScheme error container pair from Sure tokens so validation and connection banners keep visible icon/text contrast. Add theme assertions for both light and dark modes.

* fix(mobile): preserve full-width action buttons
2026-06-11 16:19:15 +02:00
Kelvinchen03
59edcaa986 fix: EODHD lookup for EU mutual funds with EUFUND exchange code (#2212) 2026-06-11 16:17:17 +02:00
Guillem Arias Fauste
77dda53ffb feat(ds): one canonical focus ring across primitives (#2140)
* feat(ds): one canonical focus ring across primitives (#2136)

Replaces the grab-bag of per-primitive focus indicators (neutral
ring-alpha-black/white, outline-gray-900/white, faint form-field ring-4)
with a single recipe — the #1737 accessibility follow-up.

- New --color-focus-ring token: blue-600 (light) / blue-500 (dark),
  >=4:1 against both surfaces.
- Canonical .focus-ring / .focus-ring-within in components.css: a 2px
  outline + 2px offset on :focus-visible only. Outline (not a box-shadow
  ring) so the offset gap is transparent on any surface with no layout
  shift; :focus-visible so it never shows for mouse/touch.
- Applied to every focusable DS primitive: Button (had none), Link,
  Disclosure summary, Tabs nav, MenuItem (replaces the browser-default
  box), SearchInput, Tooltip trigger, Popover trigger, Select panel
  (focus-within), Toggle (peer-driven outline-focus-ring). .form-field
  adopts it via :focus-within, replacing the ~1:1 ring-4.
- Dialog close button is a DS::Button icon variant, so it inherits the
  focus-visible-only ring and keeps no resting border (fixes "stuck ring").

Verified in-browser, light+dark: focus-visible ring on button, input, and
full-width menu row — consistent blue 2px+offset, legible on both surfaces.

Remaining follow-up: >=44px touch targets (disclosure trigger, composer
send); bespoke notification / account-new close buttons that still carry a
permanent border.

* fix(ds): #2136 interactive-state follow-ups — touch target + close-button chrome

- Disclosure default trigger: add min-h-11 (44px) so the standalone disclosure
  summary clears the touch-target minimum (was px-3 py-2 ~36px). Composer send +
  the coming-soon icons are already DS::Button icon/md (w-11 h-11).
- Notification close buttons (sync_toast, notice): drop the resting
  border-alpha-black-50 box ("frame shouts, glyph muted"); keep a bg-container +
  shadow-xs chip so the corner control stays visible over the page, and brighten
  the muted glyph on hover (text-subdued -> hover:text-primary).

* refactor(ds): focus ring -> neutral hugging box-shadow (was blue outline)

Per design feedback: the blue 2px outline + 2px offset read as a loud,
detached frame on the otherwise-neutral UI. Switch the canonical .focus-ring
to a soft box-shadow ring that hugs the control (follows border-radius, no
gap), in the theme-aware neutral focus-ring token (alpha-black/white-400).
Transparent outline kept as a forced-colors fallback; toggle peer-driver
switched from outline-* to ring-* to match. Still one token, :focus-visible
only. Strength is tunable (currently subtle ~1.5:1).

* fix(ds): focus ring vanished on shadowed controls — outline, not box-shadow

The neutral box-shadow ring lived in the components layer, so any utility-layer
shadow-* (or .form-field's focus-within:shadow-none) on the same element
overrode it and the ring silently disappeared on shadowed buttons/inputs. Draw
the same subtle neutral ring with a hugging `outline` (outline-offset: 0)
instead — a separate property with no box-shadow conflict, and it doubles as the
forced-colors indicator. Toggle peer-driver switched ring-* -> outline-* to
match. Look is unchanged (neutral, hugging, subtle); it just no longer vanishes.

* fix(a11y): enlarge sync-toast close-button touch target (p-0.5 -> p-1.5)

The hover-revealed close button had ~2px padding around a 20px icon (~24px
total), at the WCAG 2.5.8 AAA boundary. p-1.5 brings the interactive area to
~32px. Addresses CodeRabbit review on #2140.

* fix(ds): keep form-field's resting halo; stop the outline color flash

Two testing findings:

- .form-field reverts to its original always-on soft ring
  (focus-within ring-4 at low alpha, theme-aware) instead of adopting
  the keyboard-only outline. It's a resting decoration, not a focus
  indicator, and the lower-opacity halo was the better look. The
  canonical block's comment documents the deliberate opt-out.

- .focus-ring/.focus-ring-within now carry a base transparent 2px
  outline so consumers with transition-all (form-field had it) animate
  transparent -> token on focus instead of passing through
  currentColor, which flashed as a black border appearing and then
  fading out.

* feat(ds): focus-ring token clears WCAG 3:1 non-text contrast

alpha-black-400 (20%) measured ~1.6:1 against white — visible but below
the AA bar for focus indicators. Bump to the 700 stop (50%): ~3.95:1 on
light containers, ~4.6:1 on dark. Recipe unchanged; one token edit via
tokens:build.

* fix(ds): ring the hand-rolled privacy toggle too

The header pair showed two different focus treatments: panel-right (a
DS::Button) got the new token ring while the hand-rolled privacy
toggle next to it fell back to the browser-default ring — the sweep
covered DS primitives but not bespoke buttons. Both privacy toggles
(mobile + desktop) now carry .focus-ring.

Also documents the transition interplay on the focused-state rule:
consumers with transition-colors fade the ring in over 150ms because
Tailwind v4's color transition list includes outline-color. Verified
settled value at the intended 50% alpha via Playwright.

* fix(ds): ring the sidebar and settings nav links

The reshoot caught both nav species falling back to the browser's blue
default ring — main sidebar items and settings nav items are bespoke
link_to markup the primitive sweep missed, and they're the primary
keyboard path in the app. Both adopt .focus-ring (main nav adds
rounded-lg so the outline follows a shape).

* fix(ds): retire the legacy base-layer button ring for the canonical outline

The @layer base button rule still painted a ring-2 ring-offset-2
box-shadow on :focus-visible. Box-shadow and outline are independent
properties, so .focus-ring (an outline) could never clear it and every
button-tag primitive double-painted both indicators on keyboard focus.

Apply the canonical recipe to the base button rule itself: every
<button> now gets the transparent resting outline + focus-ring token on
:focus-visible by default.

Two bespoke buttons suppressed the outline with focus:outline-none and
relied on the base ring for their keyboard indicator (category dropdown
rows, the sign-up password toggle). Drop the suppression so they pick
up the canonical outline — :focus-visible keeps it keyboard-only, which
is what the suppression was protecting against anyway.

* fix(ds): segmented control adopts the canonical focus recipe

The segment rule inlined its own focus-visible outline (offset 2,
alpha-400 colors) with a comment noting it was temporary until the
canonical token landed — this branch is that token. Drop the inlined
utilities: button segments get the outline from the base button rule,
and link segments now carry .focus-ring.
2026-06-11 16:05:13 +02:00
Guillem Arias Fauste
5f391f7fff fix(settings): preserve content scroll position per page across navigation (#2277)
Settings nav items are plain Turbo Drive links (full-body visits); Turbo only
restores window scroll, so the nested overflow-y-auto content container snapped
to the top on every settings navigation.

Add a settings-scroll Stimulus controller that saves/restores the content scroll
keyed by pathname: returning to a page restores its scroll, a new page opens at
the top, and a same-page re-render (settings form auto-submit) keeps scroll.
Distinct from the nav's preserve-scroll controller, which keys by element id to
intentionally carry one position across pages.

Verified live: scroll 250 on Preferences -> Appearance opens at top -> back to
Preferences restores 250.
2026-06-11 15:52:07 +02:00
Guillem Arias Fauste
74d0452a2b fix(ds): sync-settings env notice used undefined warning-* classes -> DS::Alert (#2278)
settings/hostings/_sync_settings.html.erb rendered the 'configured via env'
notice with bg-warning-50 / border-warning-200 / text-warning-600 / text-warning-800
-- none of which exist as Tailwind utilities, so the box rendered fully unstyled
(no tint, no border, default text color). Replace the hand-built box with the
canonical DS::Alert(:warning), matching the warning-surface recipe and the
ds-notice-neutral-text convention.
2026-06-11 15:50:38 +02:00
Guillem Arias Fauste
8736c7c27e refactor(settings): consistency pass — header-less settings_section + guides (#2279)
* refactor(settings): header-less settings_section variant + migrate guides

Add a title:nil branch to settings_section so a page can route through the one
canonical surface recipe (bg-container shadow-border-xs rounded-xl p-4 space-y-4)
without a section header. No visual change to existing titled callers.

Migrate the guides page onto it: replace the hand-rolled card + hardcoded
'Guides' page title with settings_section + t('.page_title') (new locale key).
First step of the settings design-consistency pass.

* refactor(family-exports): un-nest exports list

The exports list rendered 'Exports' three times (page h1 + settings_section
title + inset count header) inside three stacked surfaces (section card >
bg-container-inset > inner bg-container table card). Flatten it:
- settings_section header-less (drop the duplicate title; the page h1 + the
  inset count header already label it),
- drop the redundant inner space-y-4 wrapper,
- table sits directly in the inset (remove the inner bg-container card).

Now: one title, one card, one inset.

* refactor(settings): payments + appearances consistency

- payments: subscription summary row was bg-container inside the section's
  bg-container (container-on-container) -> bg-container-inset + p-4.
- appearances: toggle-row labels used <h4> (heading-level break inside a
  settings_section) -> <p font-medium>.

* refactor(settings): preferences consistency

- month_start_day warning: text-warning bg-warning/10 colored-body-text box
  -> DS::Alert(:warning) (neutral-text recipe).
- preview-features block: hand-rolled <section bg-container shadow-border-xs
  rounded-xl p-4> -> header-less settings_section (canonical surface).
- toggle-row <h4> -> <p font-medium>; text-[11px] base-currency badge -> text-xs.

* refactor(settings): profiles consistency

- unconfirmed-email notice was hardcoded English -> i18n
  (unconfirmed_email_notice_html + resend_confirmation_link keys).
- role + pending chips: bespoke 'rounded-md bg-surface px-1.5 py-0.5 uppercase'
  pills -> DS::Pill (tone: gray, badge mode).
- pending-invitation row border: border-alpha-black-25 -> shadow-border-xs
  (match the member-row token).
2026-06-11 15:48:53 +02:00
Guillem Arias Fauste
d29591cff9 fix(ds): unify tab/chip controls on DS::SegmentedControl (#8) (#2284)
* fix(ds): goals status filter -> DS::SegmentedControl

The goals status chips were a hand-rolled segmented control: active state
toggled via ad-hoc bg-container/shadow-border-xs/text-* classes, NO hover on
inactive chips, and a light-only focus ring (ring-alpha-black-100, invisible in
dark mode). Migrate to DS::SegmentedControl button segments + toggle the
canonical --active class in goals_filter_controller#syncChipState. Gains the
dark-safe hover (hover:bg-gray-200 / theme-dark:hover:bg-gray-800) and the
canonical focus ring. First control in the tab-consistency pass (#8).

* fix(ds): provider filter chips -> DS::SegmentedControl

Same hand-rolled segmented control as the goals chips: active toggled via
ad-hoc bg-container/shadow/text classes, no inactive hover, light-only
ring-alpha-black-100 focus ring. Migrate to DS::SegmentedControl + toggle the
canonical --active class in providers_filter_controller#syncChipState.

* fix(ds): transaction-type tabs -> DS::SegmentedControl

Expense/Income/Transfer tabs were hand-rolled with a hover==active affordance
bug: inactive hover raised bg to bg-container, identical to the active state, so
hover and selected were indistinguishable. Migrate to DS::SegmentedControl (link
segments + icons); the controller toggles the canonical --active class instead
of swapping ad-hoc ACTIVE/INACTIVE class lists. The client-side nature switch
(expense<->income updates the form's hidden nature field without navigating) is
preserved -- verified live: switching to Income flips the active segment AND
sets the nature field to inflow.

* fix(ds): reports period tabs -> DS::SegmentedControl

The Monthly/Quarterly/YTD/Last-6-Months/Custom period selector was five
DS::Link ghost/secondary buttons -- a different tab idiom than the rest of the
app. Migrate to DS::SegmentedControl link segments (server-rendered active via
aria-current; pure navigation, no controller). Now matches the goals / provider /
transaction-type controls + the AI provider picker. Also autocorrected a
pre-existing single-quote in the next-period aria-label.
2026-06-11 15:47:00 +02:00
Guillem Arias Fauste
2defccf366 fix(ds): dark-mode hover — alpha-black-25 -> surface-hover on date-nav triggers (#2287)
The budget/report period navigation triggers (the prev/next chevrons + the
'Month v' popover button) used hover:bg-alpha-black-25 -- a 3% black overlay
that is invisible on dark surfaces, so they had no visible hover affordance in
dark mode. Swap to the theme-aware hover:bg-surface-hover token (gray-100 in
light, gray-800 in dark) already used by the settings nav. 4 occurrences across
budgets/_picker, budgets/_budget_header, reports/index. (Also autocorrected a
pre-existing single-quote in reports/index.)
2026-06-11 15:45:48 +02:00
Guillem Arias Fauste
922b8853a9 fix(ds): add-account menu affordance — right-size close, clickable rows (#2276)
The account picker ("What would you like to add?" / "How do you want to add
it?") had affordance gaps:

- The close (x) and back (arrow-left) buttons were size: lg, oversized vs every
  other modal close. Drop to size: md.
- The type/method rows had a hover background but no rest-state cue that they
  navigate. Add a trailing chevron (subdued, brightens on hover) so they read as
  actionable, and move the method-selector rows onto the theme-aware
  hover:bg-surface-hover token (were hover:bg-surface).
2026-06-11 15:43:49 +02:00
Guillem Arias Fauste
7ebe5154cb fix(ds): balance-sheet weight column alignment + category pill padding (#2275)
- Dashboard balance-sheet weight cell wrapped its percentage ("64 %" -> two
  lines) for 2-digit values: the account-row cell is w-14 (56px), too narrow
  for the 5-bar gauge plus a spaced locale percentage (ca/es format "NN %").
  Widen the account-row weight cell to w-20 to match the header/group columns
  (also fixing the column misalignment) and add whitespace-nowrap to the value.
- Bump DS::Pill :md vertical padding py-0.5 -> py-1 so category pills (and the
  other :md pills) are not vertically cramped.
2026-06-11 15:32:07 +02:00
Guillem Arias Fauste
b2b89437b0 fix(ds): route remaining literal yellow warning surfaces onto --color-warning (#2250)
* fix(ds): route remaining literal yellow warning surfaces onto --color-warning

Completes the #2198 warning consolidation for view-level surfaces:

- rules/index recent-runs status badges → DS::Pill (warning / success / error).
- accounts/_account_sidebar_tabs missing-data notice: bg-yellow-tint-10 /
  text-yellow-600 → bg-warning/10 / text-warning.
- import/confirms/_mappings unassigned-account notice → bg-warning/10 /
  border-warning/20 (also fixes a missing dark variant — it was light-yellow
  in dark mode).
- simplefin/_replacement_prompt card → bg-warning/10 / border-warning/20,
  dropping the now-redundant theme-dark: companions (the token is theme-aware).

rules' blue/purple execution-type badges and the red failed-row highlight are
left as-is (not warning surfaces).

Part of #2198.

* fix(ds): neutral text in sidebar missing-data notice (match DS::Alert recipe)

Warning surfaces follow the DS::Alert recipe — warning tint + warning-colored
icon, but neutral body text (text-primary / text-secondary). The sidebar
missing-data notice was the lone holdout still painting its text (and link)
with text-warning. Switch to neutral text; keep the bg-warning/10 tint and the
warning-colored triangle/chevron as the accent. More readable (color-on-tint
text is low-contrast) and consistent with the DS::Alert migrations.

* fix(ds): missing-data sidebar notice → static DS::Alert (drop disclosure)

The notice was a raw collapsible <details> styled as a warning — a hybrid
that hid its own primary action (the "Configure providers" link) and its
explanation behind a chevron click. A warning's job is to surface a problem
and its fix; a disclosure's job is to hide secondary detail. The two fought
each other (triangle-alert "act on this" vs chevron "optional, expand").

Replace with a static DS::Alert(:warning): icon + title + body + the
Configure link, always visible. Fixes the affordance, surfaces the action,
canonicalizes the last warning-styled raw <details>, and matches the other
DS::Alert notices.

* fix(ds): grey body text in missing-data alert for title/body hierarchy

DS::Alert renders its body in text-primary (same dark as the title). For
this notice, drop the description to text-secondary so the title (primary,
semibold) reads above the supporting body (grey) — clearer hierarchy. Still
neutral (no colored text); the Configure link stays primary + underline so
the action remains the prominent element.

* fix(ds): bump account_sidebar_tabs cache version v1→v2

Flush stale <details>-markup fragments on deploy. The sidebar
missing-data notice migrated from <details> to a static DS::Alert,
but the fragment is cached with a 12h TTL — without a key change,
old markup renders until expiry (and inconsistently across
staggered multi-server cache warmups). Bumping the version string
changes the cache-key namespace so every cached fragment is
bypassed immediately.
2026-06-11 15:30:08 +02:00
Guillem Arias Fauste
ab3e7e98c3 fix(ds): convert_to_trade price warning — fix dead dark:, use warning token (#2249)
The price-mismatch warning used bare `dark:` variants, but the app's dark
mode is the `theme-dark` custom variant — so those styles never applied and
the box stayed light-yellow on dark surfaces. Replace the literal yellow
palette + dead `dark:` classes with the theme-aware `--color-warning` token
(`bg-warning/10`, `border-warning/20`), the warning-colored alert-triangle,
and neutral primary/secondary text (matching the DS::Alert recipe). One class
now adapts to both themes; the JS targets (priceWarning / priceWarningMessage)
are unchanged.

Closes #2248. Part of #2198.
2026-06-11 15:27:11 +02:00
Guillem Arias Fauste
211e407456 fix(ds): migrate remaining amber notice boxes to DS::Alert(:warning) (#2247)
Continues the #2198 warning consolidation (after the SSO surfaces).
Replaces the last hand-rolled bg-amber-50 notice boxes with DS::Alert:

- pages/redis_configuration_error: "why Redis is required" callout →
  DS::Alert(title:, message:, variant: :warning).
- family_exports/new: export "Note" callout → DS::Alert(title:, message:).
- sessions/new: "no auth methods enabled" notice → DS::Alert(message:).

The DS::Alert warning recipe (warning tint + neutral text + alert-triangle)
replaces the literal amber palette while preserving each notice's intent.

Part of #2198. After this, the only remaining raw amber-* is the dynamic
validation feedback in admin_sso_form_controller.js (deferred — JS form
state styling, a separate concern).
2026-06-11 15:25:33 +02:00
Guillem Arias Fauste
c45b786e86 fix(ds): migrate SSO amber warning surfaces to DS::Alert / DS::Pill (#2246)
Apply the resolved warning-hue decision (use --color-warning; no separate
amber token) to the admin SSO surfaces flagged in #2198:

- admin/sso_providers: the "legacy providers" notice → DS::Alert(:warning);
  the "ENV configured" badge → DS::Pill(tone: :warning).
- settings/securities: the single-OIDC password warning → DS::Alert(:warning).

Replaces hand-rolled bg-amber-50 / border-amber-200 / text-amber-800 boxes
and an amber badge with the functional warning token via DS components. The
DS::Alert warning recipe (warning tint + neutral text + alert-triangle)
matches the prior look while clearing the literal-token drift.

Part of #2198.
2026-06-11 15:24:02 +02:00
Guillem Arias Fauste
c05a64ee9b fix(ds): canonical destructive red → red-500 (token + button) (#2245)
* fix(ds): canonical destructive red → red-500 (token + button)

Per the resolved color decision, --color-destructive moves from red-600
(#EC2222) to red-500 (#F13636), aligning the destructive token with the
red-500 already used app-wide for negative amounts and error text.

- design/tokens/sure.tokens.json: destructive, border-destructive and
  button-bg-destructive (red-600→red-500) + button-bg-destructive-hover
  (red-700→red-600); dark values unchanged (red-400 base / red-500 hover).
- Regenerate _generated.css.
- buttonish.rb: the destructive button now uses the theme-aware
  button-bg-destructive / -hover utilities instead of hardcoded
  bg-red-600/700, removing the last raw-palette destructive bypass.

Part of #2134.

* fix(ds): keep destructive button fill at red-600 for white-label contrast

Addresses review (Codex P2): the canonical-destructive flip to red-500 also
lowered the solid destructive button fill to red-500, dropping white-on-red
label contrast to ~3.95:1 at text-sm. Keep button-bg-destructive at red-600 /
hover red-700 (white-on-red ~4.36:1 — the prior level, no regression).

--color-destructive and border-destructive stay red-500: those are red-on-
white text/border usages where red-500 is the chosen canonical hue. Solid
white-on-red fills legitimately use a darker shade.
2026-06-11 15:23:07 +02:00
Guillem Arias Fauste
4dbfbf0bc8 fix(ds): replace invalid bg-surface-default with bg-surface (#2244)
`bg-surface-default` is not a defined design-system token — Tailwind emits
no rule for it, so these surfaces render with no background at all. Five
call sites were affected:

- rules/index.html.erb — recent-runs table header
- settings/llm_usages/show.html.erb — usage table header
- settings/ai_prompts/show.html.erb — three prompt-preview boxes

Replace with the canonical `bg-surface` token — the same fill the
admin/users and settings/debugs table headers already use. Clears a
Rule 2 (non-functional token) finding from the weekly DS drift scan.
2026-06-11 15:20:01 +02:00
Guillem Arias Fauste
034a12f1d8 fix(ds): use DS::Disclosure for investment-performance expander (#2243)
The "view details" expander rendered a raw <details>/<summary>, flagged
as a Rule 1 (bypassing DS components) finding in the weekly DS drift scan
(#2157). Migrate it to the DS::Disclosure :inline variant — the same
primitive already used for the goals archived section and provider panels.

Gains the canonical focus-visible ring and motion-safe chevron rotation
for free; markup, copy, and i18n keys are otherwise unchanged.
2026-06-11 15:19:20 +02:00