fix(goals): unified per-goal account color map + smaller pen toggle

User flagged two regressions: account colors didn't match between the
goal preview-card avatar stack on the index and the funding-widget
rows on the show page, and the color-picker pen toggle on the new-goal
modal still felt too big.

Color matching:

- `AccountStackComponent` (index card) used
  `Goals::AvatarComponent.color_for(account.name)` — MD5-of-name into
  the 10-color palette.
- `FundingAccountsBreakdownComponent` (show page) recently switched to
  `color_for(account.id.to_s)` — MD5-of-id.
- Same account, two surfaces, two different palette picks. Plus
  either hashing scheme can collide within a multi-account goal
  (palette has 10 colors).

Move ownership to the Goal model: `Goal#account_color_map` returns
`{ account_id => palette_hex }` for the goal's linked accounts. Sort
by `id` for a stable order across reloads, then assign
`palette[i % palette.size]`. Stable + collision-free up to 10
accounts in a single goal (a realistic upper bound — most goals
link 1-3).

Both consumers now read off the same source:

- `AccountStackComponent.new(accounts:, color_map:)` accepts a hash
  and falls back to the name-hash if no map provided (kept for
  callers that don't have a goal in scope yet).
- `FundingAccountsBreakdownComponent#color_for` reads
  `goal.account_color_map[account.id]`.
- Goal card on index passes `goal.account_color_map` to the stack.

Pen toggle:

The new-goal color-picker pen sat in a `w-5 h-5` circle with a
`border` ring + `text-secondary` icon. The border + secondary text
weight kept it loud against the avatar even at 20px. Drop the
border, drop the size another step (`w-4 h-4`), recolor the icon
`text-subdued` + `hover:text-secondary` so the affordance recedes
when not interacted with. Position shifts from `-bottom-1 -right-1`
(8px overhang) to `-bottom-0.5 -right-0.5` (2px overhang) since the
smaller circle doesn't need the larger float. Icon swaps "pen" for
"pencil" (the more conventional edit indicator across Sure).
This commit is contained in:
Guillem Arias
2026-05-14 22:30:26 +02:00
parent 263ccbf5cc
commit f182da79c8
6 changed files with 33 additions and 9 deletions

View File

@@ -1,7 +1,7 @@
<span class="inline-flex items-center" aria-hidden="true">
<% shown.each_with_index do |account, i| %>
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-inverse text-[9px] font-semibold ring-2 ring-container"
style="background-color: <%= Goals::AvatarComponent.color_for(account.name) %>; <%= "margin-left: -6px;" if i > 0 %>"
style="background-color: <%= color_for(account) %>; <%= "margin-left: -6px;" if i > 0 %>"
title="<%= account.name %>">
<%= initial_for(account) %>
</span>

View File

@@ -1,7 +1,8 @@
class Goals::AccountStackComponent < ApplicationComponent
def initialize(accounts:, max: 3)
def initialize(accounts:, max: 3, color_map: nil)
@accounts = accounts
@max = max
@color_map = color_map || {}
end
def shown
@@ -15,4 +16,12 @@ class Goals::AccountStackComponent < ApplicationComponent
def initial_for(account)
account.name.to_s.strip.first&.upcase || "?"
end
# Color for this account, sourced from the per-goal color map when the
# caller provided one (so the stack on the index card matches the funding
# widget on the show page). Falls back to the name-hashed palette pick
# for backward compatibility with any caller that didn't pass `color_map:`.
def color_for(account)
@color_map[account.id] || Goals::AvatarComponent.color_for(account.name)
end
end

View File

@@ -55,7 +55,7 @@
<div class="mt-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<%= render Goals::AccountStackComponent.new(accounts: linked_accounts) %>
<%= render Goals::AccountStackComponent.new(accounts: linked_accounts, color_map: goal.account_color_map) %>
<span class="text-xs text-subdued"><%= linked_accounts_count_label %></span>
</div>
<span class="text-xs text-subdued tabular-nums"><%= footer_line %></span>

View File

@@ -30,11 +30,11 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
((balance.to_d / total) * 100).round
end
# Deterministic palette pick keyed on account.id (not name) so renaming an
# account doesn't recolor it retroactively across every goal view, and two
# accounts with colliding name-hashes don't end up sharing a color.
# Pull from the goal's per-goal account color map so the colors here
# (distribution bar, row avatars) match the AccountStackComponent on the
# index card. Stable + collision-free within the goal up to PALETTE size.
def color_for(account)
Goals::AvatarComponent.color_for(account.id.to_s)
goal.account_color_map[account.id] || Goals::AvatarComponent.color_for(account.name)
end
# Label shown beneath the account name. Prefers the depository subtype

View File

@@ -268,6 +268,21 @@ class Goal < ApplicationRecord
any_connected_account? ? "goals.show.pledge_just_transferred" : "goals.show.pledge_just_saved"
end
# { account_id => palette_hex } for this goal's linked accounts. Stable
# within a goal (so the preview-card avatar stack on the index and the
# funding-widget rows + distribution bar on the show page agree on which
# color belongs to which account) and collision-free up to PALETTE size
# (10 colors). Sort by id so the assignment doesn't shuffle when the
# accounts are re-loaded in a different order.
def account_color_map
@account_color_map ||= begin
palette = Goals::AvatarComponent::PALETTE
linked_accounts.sort_by(&:id).each_with_index.to_h do |account, i|
[ account.id, palette[i % palette.size] ]
end
end
end
# Single source of truth for the projection-chart subtitle / chart-aria
# description. Used to live inline in show.html.erb as a 17-line if/elsif
# chain. Returns an `html_safe` string when it picks the `_html` variant.

View File

@@ -15,8 +15,8 @@
</span>
<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 w-5 h-5 border-subdued rounded-full text-secondary">
<%= icon("pen", size: "xs", class: "w-3 h-3") %>
<summary class="cursor-pointer absolute -bottom-0.5 -right-0.5 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover w-4 h-4 rounded-full text-subdued hover:text-secondary">
<%= icon("pencil", size: "xs") %>
</summary>
<div class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50 bg-container p-3 border border-alpha-black-25 rounded-2xl shadow-xs w-80 max-w-[calc(100vw-2rem)] max-h-[60vh] overflow-y-auto"