From 880ca696570e5e69ee3e3a592e325b40bea0b39a Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Thu, 14 May 2026 22:12:52 +0200 Subject: [PATCH] fix(goals): demote Behind pill to neutral surface + drop em-dashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavioural + RUI audit follow-ups. The yellow overload finding flagged three concurrent yellow surfaces on the show page: the "Behind" status pill, the catch-up alert, and the open-pledge banner(s). Demoting the alert to outline ownership of the primary CTA addressed one layer, but the pill kept fighting the alert for hue attention. "Behind" is a state, not a call to action; the alert owns the action signal. Switch the pill's classes from `bg-yellow-500/10 text-yellow-700` to `bg-surface-inset text-yellow-700` (with the same dark-mode override). Background goes neutral (matches paused/archived chips); the text keeps the warning hue and the triangle-alert icon stays. Signal preserved, weight reduced. The yellow alert below now reads as the primary nudge instead of one of three matching tones. Also: copy/em-dash sweep across goal surfaces. User-facing strings that contained em-dashes ("Reaches 70% — $X of $Y", "into your linked account — Sure will catch it", "You're at 80% — $X of $Y") read as a stylistic tic; replace with comma/period/period respectively. Form-stepper review placeholders "—" become "…" (ellipsis reads as "not yet set" without the typographic weight). Code comments + log messages also scrubbed for consistency; awkward sed artifacts (//. its...) restored to readable English. No locale-key shape changes; pure string-content edits + one component-style tweak. --- app/components/goals/avatar_component.rb | 4 ++-- ...funding_accounts_breakdown_component.html.erb | 4 ++-- app/components/goals/status_pill_component.rb | 2 +- .../goal_pledge_preview_controller.js | 2 +- .../goal_projection_chart_controller.js | 16 ++++++++-------- .../controllers/goal_stepper_controller.js | 12 ++++++------ app/models/goal.rb | 16 ++++++++-------- app/models/goal_pledge.rb | 2 +- app/views/goals/_form_stepper.html.erb | 6 +++--- app/views/goals/index.html.erb | 2 +- app/views/goals/show.html.erb | 4 ++-- config/locales/views/goal_pledges/en.yml | 4 ++-- config/locales/views/goals/en.yml | 4 ++-- 13 files changed, 39 insertions(+), 39 deletions(-) diff --git a/app/components/goals/avatar_component.rb b/app/components/goals/avatar_component.rb index 572a26909..dcfaf7159 100644 --- a/app/components/goals/avatar_component.rb +++ b/app/components/goals/avatar_component.rb @@ -10,7 +10,7 @@ class Goals::AvatarComponent < ApplicationComponent # Deterministic color pick from the palette so the same string maps to # the same color across processes (Ruby's String#hash is randomized per - # boot for DoS protection — not stable enough for visual identity). + # boot for DoS protection. not stable enough for visual identity). def self.color_for(name) return PALETTE.first if name.blank? PALETTE[Digest::MD5.hexdigest(name).to_i(16) % PALETTE.size] @@ -26,7 +26,7 @@ class Goals::AvatarComponent < ApplicationComponent attr_reader :color - # Don't expose @icon via attr_reader — `icon` collides with the global + # Don't expose @icon via attr_reader. `icon` collides with the global # icon helper used inside the template. def icon_name @icon diff --git a/app/components/goals/funding_accounts_breakdown_component.html.erb b/app/components/goals/funding_accounts_breakdown_component.html.erb index fcf167018..24ef45fd5 100644 --- a/app/components/goals/funding_accounts_breakdown_component.html.erb +++ b/app/components/goals/funding_accounts_breakdown_component.html.erb @@ -7,7 +7,7 @@

<%= t("goals.show.funding_accounts_heading") %>

