From da4af43a7d65ba65deacea407283ee0733bafcca Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Thu, 14 May 2026 22:07:32 +0200 Subject: [PATCH] fix(goals/new): avatar default icon + restore .goal-avatar color-mix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../color_icon_picker_controller.js | 8 +++++-- .../controllers/goal_stepper_controller.js | 21 ++++++++++++++++--- app/views/goals/_color_picker.html.erb | 7 +++++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/app/javascript/controllers/color_icon_picker_controller.js b/app/javascript/controllers/color_icon_picker_controller.js index ec0ab6457..55138dd4a 100644 --- a/app/javascript/controllers/color_icon_picker_controller.js +++ b/app/javascript/controllers/color_icon_picker_controller.js @@ -85,8 +85,12 @@ export default class extends Controller { } updateAvatarColors(color) { - this.avatarTarget.style.backgroundColor = `${this.#backgroundColor(color)}`; - this.avatarTarget.style.color = color; + // Update the `--avatar-color` CSS variable instead of overriding + // `style.color` / `style.backgroundColor` directly. The `.goal-avatar` + // class does theme-aware `color-mix` work off the variable (light mode + // darkens the letter, dark mode uses the full color) — overriding the + // resolved values inline killed that contrast logic. + this.avatarTarget.style.setProperty("--avatar-color", color); } handleIconColorChange(e) { diff --git a/app/javascript/controllers/goal_stepper_controller.js b/app/javascript/controllers/goal_stepper_controller.js index e40737676..69695b648 100644 --- a/app/javascript/controllers/goal_stepper_controller.js +++ b/app/javascript/controllers/goal_stepper_controller.js @@ -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() { diff --git a/app/views/goals/_color_picker.html.erb b/app/views/goals/_color_picker.html.erb index 4462d5ccc..5ccba3b92 100644 --- a/app/views/goals/_color_picker.html.erb +++ b/app/views/goals/_color_picker.html.erb @@ -3,11 +3,14 @@
+ data-color-icon-picker-target="avatar" + data-goal-stepper-target="avatarPreview"> <% if form.object.icon.present? %> <%= icon(form.object.icon, color: "current", size: "md") %> + <% elsif form.object.name.present? %> + <%= form.object.name.strip.first&.upcase %> <% else %> - <%= form.object.name.to_s.strip.first&.upcase || "?" %> + <%= icon("target", color: "current", size: "md") %> <% end %>