fix(goals/new): avatar default icon + restore .goal-avatar color-mix

Two interlocking bugs on the new-goal modal's color/icon preview.

1. Avatar fell back to a literal "?" when icon + name were both
   blank — `form.object.name.to_s.strip.first&.upcase || "?"`. User
   reported the avatar looked empty on a fresh modal because the
   "?" disappears against many palette tints. Categories handle
   this by always showing the category icon. Replace the "?"
   fallback chain with a default `target` icon (matches the goal
   creation header's iconography):
     • icon present       → render that icon
     • icon blank, name   → render first letter
     • icon blank, no name → render default "target" icon

2. Picking a color via the Pickr color picker called
   `updateAvatarColors(color)` which inlined `style.backgroundColor`
   + `style.color = color` — overriding the `.goal-avatar` class's
   `color-mix(in oklab, var(--avatar-color) 55%, black)` rule. The
   class handles theme-aware contrast (darken text in light mode,
   full color in dark mode); the inline override killed it and
   text rendered at the same lightness as the 10% tint background.
   Update only the `--avatar-color` CSS variable; let the class
   continue computing the resolved colors.

Wire the avatar to the goal-stepper controller properly:
`_color_picker.html.erb` gains `data-goal-stepper-target="avatarPreview"`
on the span. `nameChanged` now updates the avatar directly (the
previous selector queried `[data-testid="goal-avatar"]` which
doesn't exist on the color_picker span) and:
- swaps to the first letter as the user types,
- restores the default-icon HTML (captured at connect) when the
  name is cleared,
- bails when the user has explicitly checked an icon radio (don't
  undo their choice).
This commit is contained in:
Guillem Arias
2026-05-14 22:07:32 +02:00
parent 62f8dc7514
commit da4af43a7d
3 changed files with 29 additions and 7 deletions

View File

@@ -51,6 +51,11 @@ export default class extends Controller {
connect() {
this.currentStep = 1;
this.refreshSubmitState();
// Capture the default avatar contents (the "target" icon SVG) so we
// can restore it when the user clears the name field after typing.
if (this.hasAvatarPreviewTarget) {
this._defaultAvatarHTML = this.avatarPreviewTarget.innerHTML;
}
}
blockEnter(event) {
@@ -108,10 +113,20 @@ export default class extends Controller {
this.clearFieldError(this.nameInputTarget, this.hasNameErrorTarget ? this.nameErrorTarget : null);
}
if (!this.hasAvatarPreviewTarget || !this.hasNameInputTarget) return;
// 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;
const name = this.nameInputTarget.value.trim();
const initial = name ? name.charAt(0).toUpperCase() : "?";
const inner = this.avatarPreviewTarget.querySelector('[data-testid="goal-avatar"]');
if (inner) inner.textContent = initial;
if (name) {
this.avatarPreviewTarget.textContent = name.charAt(0).toUpperCase();
} else if (this._defaultAvatarHTML) {
// Captured at connect — restore the default "target" icon from the
// server-rendered template, not a "?" character.
this.avatarPreviewTarget.innerHTML = this._defaultAvatarHTML;
}
}
validateStep1() {