- <%# Distribution bar — proportional weight of each account in this goal. + <%# Distribution bar. proportional weight of each account in this goal. Color encoding is shared with the row avatar so the bar reads as a legend by itself; no separate dot/name/% strip needed. %> <% if rows.size > 1 && total.positive? %> @@ -19,7 +19,7 @@
<% end %> - <%# Per-account detail — avatar / name+type / % / last-30d + last-90d %> + <%# Per-account detail. avatar / name+type / % / last-30d + last-90d %>
<% rows.each_with_index do |row, idx| %> diff --git a/app/components/goals/status_pill_component.rb b/app/components/goals/status_pill_component.rb index e39c06c36..03822722c 100644 --- a/app/components/goals/status_pill_component.rb +++ b/app/components/goals/status_pill_component.rb @@ -8,7 +8,7 @@ class Goals::StatusPillComponent < ApplicationComponent # we-promise/sure#1736 lands token-level fixes. VARIANTS = { on_track: { classes: "bg-green-500/10 text-green-700 theme-dark:text-green-300", icon: "circle-check" }, - behind: { classes: "bg-yellow-500/10 text-yellow-700 theme-dark:text-yellow-300", icon: "triangle-alert" }, + behind: { classes: "bg-surface-inset text-yellow-700 theme-dark:text-yellow-300", icon: "triangle-alert" }, reached: { classes: "bg-green-500/10 text-green-700 theme-dark:text-green-300", icon: "star" }, completed: { classes: "bg-green-500/10 text-green-700 theme-dark:text-green-300", icon: "circle-check-big" }, no_target_date: { classes: "bg-surface-inset text-gray-700 theme-dark:text-gray-200", icon: "infinity" }, diff --git a/app/javascript/controllers/goal_pledge_preview_controller.js b/app/javascript/controllers/goal_pledge_preview_controller.js index 535345f48..9246b6503 100644 --- a/app/javascript/controllers/goal_pledge_preview_controller.js +++ b/app/javascript/controllers/goal_pledge_preview_controller.js @@ -28,7 +28,7 @@ export default class extends Controller { // Helper text reacts to the currently-selected account, not the goal as a // whole. A mixed-funding goal (one connected account + one manual) used to // paint the "connected" helper even if the user then picked the manual - // account from the dropdown — the saved pledge would be `kind: manual_save` + // account from the dropdown; the saved pledge would be `kind: manual_save` // (correct, per `kind_for_account` in the controller) but the helper read // "transfer-style" copy until submission. accountChanged() { diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index 4dc541d20..887c66ec2 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -38,9 +38,9 @@ export default class extends Controller { } // After a Turbo render (eg. after saving the goal from the edit modal // and redirecting back to show), the chart container can be left empty - // — its children are wiped by the morph but connect() was already - // called and ResizeObserver doesn't fire because the size didn't - // change. Listen for the render event so we redraw when needed. + // its children may be wiped by the morph even though connect() was + // already called, and ResizeObserver doesn't fire because the size + // didn't change. Listen for the render event so we redraw when needed. this._onTurboRender = () => { if (!this.element.querySelector("svg")) this._draw(); }; @@ -139,7 +139,7 @@ export default class extends Controller { .attr("viewBox", `0 0 ${width} ${height}`) .attr("preserveAspectRatio", "none"); - // Drop the child — browsers render it as a native hover tooltip + // Drop the <title> child; browsers render it as a native hover tooltip // that fights with our own crosshair tooltip. aria-label gives the same // SR accessible name without the tooltip side-effect. const descId = `chart-desc-${this._id()}`; @@ -209,7 +209,7 @@ export default class extends Controller { .attr("fill", textPrimary) .text(`Target · ${this._fmtMoneyShort(targetAmount, data.currency)}`); } else { - // Plenty of room — keep the right-side full-format label. + // Plenty of room: keep the right-side full-format label. svg .append("text") .attr("x", margin.left + innerWidth - 4) @@ -252,7 +252,7 @@ export default class extends Controller { if (requiredSeries.length) { // Light dashed reference line: the path needed to hit the target. - // Neutral stroke (text-secondary) instead of green — both the + // Neutral stroke (text-secondary) instead of green: both the // projection and the required line are otherwise green when the // goal is on track, and the two would visually merge. svg @@ -291,7 +291,7 @@ export default class extends Controller { // Suppress the projection-end label when it would visually collide // with the target label above. In a barely-on-track case the dot - // already conveys "you'll hit the target" — duplicating "$2.4K" + // already conveys "you'll hit the target". duplicating "$2.4K" // beside "Target · $2,400" adds noise. const projDotY = y(projectionEnd); const collidesWithTargetLabel = targetAmount > 0 && Math.abs(projDotY - y(targetAmount)) < 18; @@ -407,7 +407,7 @@ export default class extends Controller { } } - // Hover interactivity — crosshair + dots + tooltip on pointermove. + // Hover interactivity: crosshair + dots + tooltip on pointermove. // Transparent rect catches pointer events across the plot area. const crosshair = svg .append("line") diff --git a/app/javascript/controllers/goal_stepper_controller.js b/app/javascript/controllers/goal_stepper_controller.js index 69695b648..e67f27853 100644 --- a/app/javascript/controllers/goal_stepper_controller.js +++ b/app/javascript/controllers/goal_stepper_controller.js @@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus"; // // Single <form> with two panels. Step 1 collects identity (name, amount, // date, color, notes, linked accounts). Step 2 reviews and submits. All -// state lives in the DOM — no half-records, single POST. +// state lives in the DOM. No half-records, single POST. export default class extends Controller { static targets = [ "step1Panel", @@ -114,7 +114,7 @@ export default class extends Controller { } if (!this.hasAvatarPreviewTarget || !this.hasNameInputTarget) return; - // If the user has explicitly picked an icon, leave it alone — name + // If the user has explicitly picked an icon, leave it alone. Name // changes shouldn't undo an explicit choice. const iconPicked = this.element.querySelector('input[name="goal[icon]"]:checked'); if (iconPicked) return; @@ -123,7 +123,7 @@ export default class extends Controller { if (name) { this.avatarPreviewTarget.textContent = name.charAt(0).toUpperCase(); } else if (this._defaultAvatarHTML) { - // Captured at connect — restore the default "target" icon from the + // Captured at connect. Restore the default "target" icon from the // server-rendered template, not a "?" character. this.avatarPreviewTarget.innerHTML = this._defaultAvatarHTML; } @@ -229,7 +229,7 @@ export default class extends Controller { updateReview() { if (!this.hasReviewNameTarget) return; - const name = this.element.querySelector('input[name="goal[name]"]')?.value || "—"; + const name = this.element.querySelector('input[name="goal[name]"]')?.value || "…"; const amountInput = this.element.querySelector('input[name="goal[target_amount]"]'); const amount = amountInput?.value ? Number.parseFloat(amountInput.value) : 0; const dateInput = this.element.querySelector('input[type="date"][name="goal[target_date]"]'); @@ -239,7 +239,7 @@ export default class extends Controller { this.reviewNameTarget.textContent = name; if (this.hasReviewSummaryTarget) { - const formattedAmount = amount > 0 ? this.#money(amount) : "—"; + const formattedAmount = amount > 0 ? this.#money(amount) : "…"; const template = dateValue ? this.summaryWithDateValue : this.summaryNoDateValue; this.reviewSummaryTarget.textContent = template .replace("{amount}", formattedAmount) @@ -260,7 +260,7 @@ export default class extends Controller { } else if (amount > 0 && checked.length > 0) { this.reviewSuggestedTarget.textContent = this.suggestedNoDateValue; } else { - this.reviewSuggestedTarget.textContent = "—"; + this.reviewSuggestedTarget.textContent = "…"; } } } diff --git a/app/models/goal.rb b/app/models/goal.rb index 1c230bd56..63654f233 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -128,11 +128,11 @@ class Goal < ApplicationRecord end # 90-day rolling monthly pace: net inflow into linked accounts divided by - # three months. Transfers between linked accounts net to zero — both sides - # land inside this account set. Transfers from outside (e.g. checking → - # linked savings) net positive, which is the behaviour we want: the user - # taps "I just transferred…", the transfer arrives, balance goes up, - # pace goes up, status flips off "behind". Excludes user-flagged-excluded + # three months. Transfers between linked accounts net to zero (both sides + # land inside this account set). Transfers from outside (e.g. checking + # into linked savings) net positive, which is the behaviour we want: the + # user records a pledge, the transfer arrives, balance goes up, pace + # goes up, status flips off "behind". Excludes user-flagged-excluded # entries. Entry amount sign convention in Sure: inflow is negative. def pace return @pace if defined?(@pace) @@ -255,7 +255,7 @@ class Goal < ApplicationRecord end # True when any linked account is wired to a live sync provider (Plaid, - # SimpleFIN, or any AccountProvider — Brex, Enable Banking, IBKR, Kraken, + # SimpleFIN, or any AccountProvider. Brex, Enable Banking, IBKR, Kraken, # SnapTrade, Lunchflow). Drives the pledge-create copy: connected accounts # get the "I just transferred…" path; manual-only accounts get "I just # saved…" so users aren't told to wait for a sync that won't happen. @@ -296,7 +296,7 @@ class Goal < ApplicationRecord # to hit the target on time. Pending pledges are approximate (one-off # amounts treated as this-month inflow) but excluding them produced the # bad case where the alert demanded $X/mo while the user had already - # pledged $X — telling them to act on top of the action they just took. + # pledged $X, telling them to act on top of the action they just took. # Clamps at zero so a fully-covered goal doesn't surface a $0 demand. def catch_up_delta_money return Money.new(0, currency) if monthly_target_amount.nil? @@ -317,7 +317,7 @@ class Goal < ApplicationRecord ).balance_series.values rescue StandardError => e # Degrade gracefully (chart drops to target-line-only) but surface - # the failure — silent fallbacks here masked real Builder bugs. + # the failure; silent fallbacks here masked real Builder bugs. Rails.logger.error("Goal##{id} balance series failed: #{e.class}: #{e.message}") Sentry.capture_exception(e) if defined?(Sentry) [] diff --git a/app/models/goal_pledge.rb b/app/models/goal_pledge.rb index 023d98ec1..e94f3cc40 100644 --- a/app/models/goal_pledge.rb +++ b/app/models/goal_pledge.rb @@ -27,7 +27,7 @@ class GoalPledge < ApplicationRecord monetize :amount # Newest first. Used by the show page to render pending-pledge banners in - # "most-recent on top" order. Not actually chronological — kept for clarity. + # "most-recent on top" order. Not actually chronological; kept for clarity. scope :reverse_chronological, -> { order(created_at: :desc) } scope :open_and_expired_now, -> { where(status: "open").where("expires_at < ?", Time.current) diff --git a/app/views/goals/_form_stepper.html.erb b/app/views/goals/_form_stepper.html.erb index 5314e0b60..06471bfcc 100644 --- a/app/views/goals/_form_stepper.html.erb +++ b/app/views/goals/_form_stepper.html.erb @@ -114,12 +114,12 @@ <div class="flex items-center gap-3"> <%= render DS::FilledIcon.new(variant: :container, icon: "target", size: "lg", rounded: false) %> <div class="min-w-0 flex-1"> - <p class="text-base font-medium text-primary truncate" data-goal-stepper-target="reviewName">—</p> - <p class="text-sm text-secondary tabular-nums" data-goal-stepper-target="reviewSummary">—</p> + <p class="text-base font-medium text-primary truncate" data-goal-stepper-target="reviewName">…</p> + <p class="text-sm text-secondary tabular-nums" data-goal-stepper-target="reviewSummary">…</p> </div> </div> - <p class="text-sm text-primary tabular-nums border-t border-subdued pt-3" data-goal-stepper-target="reviewSuggested">—</p> + <p class="text-sm text-primary tabular-nums border-t border-subdued pt-3" data-goal-stepper-target="reviewSuggested">…</p> </div> </section> diff --git a/app/views/goals/index.html.erb b/app/views/goals/index.html.erb index 2e47f37b7..e7981a51d 100644 --- a/app/views/goals/index.html.erb +++ b/app/views/goals/index.html.erb @@ -7,7 +7,7 @@ <% if @counts["all"].zero? %> <%= render "empty_state", linkable_account_count: @linkable_account_count %> <% else %> - <%# KPI strip — Contributed last 30d (with prior-30d comparison) / Needs / On track %> + <%# KPI strip. Contributed last 30d (with prior-30d comparison) / Needs / On track %> <section class="grid grid-cols-1 md:grid-cols-3 gap-3"> <div class="bg-container rounded-xl shadow-border-xs px-5 py-5"> <p class="text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t(".kpi.contributed_label") %></p> diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index 4dd758b1c..be63409e6 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -148,10 +148,10 @@ <% end %> <% end %> <% elsif @goal.status == :behind && @goal.monthly_target_amount && @goal.catch_up_delta_money.amount.positive? %> - <%# Title uses the *delta* — the amount the user must add to current pace + <%# Title uses the *delta*. the amount the user must add to current pace to make the date, *after* subtracting open pledges. When pending pledges already cover the gap, `catch_up_delta_money` returns zero - and this branch suppresses — no "Save $0/mo" demand. Pre-fill the + and this branch suppresses. no "Save $0/mo" demand. Pre-fill the pledge CTA with the delta too, so submitting once funds the gap instead of double-counting on top of pace and pending. %> <%= render DS::Alert.new(variant: "warning", title: t("goals.show.catch_up.title", amount: @goal.catch_up_delta_money.format(precision: 0))) do %> diff --git a/config/locales/views/goal_pledges/en.yml b/config/locales/views/goal_pledges/en.yml index 3124561e1..fd69a9672 100644 --- a/config/locales/views/goal_pledges/en.yml +++ b/config/locales/views/goal_pledges/en.yml @@ -8,8 +8,8 @@ en: account_label: Into account submit: Record pledge preview_zero: "Currently {current} of {target} saved." - preview_nonzero: "Reaches {percent}% — {newTotal} of {target}." - preview_reached: "Hits your {target} target — goal reached." + preview_nonzero: "Reaches {percent}%, {newTotal} of {target}." + preview_reached: "Hits your {target} target. Goal reached." create: success: Pledge recorded. Sure will confirm it on the next sync. renew: diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index 345a5efd1..a54db7143 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -146,7 +146,7 @@ en: adjust_target_cta: Adjust target instead confirm_complete_title: Mark this goal complete? confirm_complete_body: It leaves the Ongoing list. You can still archive or restore it later. - confirm_complete_body_short: "You're at %{progress}% — %{saved} of %{target}. Marking complete records this as your achievement instead of the original target. Continue, or close this and adjust the target instead?" + confirm_complete_body_short: "You're at %{progress}%, %{saved} of %{target}. Marking complete records this as your achievement instead of the original target. Continue, or close this and adjust the target instead?" confirm_complete_cta: Mark complete confirm_archive_title: Archive this goal? confirm_archive_body: Archived goals disappear from the main list. You can restore them later. @@ -173,7 +173,7 @@ en: cta: Set target date empty: heading: No deposits yet - body: Make a transfer into your linked account — Sure will catch it on the next sync. Or update your manual account balance. + body: Make a transfer into your linked account. Sure will catch it on the next sync. Or update your manual account balance. errors: not_found: This goal couldn't be found. It may have been deleted. states: