Files
sure/savings-audit-refactoring-ui.md
Guillem Arias cf4e560a4c feat(goals): extract shared color_icon_picker controller; add icon to goals; tinted avatar
User requested replacing the in-house color disclosure with the
categories color+icon popover. Done as a controller extraction so
categories and goals share one Stimulus controller (user's option:
"Extract a shared color_icon_picker_controller.js").

- `git mv` app/javascript/controllers/category_controller.js to
  color_icon_picker_controller.js. Categories form + color_avatar
  partial updated to use the new identifier (data-controller=
  "color-icon-picker", target/action selectors renamed).
- Goal model gains an icon column (migration
  20260511190000_add_icon_to_goals.rb) + ICONS = Category.icon_codes
  + inclusion validation. GoalsController permits :icon in
  goal_params + goal_update_params.
- Goals::AvatarComponent now renders icon when present (falls back to
  first-letter initial), and adopts the Categories tinted-bg + colored
  -content style (bg = `color-mix(in oklab, COLOR 10%, transparent)`,
  text/icon = COLOR). Matches the picker's live preview so what the
  user sees during selection equals the saved state.
- New goals/_color_picker.html.erb mirrors categories/_form's popover:
  avatar + pen overlay summary + popup with color row (+ rainbow
  custom-hex trigger) + icon grid. Pickr / contrast validation / auto-
  adjust all inherited from the shared controller.
- Stepper step 1 layout: drop the inline letter-avatar (data-goal-
  stepper-target="avatarPreview") in favour of the picker avatar next
  to the name input. Step 1's tail no longer renders a separate color
  partial. Edit form passes icons local through.

Verified live: new goal modal renders 11 color radios (10 presets +
custom) + 141 icon radios + pen-summary; categories form still
operational (no console errors) under the renamed controller.
2026-05-11 21:28:23 +02:00

