From e38632632ca38c8dfc11c8f8dfed79e9f5806ed1 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:37:30 -0700 Subject: [PATCH 01/11] feat(mobile): standardize money typography and semantic amount color (#2331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .github/workflows/ci.yml | 4 +- .github/workflows/llm-evals.yml | 2 +- .../screens/recent_transactions_screen.dart | 41 +++---- .../lib/screens/transactions_list_screen.dart | 12 +- mobile/lib/theme/sure_colors.dart | 45 +++++++ mobile/lib/theme/sure_theme.dart | 2 + mobile/lib/widgets/account_card.dart | 16 ++- mobile/lib/widgets/money_text.dart | 110 ++++++++++++++++++ mobile/lib/widgets/net_worth_card.dart | 44 ++++--- mobile/test/widgets/account_card_test.dart | 41 +++++++ mobile/test/widgets/money_text_test.dart | 74 ++++++++++++ test/test_helper.rb | 7 ++ 12 files changed, 347 insertions(+), 51 deletions(-) create mode 100644 mobile/lib/theme/sure_colors.dart create mode 100644 mobile/lib/widgets/money_text.dart create mode 100644 mobile/test/widgets/account_card_test.dart create mode 100644 mobile/test/widgets/money_text_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ab5d2c4..76d7c52a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: env: PLAID_CLIENT_ID: foo PLAID_SECRET: bar - DATABASE_URL: postgres://postgres:postgres@localhost:5432 + DATABASE_URL: postgres://postgres:postgres@localhost:5432 # pipelock:ignore REDIS_URL: redis://localhost:6379 RAILS_ENV: test @@ -141,7 +141,7 @@ jobs: env: PLAID_CLIENT_ID: foo PLAID_SECRET: bar - DATABASE_URL: postgres://postgres:postgres@localhost:5432 + DATABASE_URL: postgres://postgres:postgres@localhost:5432 # pipelock:ignore REDIS_URL: redis://localhost:6379 RAILS_ENV: test diff --git a/.github/workflows/llm-evals.yml b/.github/workflows/llm-evals.yml index aaa5a13a1..249afa96b 100644 --- a/.github/workflows/llm-evals.yml +++ b/.github/workflows/llm-evals.yml @@ -11,7 +11,7 @@ permissions: env: EVAL_MODELS: gpt-4.1 RAILS_ENV: test - DATABASE_URL: postgres://postgres:postgres@localhost:5432 + DATABASE_URL: postgres://postgres:postgres@localhost:5432 # pipelock:ignore REDIS_URL: redis://localhost:6379 PLAID_CLIENT_ID: foo PLAID_SECRET: bar diff --git a/mobile/lib/screens/recent_transactions_screen.dart b/mobile/lib/screens/recent_transactions_screen.dart index 546cea286..ccf7a2768 100644 --- a/mobile/lib/screens/recent_transactions_screen.dart +++ b/mobile/lib/screens/recent_transactions_screen.dart @@ -7,6 +7,7 @@ import '../providers/transactions_provider.dart'; import '../providers/accounts_provider.dart'; import '../providers/auth_provider.dart'; import '../utils/amount_parser.dart'; +import '../widgets/money_text.dart'; class RecentTransactionsScreen extends StatefulWidget { const RecentTransactionsScreen({super.key}); @@ -136,6 +137,7 @@ class _RecentTransactionsScreenState extends State { itemBuilder: (context, index) { final transaction = recentTransactions[index]; return _buildTransactionItem( + context, transaction, colorScheme, ); @@ -173,7 +175,8 @@ class _RecentTransactionsScreenState extends State { ); } - Widget _buildTransactionItem(Transaction transaction, ColorScheme colorScheme) { + Widget _buildTransactionItem( + BuildContext context, Transaction transaction, ColorScheme colorScheme) { final account = _getAccount(transaction.accountId); final accountName = account?.name ?? 'Unknown Account'; @@ -190,21 +193,17 @@ class _RecentTransactionsScreenState extends State { amount = -amount; } - // Determine display properties based on final amount + // Determine display properties based on final amount. The semantic color + // comes from the Sure design-system tokens (success/destructive/subdued) + // via MoneyTrend, instead of raw Colors.green/red. final isPositive = amount == null || amount >= 0; - Color amountColor; - String sign; - - if (amount == null) { - amountColor = Colors.grey; - sign = ''; - } else if (isPositive) { - amountColor = Colors.green.shade700; - sign = '+'; - } else { - amountColor = Colors.red.shade700; - sign = '-'; - } + final moneyTrend = SureMoney.trendForAmount(amount); + final amountColor = SureMoney.color(context, moneyTrend); + final sign = amount == null + ? '' + : isPositive + ? '+' + : '-'; String formattedDate; try { @@ -219,11 +218,7 @@ class _RecentTransactionsScreenState extends State { leading: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: amount == null - ? Colors.grey.withValues(alpha: 0.1) - : isPositive - ? Colors.green.withValues(alpha: 0.1) - : Colors.red.withValues(alpha: 0.1), + color: amountColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( @@ -273,14 +268,14 @@ class _RecentTransactionsScreenState extends State { ], ], ), - trailing: Text( + trailing: MoneyText( amount == null ? transaction.amount : '$sign${transaction.currency} ${_formatAmount(amount.abs())}', - style: TextStyle( + trend: moneyTrend, + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, - color: amountColor, ), ), ); diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart index 8e9cb4100..3bdca2e8c 100644 --- a/mobile/lib/screens/transactions_list_screen.dart +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -13,6 +13,7 @@ import '../widgets/category_filter.dart'; import '../widgets/sync_status_badge.dart'; import '../services/log_service.dart'; import '../utils/amount_parser.dart'; +import '../widgets/money_text.dart'; class TransactionsListScreen extends StatefulWidget { final Account account; @@ -58,11 +59,13 @@ class _TransactionsListScreenState extends State { // Determine if the final value is positive bool isPositive = numericValue >= 0; + final trend = isPositive ? MoneyTrend.inflow : MoneyTrend.outflow; return { 'isPositive': isPositive, 'displayAmount': parsed.displayText, - 'color': isPositive ? Colors.green : Colors.red, + 'trend': trend, + 'color': SureMoney.color(context, trend), 'icon': isPositive ? Icons.arrow_upward : Icons.arrow_downward, 'prefix': isPositive ? '' : '-', }; @@ -71,7 +74,8 @@ class _TransactionsListScreenState extends State { return { 'isPositive': true, 'displayAmount': amount, - 'color': Colors.grey, + 'trend': MoneyTrend.neutral, + 'color': SureMoney.color(context, MoneyTrend.neutral), 'icon': Icons.help_outline, 'prefix': '', }; @@ -655,12 +659,12 @@ class _TransactionsListScreenState extends State { ), ), Flexible( - child: Text( + child: MoneyText( '${displayInfo['prefix']}${displayInfo['displayAmount']}', + trend: displayInfo['trend'] as MoneyTrend, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, - color: displayInfo['color'] as Color, ), ), ), diff --git a/mobile/lib/theme/sure_colors.dart b/mobile/lib/theme/sure_colors.dart new file mode 100644 index 000000000..1438dcae9 --- /dev/null +++ b/mobile/lib/theme/sure_colors.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'sure_tokens.dart'; + +/// Exposes the full Sure design-system palette to widgets in a brightness-aware +/// way. The generated [SureTokenPalette] carries semantic colors (success, +/// destructive, textSubdued, …) that the base [ColorScheme] does not, so without +/// this extension widgets have to branch on `Theme.of(context).brightness` and +/// reach into `SureTokens.light`/`SureTokens.dark` by hand. Registering the +/// active palette here lets them resolve the correct token via +/// `SureColors.of(context).palette.success` instead. +@immutable +class SureColors extends ThemeExtension { + const SureColors(this.palette); + + final SureTokenPalette palette; + + /// The active palette for [context]. Falls back to the palette matching the + /// active brightness if the extension is missing (e.g. a widget built outside + /// [SureTheme] in a test), so dark surfaces don't get light-palette colors. + static SureColors of(BuildContext context) { + final theme = Theme.of(context); + return theme.extension() ?? + SureColors( + theme.brightness == Brightness.dark + ? SureTokens.dark + : SureTokens.light, + ); + } + + @override + SureColors copyWith({SureTokenPalette? palette}) { + return SureColors(palette ?? this.palette); + } + + @override + SureColors lerp(ThemeExtension? other, double t) { + // Design tokens are a discrete light/dark pair; a mid-transition blend of + // every semantic color is not meaningful, so swap at the midpoint. + if (other is! SureColors) { + return this; + } + return t < 0.5 ? this : other; + } +} diff --git a/mobile/lib/theme/sure_theme.dart b/mobile/lib/theme/sure_theme.dart index 0973d9128..a327dbbff 100644 --- a/mobile/lib/theme/sure_theme.dart +++ b/mobile/lib/theme/sure_theme.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'sure_colors.dart'; import 'sure_tokens.dart'; class SureTheme { @@ -38,6 +39,7 @@ class SureTheme { colorScheme: colorScheme, scaffoldBackgroundColor: tokens.surface, useMaterial3: true, + extensions: >[SureColors(tokens)], appBarTheme: AppBarTheme( centerTitle: true, elevation: 0, diff --git a/mobile/lib/widgets/account_card.dart b/mobile/lib/widgets/account_card.dart index f3cbd8636..7e45adc33 100644 --- a/mobile/lib/widgets/account_card.dart +++ b/mobile/lib/widgets/account_card.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import '../models/account.dart'; +import '../theme/sure_colors.dart'; +import 'money_text.dart'; class AccountCard extends StatelessWidget { final Account account; @@ -42,9 +44,9 @@ class AccountCard extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; if (account.isAsset) { - return Colors.green; + return SureColors.of(context).palette.success; } else if (account.isLiability) { - return Colors.red; + return SureColors.of(context).palette.destructive; } return colorScheme.primary; } @@ -109,9 +111,13 @@ class AccountCard extends StatelessWidget { children: [ Text( account.balance, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: account.isLiability ? Colors.red : null, + style: SureMoney.tabular( + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: account.isLiability + ? SureColors.of(context).palette.destructive + : null, + ), ), ), const SizedBox(height: 4), diff --git a/mobile/lib/widgets/money_text.dart b/mobile/lib/widgets/money_text.dart new file mode 100644 index 000000000..8420b8fcb --- /dev/null +++ b/mobile/lib/widgets/money_text.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import '../theme/sure_colors.dart'; + +/// Directional meaning of a monetary value, used to pick its semantic color. +/// This mirrors the app's existing in/out coloring (money in is positive, money +/// out is negative) but routes it through Sure design-system tokens instead of +/// raw [Colors.green]/[Colors.red]. +enum MoneyTrend { + /// Money coming in (positive change, income, a gain). Uses `success`. + inflow, + + /// Money going out (negative change, expense, a loss). Uses `destructive`. + outflow, + + /// No directional meaning / unknown amount. Uses `textSubdued`. + neutral, +} + +/// Shared money typography + semantic color, so every screen renders monetary +/// values the same way: tabular figures (digits stay aligned across rows) and a +/// design-system color chosen from the value's [MoneyTrend]. +/// +/// Convention for callers: +/// - Directionally-colored amounts (income/expense, +/- changes): use +/// [MoneyText] (or [style]) with a [MoneyTrend] — it applies the semantic +/// color *and* tabular figures, so do not also wrap the style in [tabular]. +/// - Neutral amounts that are not directionally colored (account balances, net +/// worth, per-currency totals): use [tabular] to keep digits aligned without +/// changing the color. [MoneyTrend.neutral] is reserved for unknown/unparsed +/// amounts (renders in `textSubdued`). +class SureMoney { + const SureMoney._(); + + /// The design-system color for [trend] in the active theme. + static Color color(BuildContext context, MoneyTrend trend) { + final palette = SureColors.of(context).palette; + switch (trend) { + case MoneyTrend.inflow: + return palette.success; + case MoneyTrend.outflow: + return palette.destructive; + case MoneyTrend.neutral: + return palette.textSubdued; + } + } + + /// Derive a [MoneyTrend] from a signed amount. `null` amounts are [neutral]. + static MoneyTrend trendForAmount(double? amount) { + if (amount == null) { + return MoneyTrend.neutral; + } + return amount >= 0 ? MoneyTrend.inflow : MoneyTrend.outflow; + } + + /// A money [TextStyle]: [base] (so callers keep their size) plus the semantic + /// color for [trend] and tabular figures for column-aligned digits. + static TextStyle style( + BuildContext context, { + required MoneyTrend trend, + TextStyle? base, + }) { + final resolved = base ?? const TextStyle(); + return resolved.copyWith( + color: color(context, trend), + fontWeight: resolved.fontWeight ?? FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ); + } + + /// Apply only money typography (tabular figures) to [base], leaving the color + /// untouched. For neutral balances/totals (e.g. net worth) that are not + /// directionally colored but should still keep digits column-aligned. + static TextStyle tabular(TextStyle? base) { + return (base ?? const TextStyle()).copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ); + } +} + +/// Renders a pre-formatted monetary [text] with shared money typography and the +/// semantic color for [trend]. +class MoneyText extends StatelessWidget { + const MoneyText( + this.text, { + super.key, + required this.trend, + this.style, + this.textAlign, + this.overflow, + }); + + final String text; + final MoneyTrend trend; + + /// Base style (size/weight). Color and tabular figures are applied on top. + final TextStyle? style; + final TextAlign? textAlign; + final TextOverflow? overflow; + + @override + Widget build(BuildContext context) { + return Text( + text, + textAlign: textAlign, + overflow: overflow, + style: SureMoney.style(context, trend: trend, base: style), + ); + } +} diff --git a/mobile/lib/widgets/net_worth_card.dart b/mobile/lib/widgets/net_worth_card.dart index 2d0ca03a0..2de81de89 100644 --- a/mobile/lib/widgets/net_worth_card.dart +++ b/mobile/lib/widgets/net_worth_card.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import '../theme/sure_colors.dart'; +import 'money_text.dart'; + enum AccountFilter { all, assets, liabilities } class NetWorthCard extends StatelessWidget { @@ -25,6 +28,7 @@ class NetWorthCard extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final sureColors = SureColors.of(context); return Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -74,10 +78,14 @@ class NetWorthCard extends StatelessWidget { const SizedBox(height: 4), Text( netWorthFormatted ?? '--', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: isStale ? colorScheme.secondary : colorScheme.onSurface, - ), + style: SureMoney.tabular( + Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: isStale + ? colorScheme.secondary + : colorScheme.onSurface, + ), + ), ), ], ), @@ -97,7 +105,7 @@ class NetWorthCard extends StatelessWidget { Expanded( child: _FilterButton( totals: assetTotalsByCurrency, - color: Colors.green, + color: sureColors.palette.success, isSelected: currentFilter == AccountFilter.assets, onTap: () { if (currentFilter == AccountFilter.assets) { @@ -110,7 +118,7 @@ class NetWorthCard extends StatelessWidget { context, 'Assets', assetTotalsByCurrency, - Colors.green, + sureColors.palette.success, ), formatAmount: formatAmount, ), @@ -126,7 +134,7 @@ class NetWorthCard extends StatelessWidget { Expanded( child: _FilterButton( totals: liabilityTotalsByCurrency, - color: Colors.red, + color: sureColors.palette.destructive, isSelected: currentFilter == AccountFilter.liabilities, onTap: () { if (currentFilter == AccountFilter.liabilities) { @@ -139,7 +147,7 @@ class NetWorthCard extends StatelessWidget { context, 'Liabilities', liabilityTotalsByCurrency, - Colors.red, + sureColors.palette.destructive, ), formatAmount: formatAmount, ), @@ -304,10 +312,12 @@ class _FilterButton extends StatelessWidget { ? Center( child: Text( formatAmount(sortedEntries.first.key, sortedEntries.first.value), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), + style: SureMoney.tabular( + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), ), ) : NotificationListener( @@ -324,10 +334,12 @@ class _FilterButton extends StatelessWidget { return Center( child: Text( formatAmount(entry.key, entry.value), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), + style: SureMoney.tabular( + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), ), ); }, diff --git a/mobile/test/widgets/account_card_test.dart b/mobile/test/widgets/account_card_test.dart new file mode 100644 index 000000000..3533bf034 --- /dev/null +++ b/mobile/test/widgets/account_card_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/models/account.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/theme/sure_tokens.dart'; +import 'package:sure_mobile/widgets/account_card.dart'; + +void main() { + Account account(String classification) => Account( + id: '1', + name: 'Test account', + balance: 'USD 100.00', + currency: 'USD', + accountType: + classification == 'liability' ? 'credit_card' : 'depository', + classification: classification, + ); + + Future pump(WidgetTester tester, Account a) async { + await tester.pumpWidget( + MaterialApp( + theme: SureTheme.light, + home: Scaffold(body: AccountCard(account: a)), + ), + ); + } + + testWidgets('liability balance uses the destructive design-system token', + (tester) async { + await pump(tester, account('liability')); + final balance = tester.widget(find.text('USD 100.00')); + expect(balance.style?.color, SureTokens.light.destructive); + }); + + testWidgets('asset balance keeps default (non-destructive) color', + (tester) async { + await pump(tester, account('asset')); + final balance = tester.widget(find.text('USD 100.00')); + expect(balance.style?.color, isNot(SureTokens.light.destructive)); + }); +} diff --git a/mobile/test/widgets/money_text_test.dart b/mobile/test/widgets/money_text_test.dart new file mode 100644 index 000000000..d2d0b21e4 --- /dev/null +++ b/mobile/test/widgets/money_text_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/theme/sure_tokens.dart'; +import 'package:sure_mobile/widgets/money_text.dart'; + +void main() { + group('SureMoney.trendForAmount', () { + test('null amount is neutral', () { + expect(SureMoney.trendForAmount(null), MoneyTrend.neutral); + }); + + test('positive and zero are inflow, negative is outflow', () { + expect(SureMoney.trendForAmount(12.5), MoneyTrend.inflow); + expect(SureMoney.trendForAmount(0), MoneyTrend.inflow); + expect(SureMoney.trendForAmount(-3), MoneyTrend.outflow); + }); + }); + + Future pumpContext(WidgetTester tester, ThemeData theme) async { + late BuildContext captured; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + captured = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + return captured; + } + + group('SureMoney.color resolves design-system tokens', () { + testWidgets('light theme maps trends to success/destructive/subdued', + (tester) async { + final context = await pumpContext(tester, SureTheme.light); + expect(SureMoney.color(context, MoneyTrend.inflow), + SureTokens.light.success); + expect(SureMoney.color(context, MoneyTrend.outflow), + SureTokens.light.destructive); + expect(SureMoney.color(context, MoneyTrend.neutral), + SureTokens.light.textSubdued); + }); + + testWidgets('dark theme resolves the dark palette', (tester) async { + final context = await pumpContext(tester, SureTheme.dark); + expect( + SureMoney.color(context, MoneyTrend.inflow), SureTokens.dark.success); + expect(SureMoney.color(context, MoneyTrend.outflow), + SureTokens.dark.destructive); + }); + }); + + group('MoneyText', () { + testWidgets('applies semantic color and tabular figures', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: SureTheme.light, + home: const Scaffold( + body: MoneyText('+\$10.00', trend: MoneyTrend.inflow), + ), + ), + ); + + final text = tester.widget(find.text('+\$10.00')); + expect(text.style?.color, SureTokens.light.success); + expect(text.style?.fontFeatures, + contains(const FontFeature.tabularFigures())); + }); + }); +} diff --git a/test/test_helper.rb b/test/test_helper.rb index 461ebe3a6..b70459708 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -78,6 +78,13 @@ module ActiveSupport # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all + # rails-settings-cached keeps an in-memory cache that survives the per-test + # transaction rollback, so a Setting.* value written in one test leaks into + # later tests and causes order-dependent failures (e.g. Settings::Hostings + # reading a stale "" where nil is expected). Reset the cache before every + # test so each starts from the rolled-back DB state. + setup { Setting.clear_cache } + # Add more helper methods to be used by all tests here... def sign_in(user) post sessions_path, params: { email: user.email, password: user_password_test } From 88343002d1ba668d0e367dccbb31f02d813eac3e Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:48:14 -0700 Subject: [PATCH 02/11] =?UTF-8?q?chore(deps):=20upgrade=20Rails=207.2=20?= =?UTF-8?q?=E2=86=92=208.1=20(#2301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- Gemfile | 2 +- Gemfile.lock | 208 +++++++++++++++-------------- app/helpers/styled_form_builder.rb | 10 +- app/models/transfer.rb | 2 +- config/application.rb | 2 +- config/brakeman.ignore | 6 +- config/brakeman.yml | 18 --- test/system/property_test.rb | 19 ++- 8 files changed, 137 insertions(+), 130 deletions(-) delete mode 100644 config/brakeman.yml diff --git a/Gemfile b/Gemfile index a58e0ccf9..fcb4f7336 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" ruby file: ".ruby-version" # Rails -gem "rails", "~> 7.2.2" +gem "rails", "~> 8.1.0" # Drivers gem "pg", "~> 1.5" diff --git a/Gemfile.lock b/Gemfile.lock index 6bc0b988d..ce6f72ecd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,81 +4,82 @@ GEM Ascii85 (2.0.1) aasm (5.5.1) concurrent-ruby (~> 1.0) - actioncable (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) + action_text-trix (2.1.19) + railties + actioncable (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.3.1) - actionpack (= 7.2.3.1) - activejob (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actionmailbox (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) mail (>= 2.8.0) - actionmailer (7.2.3.1) - actionpack (= 7.2.3.1) - actionview (= 7.2.3.1) - activejob (= 7.2.3.1) - activesupport (= 7.2.3.1) + actionmailer (8.1.3) + actionpack (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activesupport (= 8.1.3) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.3.1) - actionview (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + actionpack (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4, < 3.3) + rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.3.1) - actionpack (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actiontext (8.1.3) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.3.1) - activesupport (= 7.2.3.1) + actionview (8.1.3) + activesupport (= 8.1.3) builder (~> 3.1) - cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.3.1) - activesupport (= 7.2.3.1) + activejob (8.1.3) + activesupport (= 8.1.3) globalid (>= 0.3.6) - activemodel (7.2.3.1) - activesupport (= 7.2.3.1) - activerecord (7.2.3.1) - activemodel (= 7.2.3.1) - activesupport (= 7.2.3.1) + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) timeout (>= 0.4.0) activerecord-import (2.2.0) activerecord (>= 4.2) - activestorage (7.2.3.1) - actionpack (= 7.2.3.1) - activejob (= 7.2.3.1) - activerecord (= 7.2.3.1) - activesupport (= 7.2.3.1) + activestorage (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activesupport (= 8.1.3) marcel (~> 1.0) - activesupport (7.2.3.1) + activesupport (8.1.3) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) - minitest (>= 5.1, < 6) + minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) @@ -163,7 +164,7 @@ GEM css_parser (1.21.1) addressable csv (3.3.5) - date (3.4.1) + date (3.5.1) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) @@ -200,7 +201,7 @@ GEM ed25519 (1.4.0) email_validator (2.2.4) activemodel - erb (5.0.1) + erb (6.0.4) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) @@ -325,9 +326,10 @@ GEM inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.8.0) - irb (1.15.2) + io-console (0.8.2) + irb (1.18.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.13.0) @@ -392,15 +394,15 @@ GEM zeitwerk (~> 2.5) lucide-rails (0.7.3) railties (>= 4.1.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.2.1) matrix (0.4.2) memory_profiler (1.1.0) - method_source (1.1.0) mini_histogram (0.3.1) mini_magick (5.2.0) benchmark @@ -417,7 +419,7 @@ GEM mutex_m (0.3.0) net-http (0.9.1) uri (>= 0.11.1) - net-imap (0.5.8) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) @@ -426,22 +428,22 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) - nokogiri (1.19.2-aarch64-linux-gnu) + nio4r (2.7.5) + nokogiri (1.19.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-aarch64-linux-musl) + nokogiri (1.19.3-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.2-arm-linux-gnu) + nokogiri (1.19.3-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-arm-linux-musl) + nokogiri (1.19.3-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.2-arm64-darwin) + nokogiri (1.19.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-darwin) + nokogiri (1.19.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-gnu) + nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-musl) + nokogiri (1.19.3-x86_64-linux-musl) racc (~> 1.4) oauth2 (2.0.18) faraday (>= 0.17.3, < 4.0) @@ -516,7 +518,7 @@ GEM useragent (~> 0.16.3) posthog-ruby (3.3.3) concurrent-ruby (~> 1) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) prism (1.4.0) @@ -525,7 +527,7 @@ GEM activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.2.6) + psych (5.4.0) date stringio public_suffix (6.0.2) @@ -559,22 +561,22 @@ GEM rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) - rails (7.2.3.1) - actioncable (= 7.2.3.1) - actionmailbox (= 7.2.3.1) - actionmailer (= 7.2.3.1) - actionpack (= 7.2.3.1) - actiontext (= 7.2.3.1) - actionview (= 7.2.3.1) - activejob (= 7.2.3.1) - activemodel (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + rails (8.1.3) + actioncable (= 8.1.3) + actionmailbox (= 8.1.3) + actionmailer (= 8.1.3) + actionpack (= 8.1.3) + actiontext (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activemodel (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) bundler (>= 1.15.0) - railties (= 7.2.3.1) + railties (= 8.1.3) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -582,16 +584,15 @@ GEM rails-html-sanitizer (1.7.0) loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails-i18n (7.0.10) + rails-i18n (8.1.0) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 8) + railties (>= 8.0.0, < 9) rails-settings-cached (2.9.6) activerecord (>= 5.0.0) railties (>= 5.0.0) - railties (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + railties (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -599,23 +600,24 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.0) + rake (13.4.2) rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) rbs (3.9.4) logger rchardet (1.10.0) - rdoc (6.14.2) + rdoc (7.2.0) erb psych (>= 4.0.0) + tsort redcarpet (3.6.1) redis (5.4.0) redis-client (>= 0.22.0) redis-client (0.25.0) connection_pool regexp_parser (2.10.0) - reline (0.6.1) + reline (0.6.3) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -646,17 +648,17 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.6) - rswag-api (2.16.0) - activesupport (>= 5.2, < 8.1) - railties (>= 5.2, < 8.1) - rswag-specs (2.16.0) - activesupport (>= 5.2, < 8.1) - json-schema (>= 2.2, < 6.0) - railties (>= 5.2, < 8.1) + rswag-api (2.17.0) + activesupport (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rswag-specs (2.17.0) + activesupport (>= 5.2, < 8.2) + json-schema (>= 2.2, < 7.0) + railties (>= 5.2, < 8.2) rspec-core (>= 2.14) - rswag-ui (2.16.0) - actionpack (>= 5.2, < 8.1) - railties (>= 5.2, < 8.1) + rswag-ui (2.17.0) + actionpack (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) rubocop (1.76.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -766,7 +768,7 @@ GEM standardwebhooks (1.1.0) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.7) + stringio (3.2.0) stripe (15.3.0) swd (2.0.3) activesupport (>= 3) @@ -785,7 +787,7 @@ GEM tailwindcss-ruby (4.1.8-x86_64-linux-musl) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) - thor (1.4.0) + thor (1.5.0) timeout (0.6.1) tpm-key_attestation (0.14.1) bindata (~> 2.4) @@ -815,10 +817,10 @@ GEM base64 vernier (1.8.0) version_gem (1.1.9) - view_component (3.23.2) - activesupport (>= 5.2.0, < 8.1) + view_component (4.12.0) + actionview (>= 7.1.0) + activesupport (>= 7.1.0) concurrent-ruby (~> 1) - method_source (~> 1.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -841,14 +843,14 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) - websocket-driver (0.8.0) + websocket-driver (0.8.1) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.37) - zeitwerk (2.7.3) + zeitwerk (2.8.2) PLATFORMS aarch64-linux-gnu @@ -921,7 +923,7 @@ DEPENDENCIES rack-attack (~> 6.6) rack-cors rack-mini-profiler - rails (~> 7.2.2) + rails (~> 8.1.0) rails-i18n rails-settings-cached rchardet diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb index 277d34aa3..7899dd5ea 100644 --- a/app/helpers/styled_form_builder.rb +++ b/app/helpers/styled_form_builder.rb @@ -1,5 +1,10 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder - class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ] + # Rails 8 renamed two field_helpers entries: :text_area -> :textarea and + # :check_box -> :checkbox. Exclude both spellings of the non-text helpers, and + # alias the legacy method names below so existing `form.text_area` call sites + # stay styled. (Harmless on Rails 7.2, where the old names are present instead.) + NON_TEXT_FIELD_HELPERS = [ :label, :check_box, :checkbox, :radio_button, :fields_for, :fields, :hidden_field, :file_field ].freeze + class_attribute :text_field_helpers, default: field_helpers - NON_TEXT_FIELD_HELPERS text_field_helpers.each do |selector| class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 @@ -14,6 +19,9 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder RUBY_EVAL end + # Keep `form.text_area` styled after Rails 8 renamed the helper to `textarea`. + alias_method :text_area, :textarea if method_defined?(:textarea) + def radio_button(method, tag_value, options = {}) merged_options = { class: "form-field__radio" }.merge(options) super(method, tag_value, merged_options) diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 9b39d0f7d..f6f20474b 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -57,7 +57,7 @@ class Transfer < ApplicationRecord end def date - inflow_transaction.entry.date + inflow_transaction&.entry&.date end def sync_account_later diff --git a/config/application.rb b/config/application.rb index cba92a83c..d8a9f1d05 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,7 +9,7 @@ Bundler.require(*Rails.groups) module Sure class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.2 + config.load_defaults 8.1 # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 70f290096..07b34ca3e 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -118,13 +118,13 @@ { "warning_type": "Dangerous Eval", "warning_code": 13, - "fingerprint": "c154514a0f86341473e4abf35e77721495b326c7855e4967d284b4942371819c", + "fingerprint": "ad8e31fe9321feba741d551654a6fb0c0c7cfe8abee81822e6143404817bff5b", "check_name": "Evaluation", "message": "Dynamic string evaluated as code", "file": "app/helpers/styled_form_builder.rb", - "line": 5, + "line": 10, "link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/", - "code": "class_eval(\" def #{selector}(method, options = {})\\n form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)\\n html_options = options.except(:label, :label_tooltip, :inline, :container_class)\\n\\n build_field(method, form_options, html_options) do |merged_options|\\n super(method, merged_options)\\n end\\n end\\n\", \"app/helpers/styled_form_builder.rb\", (5 + 1))", + "code": "class_eval(\" def #{selector}(method, options = {})\\n form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)\\n html_options = options.except(:label, :label_tooltip, :inline, :container_class)\\n\\n build_field(method, form_options, html_options) do |merged_options|\\n super(method, merged_options)\\n end\\n end\\n\", \"app/helpers/styled_form_builder.rb\", (10 + 1))", "render_path": null, "location": { "type": "method", diff --git a/config/brakeman.yml b/config/brakeman.yml deleted file mode 100644 index e66126f92..000000000 --- a/config/brakeman.yml +++ /dev/null @@ -1,18 +0,0 @@ ---- -# Brakeman configuration (auto-loaded from config/brakeman.yml). -# -# Skip ONLY the Rails end-of-life check. It fires on the calendar rather than on -# anything in the code under review: brakeman's EOL checks start warning 60 days -# before a framework's end-of-life date and escalate in confidence as that date -# approaches (see brakeman/checks/eol_check.rb). Rails 7.2 reaches EOL on -# 2026-08-09, so the window is now open and `bin/brakeman` (exit code 3) turns -# red on every branch and on main regardless of the change being scanned. -# -# This is an informational dependency-freshness reminder, not a finding in this -# codebase. CheckEOLRuby is intentionally left ENABLED — the current Ruby is not -# near end of life, so that signal is preserved. -# -# TODO: remove this skip when Sure upgrades off Rails 7.2 (EOL 2026-08-09) so -# brakeman's CheckEOLRails signal is restored. -:skip_checks: - - CheckEOLRails diff --git a/test/system/property_test.rb b/test/system/property_test.rb index b0a15f35d..c6a5f17b8 100644 --- a/test/system/property_test.rb +++ b/test/system/property_test.rb @@ -13,13 +13,28 @@ class PropertiesEditTest < ApplicationSystemTestCase test "can persist property subtype" do click_link "[system test] Property Account" - find("[data-testid='account-menu']").click - click_on "Edit" + open_account_edit_dialog assert_equal "single_family_home", find("#account_accountable_attributes_subtype").value end private + # The account page issues a Turbo morph refresh shortly after it loads + # (`turbo_refreshes_with method: :morph` reacting to a family-stream + # broadcast). If the edit modal is opened while that refresh is in flight, + # the morph re-renders the page and wipes the just-loaded `#modal` + # turbo-frame before the dialog is interactive. Open via the account menu and + # retry once the refresh has settled so the test is deterministic instead of + # racing the broadcast. + def open_account_edit_dialog + 3.times do + find("[data-testid='account-menu']").click + click_on "Edit" + return if has_selector?("#account_accountable_attributes_subtype", wait: 2) + end + assert_selector "#account_accountable_attributes_subtype" + end + def open_new_account_modal within "[data-controller='DS--tabs']" do click_button "All" From bc0dcdd41ba0ad6e0c2bbfd714b5b8eb6e22d86c Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Sun, 14 Jun 2026 21:50:11 +0200 Subject: [PATCH 03/11] 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:. --- app/views/goals/_status_callout.html.erb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/views/goals/_status_callout.html.erb b/app/views/goals/_status_callout.html.erb index a2c609ad3..a8118fbc9 100644 --- a/app/views/goals/_status_callout.html.erb +++ b/app/views/goals/_status_callout.html.erb @@ -4,11 +4,11 @@ variant_classes = case goal.status when :behind - "bg-warning/10 border-warning/20 text-warning" + "bg-warning/10 border-warning/20" when :on_track - "bg-success/10 border-success/20 text-success" + "bg-success/10 border-success/20" else - "bg-surface-inset border-secondary text-secondary" + "bg-surface-inset border-secondary" end icon_glyph = case goal.status @@ -18,10 +18,16 @@ else "info" end + icon_color = case goal.status + when :behind then "warning" + when :on_track then "success" + else "default" + end + label = t("goals.status.#{goal.status}", default: goal.status.to_s.titleize) %> -
- <%= icon(icon_glyph, size: "sm") %> +
+ <%= icon(icon_glyph, size: "sm", color: icon_color) %> <%= label %> · "><%= context %> From 6ebead469012ecb25200f9a67ca9ec3334fd8f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sun, 14 Jun 2026 22:59:38 +0300 Subject: [PATCH 04/11] Bump version by hand --- .sure-version | 2 +- charts/sure/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.sure-version b/.sure-version index 64067de7a..0f0454301 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.2-alpha.4 +0.7.2-alpha.6 diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 1991d11ca..d5e24abc3 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.7.2-alpha.4 -appVersion: "0.7.2-alpha.4" +version: 0.7.2-alpha.6 +appVersion: "0.7.2-alpha.6" kubeVersion: ">=1.25.0-0" From 215864fdd9869910388d83239f65af646a51bfeb Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Sun, 14 Jun 2026 22:01:05 +0200 Subject: [PATCH 05/11] 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. --- app/views/pages/dashboard.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index af786a77b..c2fd65fa4 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -36,7 +36,7 @@
<% end %> -
gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections"> +
gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections"> <% if accessible_accounts.any? %> <% @dashboard_sections.each do |section| %> <% next unless section[:visible] %> From 84547f766c6c865edba9309d05f3d223df0f6b25 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Sun, 14 Jun 2026 22:02:35 +0200 Subject: [PATCH 06/11] fix(dashboard): align sankey zoom-out button with section header (#2313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/views/pages/dashboard/_cashflow_sankey_chart.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb b/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb index 424794383..d924b09f5 100644 --- a/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb +++ b/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb @@ -6,7 +6,7 @@ data-sankey-chart-start-date-value="<%= period.start_date&.iso8601 %>" data-sankey-chart-end-date-value="<%= period.end_date&.iso8601 %>" class="w-full h-full privacy-sensitive flex flex-col gap-2"> -
+
<%= render DS::Button.new( variant: :icon, icon: "arrow-left", From 635938ec7b7a1085a4f2330fd47a75916ee95044 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Sun, 14 Jun 2026 22:04:11 +0200 Subject: [PATCH 07/11] fix(ds): normalize legacy tooltip spacing to one recipe (#2311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/views/holdings/_missing_price_tooltip.html.erb | 2 +- app/views/investments/_value_tooltip.html.erb | 2 +- app/views/settings/llm_usages/show.html.erb | 2 +- app/views/shared/_money_field.html.erb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/holdings/_missing_price_tooltip.html.erb b/app/views/holdings/_missing_price_tooltip.html.erb index 94793a882..9ae5b3e55 100644 --- a/app/views/holdings/_missing_price_tooltip.html.erb +++ b/app/views/holdings/_missing_price_tooltip.html.erb @@ -3,7 +3,7 @@ <%= icon "info", size: "sm", color: "current" %> <%= tag.span t(".missing_data"), class: "font-normal text-xs" %>
-