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.
@@ -1,6 +1,10 @@
|
||||
<span class="inline-flex items-center justify-center text-inverse font-semibold <%= box_classes %> <%= text_classes %> <%= radius_classes %>"
|
||||
style="background-color: <%= color %>;"
|
||||
<span class="inline-flex items-center justify-center font-semibold <%= box_classes %> <%= text_classes %> <%= radius_classes %>"
|
||||
style="background-color: color-mix(in oklab, <%= color %> 10%, transparent); color: <%= color %>;"
|
||||
aria-hidden="true"
|
||||
data-testid="goal-avatar">
|
||||
<%= initial %>
|
||||
<% if icon.present? %>
|
||||
<%= icon(icon, size: icon_size, color: "current") %>
|
||||
<% else %>
|
||||
<%= initial %>
|
||||
<% end %>
|
||||
</span>
|
||||
|
||||
@@ -16,20 +16,30 @@ class Goals::AvatarComponent < ApplicationComponent
|
||||
PALETTE[Digest::MD5.hexdigest(name).to_i(16) % PALETTE.size]
|
||||
end
|
||||
|
||||
def initialize(goal: nil, name: nil, color: nil, size: "md")
|
||||
def initialize(goal: nil, name: nil, color: nil, icon: nil, size: "md")
|
||||
@goal = goal
|
||||
@name = name || goal&.name
|
||||
@color = color || goal&.color || Goal::COLORS.first
|
||||
@icon = icon || goal&.icon
|
||||
@size = SIZES.key?(size) ? size : "md"
|
||||
end
|
||||
|
||||
attr_reader :color
|
||||
attr_reader :color, :icon
|
||||
|
||||
def initial
|
||||
return "?" if @name.blank?
|
||||
@name.strip.first&.upcase || "?"
|
||||
end
|
||||
|
||||
def icon_size
|
||||
case @size
|
||||
when "sm" then "xs"
|
||||
when "md" then "sm"
|
||||
when "lg" then "md"
|
||||
when "xl" then "xl"
|
||||
end
|
||||
end
|
||||
|
||||
def box_classes
|
||||
SIZES[@size][:box]
|
||||
end
|
||||
|
||||
@@ -154,11 +154,11 @@ class GoalsController < ApplicationController
|
||||
end
|
||||
|
||||
def goal_params
|
||||
params.require(:goal).permit(:name, :target_amount, :target_date, :color, :notes)
|
||||
params.require(:goal).permit(:name, :target_amount, :target_date, :color, :icon, :notes)
|
||||
end
|
||||
|
||||
def goal_update_params
|
||||
params.require(:goal).permit(:name, :target_amount, :target_date, :color, :notes)
|
||||
params.require(:goal).permit(:name, :target_amount, :target_date, :color, :icon, :notes)
|
||||
end
|
||||
|
||||
def lookup_accounts(ids)
|
||||
|
||||
@@ -2,11 +2,14 @@ class Goal < ApplicationRecord
|
||||
include AASM, Monetizable
|
||||
|
||||
COLORS = Category::COLORS
|
||||
ICONS = Category.icon_codes
|
||||
|
||||
# Virtual attributes used by the create-modal stepper to capture an
|
||||
# optional initial contribution alongside the goal create payload.
|
||||
attr_accessor :initial_contribution_amount, :initial_contribution_account_id
|
||||
|
||||
validates :icon, inclusion: { in: ICONS, allow_nil: true }
|
||||
|
||||
belongs_to :family
|
||||
has_many :goal_accounts, dependent: :destroy
|
||||
has_many :linked_accounts, through: :goal_accounts, source: :account
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%# locals: (category:) %>
|
||||
|
||||
<span
|
||||
data-category-target="avatar"
|
||||
data-color-icon-picker-target="avatar"
|
||||
class="w-14 h-14 flex items-center justify-center rounded-full"
|
||||
style="background-color: color-mix(in oklab, <%= category.color %> 10%, transparent); color: <%= category.color %>">
|
||||
<%= icon(category.lucide_icon, size: "2xl", color: "current") %>
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
<%# locals: (category:, categories:) %>
|
||||
|
||||
<div data-controller="category" data-category-preset-colors-value="<%= Category::COLORS %>">
|
||||
<div data-controller="color-icon-picker" data-color-icon-picker-preset-colors-value="<%= Category::COLORS %>">
|
||||
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
|
||||
<section class="space-y-4">
|
||||
<div class="w-fit mx-auto relative">
|
||||
<%= render partial: "color_avatar", locals: { category: category } %>
|
||||
|
||||
<details data-category-target="details" data-action="mousedown->category#handleOutsideClick">
|
||||
<details data-color-icon-picker-target="details" data-action="mousedown->color-icon-picker#handleOutsideClick">
|
||||
<summary class="cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-7 h-7 border-subdued rounded-full text-secondary">
|
||||
<%= icon("pen", size: "sm") %>
|
||||
</summary>
|
||||
|
||||
<div class="fixed right-0 sm:right-auto mx-2 sm:ml-8 sm:mr-0 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit" data-category-target="popup">
|
||||
<div class="flex gap-2 flex-col mb-4" data-category-target="selection" style="<%= "display:none;" if @category.subcategory? %>">
|
||||
<div data-category-target="pickerSection"></div>
|
||||
<div class="fixed right-0 sm:right-auto mx-2 sm:ml-8 sm:mr-0 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit" data-color-icon-picker-target="popup">
|
||||
<div class="flex gap-2 flex-col mb-4" data-color-icon-picker-target="selection" style="<%= "display:none;" if @category.subcategory? %>">
|
||||
<div data-color-icon-picker-target="pickerSection"></div>
|
||||
<h4 class="text-secondary text-sm">Color</h4>
|
||||
<div class="flex flex-wrap md:flex-nowrap gap-2 items-center" data-category-target="colorsSection">
|
||||
<div class="flex flex-wrap md:flex-nowrap gap-2 items-center" data-color-icon-picker-target="colorsSection">
|
||||
<% Category::COLORS.each do |color| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
|
||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-icon-picker#handleColorChange" } %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-500" style="background-color: <%= color %>"></div>
|
||||
</label>
|
||||
<% end %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, "custom-color", class: "sr-only peer", data: { category_target: "colorPickerRadioBtn"} %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" data-category-target="pickerBtn" style="background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)"></div>
|
||||
<%= f.radio_button :color, "custom-color", class: "sr-only peer", data: { color_icon_picker_target: "colorPickerRadioBtn"} %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" data-color-icon-picker-target="pickerBtn" style="background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center hidden flex-col" data-category-target="paletteSection">
|
||||
<div class="flex gap-2 items-center hidden flex-col" data-color-icon-picker-target="paletteSection">
|
||||
<div class="flex gap-2 items-center w-full">
|
||||
<div class="w-6 h-6 p-4 rounded-full cursor-pointer" style="background-color: <%= category.color %>" data-category-target="colorPreview"></div>
|
||||
<%= f.text_field :color, data: { category_target: "colorInput" }, inline: true, pattern: "^#[0-9A-Fa-f]{6}$" %>
|
||||
<%= icon "palette", size: "2xl", data: { action: "click->category#toggleSections" } %>
|
||||
<div class="w-6 h-6 p-4 rounded-full cursor-pointer" style="background-color: <%= category.color %>" data-color-icon-picker-target="colorPreview"></div>
|
||||
<%= f.text_field :color, data: { color_icon_picker_target: "colorInput" }, inline: true, pattern: "^#[0-9A-Fa-f]{6}$" %>
|
||||
<%= icon "palette", size: "2xl", data: { action: "click->color-icon-picker#toggleSections" } %>
|
||||
</div>
|
||||
<div data-category-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive ">
|
||||
<div data-color-icon-picker-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive ">
|
||||
<span>Poor contrast, choose darker color or</span>
|
||||
<button type="button" class="underline cursor-pointer" data-action="category#autoAdjust">auto-adjust.</button>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="flex flex-wrap gap-0.5 max-h-52 overflow-auto">
|
||||
<% Category.icon_codes.each do |icon| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->category#handleIconChange change->category#handleIconColorChange", category_target:"icon" } %>
|
||||
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->color-icon-picker#handleIconChange change->color-icon-picker#handleIconColorChange", color_icon_picker_target:"icon" } %>
|
||||
<div class="text-secondary w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-container-inset-hover peer-checked:bg-container-inset border-1 border-transparent">
|
||||
<%= icon(icon, size: "sm", color: "current") %>
|
||||
</div>
|
||||
@@ -64,7 +64,7 @@
|
||||
<div class="space-y-2">
|
||||
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
|
||||
<% unless category.parent? %>
|
||||
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->category#handleParentChange" } %>
|
||||
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->color-icon-picker#handleParentChange" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,17 +1,64 @@
|
||||
<%# locals: (form:, colors:) %>
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm text-secondary hover:text-primary flex items-center gap-1 py-2 list-none">
|
||||
<%= icon "chevron-right", size: "sm", class: "group-open:rotate-90 transition-transform" %>
|
||||
<span class="block w-4 h-4 rounded-full" style="background-color: <%= form.object.color %>"></span>
|
||||
<%= t("goals.form_stepper.step1.fields.color") %>
|
||||
</summary>
|
||||
<%# locals: (form:, colors:, icons:) %>
|
||||
<div data-controller="color-icon-picker" data-color-icon-picker-preset-colors-value="<%= colors %>">
|
||||
<div class="w-fit relative">
|
||||
<span class="inline-flex items-center justify-center w-11 h-11 rounded-xl font-semibold text-base"
|
||||
style="background-color: color-mix(in oklab, <%= form.object.color %> 10%, transparent); color: <%= form.object.color %>;"
|
||||
data-color-icon-picker-target="avatar">
|
||||
<% if form.object.icon.present? %>
|
||||
<%= icon(form.object.icon, color: "current", size: "md") %>
|
||||
<% else %>
|
||||
<%= form.object.name.to_s.strip.first&.upcase || "?" %>
|
||||
<% end %>
|
||||
</span>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-2 pl-4 border-l border-primary py-2">
|
||||
<% colors.each do |c| %>
|
||||
<label class="relative">
|
||||
<%= form.radio_button :color, c, class: "sr-only peer" %>
|
||||
<span class="block w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-alpha-black-300" style="background-color: <%= c %>"></span>
|
||||
</label>
|
||||
<% end %>
|
||||
<details data-color-icon-picker-target="details" data-action="mousedown->color-icon-picker#handleOutsideClick">
|
||||
<summary class="cursor-pointer absolute -bottom-1 -right-1 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-6 h-6 border-subdued rounded-full text-secondary">
|
||||
<%= icon("pen", size: "xs") %>
|
||||
</summary>
|
||||
|
||||
<div class="absolute left-0 sm:left-auto sm:right-0 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit w-87 max-w-[calc(100vw-2rem)]" data-color-icon-picker-target="popup">
|
||||
<div class="flex gap-2 flex-col mb-4" data-color-icon-picker-target="selection">
|
||||
<div data-color-icon-picker-target="pickerSection"></div>
|
||||
<h4 class="text-secondary text-sm">Color</h4>
|
||||
<div class="flex flex-wrap md:flex-nowrap gap-2 items-center" data-color-icon-picker-target="colorsSection">
|
||||
<% colors.each do |c| %>
|
||||
<label class="relative">
|
||||
<%= form.radio_button :color, c, class: "sr-only peer", data: { action: "change->color-icon-picker#handleColorChange" } %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-500" style="background-color: <%= c %>"></div>
|
||||
</label>
|
||||
<% end %>
|
||||
<label class="relative">
|
||||
<%= form.radio_button :color, "custom-color", class: "sr-only peer", data: { color_icon_picker_target: "colorPickerRadioBtn" } %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" data-color-icon-picker-target="pickerBtn" style="background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center hidden flex-col" data-color-icon-picker-target="paletteSection">
|
||||
<div class="flex gap-2 items-center w-full">
|
||||
<div class="w-6 h-6 p-4 rounded-full cursor-pointer" style="background-color: <%= form.object.color %>" data-color-icon-picker-target="colorPreview"></div>
|
||||
<%= form.text_field :color, data: { color_icon_picker_target: "colorInput" }, inline: true, pattern: "^#[0-9A-Fa-f]{6}$" %>
|
||||
<%= icon "palette", size: "2xl", data: { action: "click->color-icon-picker#toggleSections" } %>
|
||||
</div>
|
||||
<div data-color-icon-picker-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive">
|
||||
<span>Poor contrast, choose darker color or</span>
|
||||
<button type="button" class="underline cursor-pointer" data-action="color-icon-picker#autoAdjust">auto-adjust.</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-center flex-col">
|
||||
<h4 class="text-secondary text-sm">Icon</h4>
|
||||
<div class="flex flex-wrap gap-0.5 max-h-52 overflow-auto">
|
||||
<% icons.each do |icon_name| %>
|
||||
<label class="relative">
|
||||
<%= form.radio_button :icon, icon_name, class: "sr-only peer", data: { action: "change->color-icon-picker#handleIconChange change->color-icon-picker#handleIconColorChange", color_icon_picker_target: "icon" } %>
|
||||
<div class="text-secondary w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-container-inset-hover peer-checked:bg-container-inset border-1 border-transparent">
|
||||
<%= icon(icon_name, size: "sm", color: "current") %>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render "color_picker", form: f, colors: Goal::COLORS %>
|
||||
<%= render "color_picker", form: f, colors: Goal::COLORS, icons: Goal::ICONS %>
|
||||
|
||||
<%= f.text_area :notes,
|
||||
label: t("goals.form_stepper.step1.fields.notes"),
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="shrink-0" data-goal-stepper-target="avatarPreview">
|
||||
<%= render Goals::AvatarComponent.new(name: goal.name, color: goal.color, size: "md") %>
|
||||
</span>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="shrink-0 pt-6">
|
||||
<%= render "color_picker", form: f, colors: Goal::COLORS, icons: Goal::ICONS %>
|
||||
</div>
|
||||
<%= f.text_field :name,
|
||||
placeholder: t("goals.form_stepper.step1.fields.name_placeholder"),
|
||||
autofocus: true,
|
||||
@@ -104,7 +104,6 @@
|
||||
placeholder: t("goals.form_stepper.step1.fields.notes_placeholder") %>
|
||||
<% end %>
|
||||
|
||||
<%= render "color_picker", form: f, colors: Goal::COLORS %>
|
||||
</section>
|
||||
|
||||
<section data-goal-stepper-target="step2Panel" class="space-y-5 hidden">
|
||||
|
||||
BIN
audit-shots/01_index_dark_desktop.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
audit-shots/01_index_light_desktop.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
audit-shots/01_index_light_desktop_full.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
audit-shots/02_index_light_mobile.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
audit-shots/03_show_behind_dark.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
audit-shots/03_show_behind_light.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
audit-shots/04_show_on_track_dark.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
audit-shots/04_show_on_track_light.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
audit-shots/05_show_no_target_date_light.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
audit-shots/06_show_reached_light.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
audit-shots/07_show_paused_light.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
audit-shots/08_index_after_reseed_light.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
audit-shots/09_new_modal_step1_light.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
audit-shots/10_new_modal_step1_validation.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
audit-shots/11_show_behind_full.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
audit-shots/12_edit_modal_light.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
audit-shots/13_index_current_light.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
audit-shots/14_empty_state_desktop_light.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
audit-shots/14_empty_state_light.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
audit-shots/_login_state.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
audit-shots/chart/01-house-behind-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
audit-shots/chart/01-house-behind-desktop-light.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
audit-shots/chart/01-house-behind-desktop.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
audit-shots/chart/01-house-behind-mobile.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
audit-shots/chart/01-house-on_track-desktop-light.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
audit-shots/chart/01b-house-behind-mobile.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
audit-shots/chart/01c-house-behind-mobile-375.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
audit-shots/chart/01d-house-mobile-600w.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
audit-shots/chart/01e-house-mobile-375w.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
audit-shots/chart/02-vacation-behind-desktop-light.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
audit-shots/chart/03-wedding-on_track-desktop-light.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
audit-shots/chart/04-emergency-no_target_date-desktop.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
audit-shots/chart/05-sabbatical-paused-desktop.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
audit-shots/chart/05b-sabbatical-paused-desktop.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
audit-shots/chart/06-paidoff-reached-desktop.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
audit-shots/chart/06b-paidoff-reached-desktop.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
audit-shots/chart/07-laptop-archived-desktop.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
audit-shots/chart/E-theme-bug-dark-attr-but-light-chart.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
audit-shots/chart/F-empty-goal-no-contribs.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
audit-shots/chart/F-empty-goal.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
audit-shots/chart/resize-600.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
5
db/migrate/20260511190000_add_icon_to_goals.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddIconToGoals < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :goals, :icon, :string
|
||||
end
|
||||
end
|
||||
5
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_05_11_100003) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_05_11_190000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -658,7 +658,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_11_100003) do
|
||||
t.index ["goal_id", "contributed_at"], name: "index_goal_contributions_on_goal_id_and_contributed_at"
|
||||
t.index ["goal_id"], name: "index_goal_contributions_on_goal_id"
|
||||
t.check_constraint "amount > 0::numeric", name: "chk_savings_contributions_amount_positive"
|
||||
t.check_constraint "source::text = ANY (ARRAY['manual'::character varying, 'initial'::character varying]::text[])", name: "chk_savings_contributions_source_enum"
|
||||
t.check_constraint "source::text = ANY (ARRAY['manual'::character varying::text, 'initial'::character varying::text])", name: "chk_savings_contributions_source_enum"
|
||||
end
|
||||
|
||||
create_table "goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -672,6 +672,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_11_100003) do
|
||||
t.string "state", default: "active", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "icon"
|
||||
t.index ["family_id", "state"], name: "index_goals_on_family_id_and_state"
|
||||
t.index ["family_id"], name: "index_goals_on_family_id"
|
||||
t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length"
|
||||
|
||||
464
savings-audit-refactoring-ui.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# 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.
|
||||