465 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Savings Goals — Refactoring UI Audit
Branch `feat/savings-goals` (we-promise/sure). Read-only audit using
Wathan & Schoger's *Refactoring UI*. Each finding cites a file:line,
severity (P0 ship-blocker, P1 fix-before-merge, P2 nice-to-have), the
RUI rule it breaks, and a minimal fix. Grouped by surface; Top-10
"ship now" at the bottom.
## Coverage caveat
Shots in `audit-shots/`. Clean captures: index (light/dark/mobile),
behind/on-track/no-target-date/reached/paused show, new-goal modal step
1, mobile show. Demo data regenerated mid-session and invalidated some
goal IDs, so edit-modal, add-contribution modal, contribution
delete-confirm, archived show, no-accounts empty state, and some mobile
modal states were read from source rather than from screenshots. All
findings are still grounded in real code paths.
---
## 1. Index — populated (light/dark/mobile)
`app/views/savings_goals/index.html.erb:11-68`**P1** — KPI strip
gives three metrics identical visual weight. *Hierarchy — actions/stats
live in a pyramid.* All three cards use `text-3xl font-medium` on
`bg-container`; nothing answers the user's actual landing question ("am I
winning?"). Elevate "Goals on track" to a primary card with a larger
numeral + a ring/bar; demote velocity + needs to compact secondary
cards (`text-xl`, tighter padding).
`app/views/savings_goals/index.html.erb:13,36,49`**P2** — Three card
panels read as one grey block in dark mode. *Depth — light-from-above.*
`bg-container` differs from `bg-app` by only ~4% L\* in dark. Add a faint
top-edge highlight (`ring-1 ring-inset ring-white/5`) so cards read as
raised, not tinted.
`app/views/savings_goals/index.html.erb:14,37,50`**P2** — Three
adjacent eyebrows `text-[11px] uppercase tracking-wide text-secondary`
create striped chrome. *Typography — all-caps as decoration.* Commit to
one all-caps style (KPI eyebrows OR section headings, not both).
`app/views/savings_goals/index.html.erb:92-119`**P1** — Search input
and chip group compete. *Forms — most-common action wins the row.*
Search uses `border border-secondary bg-container`; chips use
`bg-surface-inset` segmented. Drop the search border + `bg-surface-inset`,
cap to `md:max-w-xs`. At 1440 search currently stretches 600+px and
swallows the chip group's importance.
`app/views/savings_goals/index.html.erb:107`**P1** — Chip filter
values duplicate status-pill semantics but in a different visual
language. *Color — single palette across the app.* Add a 1.5×1.5
colored dot prefix to each chip (same green/yellow/grey as the pill) so
filter and chip share visual identity.
`app/views/savings_goals/index.html.erb:123-127`**P2** — "ONGOING ·
5" section heading shares chrome with KPI eyebrows. *Hierarchy — don't
repeat your hierarchy gestures.* Use `text-sm font-medium text-secondary`
for section headings; reserve all-caps eyebrows for KPI cards.
`app/components/savings/goal_card_component.html.erb:8-41`**P1**
Card has three competing focal points: avatar + name + pill, big
balance/target, and a ring with overlaid percent. *Cards — one job per
card.* Percent inside the ring repeats `current/target` underneath.
Drop one; let geometry tell the story OR let the numbers.
`app/components/savings/goal_card_component.html.erb:33-35`**P2**
`stroke-linecap="round"` is on the progress arc only. *Finishing
touches — consistency.* Apply to both circles for future-proofing
partial-track variants.
`app/components/savings/goal_card_component.html.erb:46`**P2**
`/ $50,000.00` is `text-xs text-subdued`. *Typography — hierarchy via
weight not just color.* In dark mode at 12px the slash + number sit
near noise. Bump to `text-secondary`; the slash already marks this as
secondary.
`app/components/savings/goal_card_component.html.erb:53-58`**P2**
Footer line wraps on narrow cards. *Spacing — fixed widths break.*
Stack vertically (`flex-col gap-1`) or shorten to "+$1,531/mo to catch
up" (cents are noise at card density).
`app/components/savings/account_stack_component.html.erb:3-12`**P2**
— 20px avatars with `text-[9px]` initials are unreadable. *Imagery —
intended sizes.* Bump to 24px or drop initials and rely on hover-title.
`app/components/savings/account_stack_component.html.erb:3`**P1**
`ring-2 ring-container` collapses in dark mode (ring color matches
page bg). *Depth — rings fake separation from the surface beneath.*
Use `ring-app` when the stack is on the page surface, `ring-container`
when on a card.
---
## 2. Show — header & action region
`app/views/savings_goals/show.html.erb:2-7`**P1** — H1 + status
pill share a row; pill is `text-xs` next to `text-2xl`. *Hierarchy —
status is meta, not a peer of the name.* Move pill to the secondary
line. Long names ("Investment property downpayment") currently truncate
to "House …" on mobile because of the pill.
`app/views/savings_goals/show.html.erb:33-49`**P1** — Edit (outline)
+ Add contribution (primary) + kebab. *Hierarchy — action pyramid.*
Pause/Resume/Complete/Archive are state changes hidden in the kebab
*after* the primary CTA. Promote Pause/Resume to a `ghost` button beside
Edit; keep Archive/Delete in the menu.
`app/views/savings_goals/show.html.erb:6-22`**P2** — Subtitle joins
target amount + date + days-left with " · ". *Typography — line length.*
At 1440 ~80ch in one parse-heavy sentence. Stack: deck line under H1,
then meta on next line.
`app/views/savings_goals/show.html.erb:26-31`**P2** — "Last
contribution 30 days ago" uses `mt-0.5` — ambiguous grouping with the
subtitle. *Spacing.* Increase to `mt-2` and give it a `clock-3` icon.
---
## 3. Show — alert banners
`app/views/savings_goals/show.html.erb:81-127`**P1** — Three
mutually-exclusive banners use the wrong variants. *Color — variant
maps to intent.* Paused = `info` (blue), archived = `info` (blue),
catch-up = `warning` (yellow, correct). Paused is a *user-chosen
neutral state*, not info; archived is *historical*. Use a neutral
banner (`bg-surface-inset`) for paused + archived.
`app/views/savings_goals/show.html.erb:86-89,98-101`**P0**
Resume/Restore CTAs use raw `class="inline-flex items-center gap-1
rounded-md px-3 py-2 ... bg-inverse hover:bg-inverse-hover"`.
*Finishing touches — supercharge defaults.* Re-implements the primary
button by hand — focus ring, loading state, disabled state diverge.
Use `DS::Button` / `DS::Link` like the catch-up CTA at line 117-124
already does.
`app/views/savings_goals/show.html.erb:108`**P2** — Catch-up title
"Save $1,531.25/mo to catch up" repeats verbatim in the CTA "Add
$1,531.25". *Hierarchy — redundant verbs.* Title states the rate; CTA
should state the verb ("Add this month" or "Add contribution").
---
## 4. Show — ring + projection
`app/views/savings_goals/show.html.erb:130-140`**P1** — Ring card
shows percent in donut center AND `$1,320 of $2,400 · $1,080 to go`
underneath. *Cards — focal point.* Same redundancy as goal card but
louder. Strip the percent from the ring or strip the dollar line.
`app/views/savings_goals/show.html.erb:185`**P2** — Projection arc
color picks green vs yellow from status. *Color — limited palette is a
feature.* For paused goals the projection still draws a confident
forecast (see Sabbatical screenshot). When paused, color the
projection `var(--color-gray-400)` and label it "If you resume."
`app/views/savings_goals/show.html.erb:179-201`**P1** — Chart card
stacks heading + summary + legend above `min-h-[200px]` chart. *Spacing
— charts need room.* At 1280 the summary wraps to two lines and eats
chart height. Move the summary into the chart as an annotation, or
push it below as a caption.
`app/views/savings_goals/show.html.erb:142-158`**P2** — Reached
celebration card = 64px disc icon + heading + body + archive button.
*Finishing touches — celebration moments deserve reward.* Add a subtle
pattern or a mini saved-progress chart so the "$15k done in 18 months"
story lands.
`app/views/savings_goals/show.html.erb:159-177`**P2** — No-target-
date card uses identical chrome to the celebration card (h3 + p + sm
outline button). *Hierarchy — different intents should look different.*
Use `bg-green-500/10` accent for celebration only; keep no-target
neutral with smaller body copy.
---
## 5. Show — stats row + bottom row
`app/views/savings_goals/show.html.erb:209-229`**P1** — Combo pace
card crams 5 facts on two lines: avg + /mo + target + delta. *Typography
— chunking.* The `text-2xl` + `text-sm` + `text-subdued` baseline-mix
forces left-to-right prose reading. Split into two side-by-side stats
(Avg vs Target) OR put the "Behind by" delta into a `text-warning`
pill on row 1 — current `text-subdued` hides the whole point.
`app/views/savings_goals/show.html.erb:233-237`**P1** — Total
contributions card displays "12 · Across all accounts" — not linked,
not actionable. *Cards — make stats actionable.* Link to scroll/filter
the list below or replace with a more useful stat (median amount,
biggest this month). The "Across all accounts" label is also wrong for
single-account goals.
`app/views/savings_goals/show.html.erb:241-256`**P2** — Two-column
`[1.6fr | 1fr]` clips both columns at 1280. *Spacing — relative weight
should match density.* Equal columns or stack at lg below 1280.
`app/views/savings_goals/_contributions_list.html.erb:10-44`**P2**
Row `px-2 py-2` is tight. *Spacing — list rows want breathing.* Bump
to `py-3`.
`app/components/savings/funding_accounts_breakdown_component.html.erb:4-10`
**P1** — Stacked bar is `h-2`. *Dashboards — data viz needs minimum
size.* 8px is below the threshold where color differences register —
especially dark mode. Bump to `h-3` with `ring-inset ring-black/5`.
`app/components/savings/funding_accounts_breakdown_component.html.erb:18`
**P2** — Meta line `text-[11px]` and percent `text-[10px]` are
off-scale. *Typography — type ramp.* The design system jumps 12→14→16.
Use `text-xs text-subdued` consistently.
`app/components/savings/funding_accounts_breakdown_component.html.erb:7`
**P2** — Bar segment uses MD5(name) color. *Color — deterministic
identity is good, hierarchy is bad.* If two accounts hash close,
segments blur. Post-process to shift adjacent segments through the
palette.
---
## 6. New-goal modal — step 1
`app/views/savings_goals/_form_stepper.html.erb:9-19`**P1** — Stepper
labels are equal-weight, only the fill differentiates. *Forms — progress
disclosure.* Make active circle 32px and inactive 28px so focus reads
through size, not just color.
`app/views/savings_goals/_form_stepper.html.erb:30-32`**P2** — Avatar
preview at `size: "md"` (36px) vs xl (64px) on the show page. *Forms —
visual feedback should match destination.* Use `size: "lg"` (44px).
`app/views/savings_goals/_form_stepper.html.erb:43-53`**P1**
Target amount + target date in `grid-cols-2`. Money field uses styled
form chrome; date field uses native HTML date input. *Forms — side-by-
side requires same input language.* Match chrome on the date field or
stack them.
`app/views/savings_goals/_form_stepper.html.erb:56-87`**P0**
Funding accounts list has no helper text. *Forms — required fields
visible.* Empty submit shows a tiny error below the list. Add a hint
under the section label: "Choose where contributions will come from."
`app/views/savings_goals/_form_stepper.html.erb:64-74`**P1**
Checkbox + row click target is good but checked state is only a 16×16
checkmark. *Selectable cards.* Checked row should swap to
`bg-surface-inset` with a filled-blue checkbox; hover stays subtle.
`app/views/savings_goals/_form_stepper.html.erb:80`**P2** — Balance
column matches account name weight (`text-sm font-medium`).
*Typography.* Bump balance to `text-secondary` so the eye distinguishes
selectable label from metadata.
`app/views/savings_goals/_form_stepper.html.erb:89-94`**P2** — Notes
disclosure is right-aligned; breaks scanning. *Forms — progressive
disclosure.* Left-align like the rest of the form.
`app/views/savings_goals/_form_stepper.html.erb:96`**P2** — Color
field is hidden in step 1; only edit form exposes the palette. *Forms —
silent state.* Either expose a small swatch row by the name field or
document the auto-pick.
---
## 7. New-goal modal — step 2
`app/views/savings_goals/_form_stepper.html.erb:99-123`**P2**
Review card weights "Funding accounts: 2" and "Suggested monthly:
$X/mo" equally. *Hierarchy — review should restate the commitment.*
Suggested monthly is the actionable fact; weight it as `text-base
text-primary`.
`app/views/savings_goals/_form_stepper.html.erb:125-152`**P1**
Initial-contribution disclosure has `include_blank: "Select account"`
on the select. If user opens it and forgets the select, submit silently
fails or zero-submits. *Forms — completeness.* Either require the
account when disclosure is open or auto-populate with the first linked
account.
`app/views/savings_goals/_form_stepper.html.erb:155-181`**P2**
Footer uses `hidden` (not `invisible`) on the Back button. *Forms —
nav visibility.* Continue button slides between steps. Use `invisible`
or `ml-auto` on Continue.
---
## 8. Edit modal
`app/views/savings_goals/_form_edit.html.erb:23-34`**P1** — Color
palette = 6 24×24 swatches with `peer-checked:ring-2`. *Forms —
selectable swatches; imagery — tap targets.* 24px is below iOS 44px
threshold. Bump to 32px, add `aria-label` per radio, show a `check`
icon inside selected swatch.
`app/views/savings_goals/_form_edit.html.erb:38`**P2** — Notes
textarea is 2 rows; stepper form's notes is 3 rows. *Forms — match
textarea sizing.* Use 3.
`app/views/savings_goals/_form_edit.html.erb:40-42`**P2** — Bare
`f.submit` without explicit variant. *Buttons — supercharge defaults.*
Wrap in `DS::Button` like new modal does.
`app/views/savings_goals/edit.html.erb:1-7`**P2** — Edit uses
default `DS::Dialog` title; new uses custom header with FilledIcon.
*Consistency — same logical action, different modal frame.* Match
headers or downgrade new.
---
## 9. Add-contribution modal
`app/views/savings_contributions/new.html.erb:11-23`**P2** — Form
order is fine but the account select has `include_blank` even when
only one account is linked. *Finishing touches — smart defaults.* Pre-
select first account when there's only one.
`app/views/savings_contributions/new.html.erb:14`**P2** — Money
field uses `hide_currency: true`. *Forms — currency clarity.* If the
goal's currency differs from primary, the user can mis-type. Show a
currency badge or put it in the label.
`app/views/savings_contributions/new.html.erb:25-27`**P2** — Same
bare `f.submit` as edit modal. Wrap in `DS::Button`.
---
## 10. Contribution row — kebab + delete-confirm
`app/views/savings_goals/_contributions_list.html.erb:24-43`**P1**
Kebab only renders for `contribution.manual?`. Non-manual rows show an
invisible `w-9 h-9` placeholder. *Spacing — don't reserve space
silently.* Good for alignment but no affordance for "why no kebab." Add
a small lock icon or "Imported" tag in the source line.
`app/views/savings_goals/_contributions_list.html.erb:33-38`**P1**
`CustomConfirm` for delete uses `destructive: true`. *Modals —
destructive needs clear out.* Confirm cancel button text is "Cancel" or
"Keep," not modal-chrome "Close" — RUI calls out action-named cancel
buttons for destructive confirms.
---
## 11. Status pill — 5 variants
`app/components/savings/status_pill_component.rb:3-8`**P1** — Two
variants share `bg-green-500/10 text-success` (on_track + reached); two
share `bg-surface-inset text-secondary` (no_target_date + paused).
*Color — each meaningful state needs distinct visuals.* Reached and
on-track are semantically different. Same for no-date and paused. Give
reached an amber/gold accent; give paused `text-subdued` to mute it.
`app/components/savings/status_pill_component.html.erb:1-4`**P2**
Pill `gap-1` is tight at `text-xs`. *Imagery — pill density.* Bump to
`gap-1.5` and `tracking-tight`.
`app/components/savings/status_pill_component.rb:6`**P2** — Icon
for `no_target_date` is `infinity`. Reads as "unlimited" not "no
deadline." Use `calendar-x` or `calendar-question`.
---
## 12. Funding accounts breakdown
`app/components/savings/funding_accounts_breakdown_component.html.erb:1-2`
**P2** — Empty state is one `<p>`. *Empty states — don't leave users
hanging.* Add a muted icon + CTA "Add your first contribution."
`app/components/savings/funding_accounts_breakdown_component.html.erb:12-26`
**P2**`space-y-3` between 3-line rows visually merges them.
*Spacing — list density.* Use `divide-y divide-subdued` or `space-y-4`.
---
## 13. Empty state — first run
`app/views/savings_goals/_empty_state.html.erb:3-29`**P1** — Icon +
heading + body + button is functional but visually generic. *Empty
states — first-run sells the feature.* Replace the 32px target icon
with a muted hero illustration showing what a populated goal looks like.
`app/views/savings_goals/_empty_state.html.erb:19-26`**P0** — When
`linkable_account_count == 0`, CTA goes to `new_account_path` with no
return path. *Empty states — guide the flow.* After account creation
the user lands on /accounts/new redirects, not /savings_goals. Add
`?return_to=/savings_goals` and a 2-step preview ("1. Connect 2. Set").
`app/views/savings_goals/_empty_state.html.erb:5-7`**P2** — Icon
container `bg-surface-inset` differs from `bg-container` by ~5% L\*.
*Depth.* Use `bg-app` to invert the relief (card > inset > icon).
---
## 14. Mobile (375×667)
`app/views/savings_goals/index.html.erb:11`**P1** — KPI strip
collapses to single-column. *Dashboards — mobile collapse.* Three
stacked full-width cards read as a notifications page. 2x2 with one
spanning, or compact "stat lines" (eyebrow + numeral inline).
`app/views/savings_goals/show.html.erb:33-78`**P0** — Header action
group truncates the goal name on mobile (captured: "House …").
*Hierarchy — action bar must collapse.* Demote Edit + kebab to a sheet;
keep only "Add contribution" visible. Name must always show.
`app/views/savings_goals/show.html.erb:130`**P1** — Stacked
ring/chart cards on mobile have no gap. *Spacing.* Add `space-y-3` on
the section so eye doesn't flow from "13%" into the chart axis.
`app/views/savings_goals/_form_stepper.html.erb:155-176`**P2**
Continue button isn't sticky on mobile. *Forms — mobile primary CTA.*
After selecting accounts, user scrolls back up to find Continue. Make
the footer `sticky bottom-0` on mobile.
---
## 15. Sidebar, breadcrumbs, header chrome
`app/views/savings_goals/index.html.erb:2-4`**P2** — Subtitle "Your
savings accounts and the goals you're working toward" shows every
visit. *Typography — page subtitles carry decoration not info.* Replace
with current-period context ("$2,940 saved in May 2026") or hide after
first visit.
`config/locales/breadcrumbs/en.yml` (savings entry) — **P2**
"Home Savings Goal name" on mobile wastes ~40px vertical. *Nav —
levels.* Drop "Home" on mobile or replace with a back chevron.
`app/views/savings_goals/show.html.erb:2`**P2** — No explicit "Back
to Savings" link near the H1. *Nav — back affordance.* The breadcrumb
is chrome, not content. Add an arrow-left button next to the avatar.
---
## Top 10 ship-now
1. `show.html.erb:86-89,98-101` **P0** — Resume/Restore banner CTAs
reimplement the primary button by hand. Replace with `DS::Button` so
focus/hover/disabled match.
2. `show.html.erb:33-78` mobile **P0** — Header truncates goal name on
mobile. Demote Edit + kebab to a sheet, keep only Add contribution.
3. `_form_stepper.html.erb:56-87` **P0** — Funding accounts list needs
an explicit hint *before* the user clicks Continue.
4. `_empty_state.html.erb:19-26` **P0** — No-accounts state needs a
return-to-savings_goals path after account creation + 2-step preview.
5. `index.html.erb:11-68` **P1** — Three equal-weight KPIs hide the one
answering "am I winning?". Elevate "Goals on track" to primary card.
6. `status_pill_component.rb:3-8` **P1** — Reached + on-track share
green; paused + no-target share grey. Give reached a gold accent;
give paused a true muted look.
7. `show.html.erb:130-140` + `goal_card_component.html.erb:8-41` **P1**
— Ring + numeric percent + dollar/target trio is redundant. Drop one.
8. `show.html.erb:81-127` **P1** — Paused + archived banners use
info-blue. Use neutral; reserve info-blue for actual info.
9. `index.html.erb:92-119` **P1** — Search/chip toolbar mismatch. Cap
search at `max-w-xs`, drop its border, add colored dots to chips.
10. `funding_accounts_breakdown_component.html.erb:4` **P1** — Stacked
bar `h-2` is too thin. `h-3` + 1px inset ring lifts it from
decoration to data.
---
## Closing notes
- Screenshots in `/Users/guillem.arias/Documents/gariasf/sure/audit-shots/`.
- No code edits made. Browser closed.
- Surfaces read from source rather than captured: add-contribution
modal, contribution delete-confirm, archived show, no-accounts empty
state, most mobile modal states. Findings still ground in real code.