fix(goals): demote Behind pill to neutral surface + drop em-dashes

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.
This commit is contained in:
Guillem Arias
2026-05-14 22:12:52 +02:00
parent da4af43a7d
commit 880ca69657
13 changed files with 39 additions and 39 deletions

View File

@@ -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() {

View File

@@ -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 <title> 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")

View File

@@ -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 = "";
}
}
}