add header background contrast warning and improve branding UX
All checks were successful
deploy / deploy (push) Successful in 1m12s

- extract dominant colors from header images during optimization
- calculate WCAG contrast ratios against theme text color
- show warning in theme editor when text may be hard to read
- prevent hiding shop name when no logo is uploaded
- auto-enable shop name when logo is deleted
- fix image cache invalidation on delete
- add missing .hidden utility class

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-08 22:40:08 +00:00
parent 32cc425458
commit 476da8121a
13 changed files with 429 additions and 220 deletions

View File

@@ -929,41 +929,32 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :logo_image, :map, default: nil
defp logo_inner(assigns) do
# Show logo if enabled and image exists
show_logo = assigns.theme_settings.show_logo && assigns.logo_image
# Show site name if enabled, or as fallback when logo should show but image is missing
show_site_name =
assigns.theme_settings.show_site_name ||
(assigns.theme_settings.show_logo && !assigns.logo_image)
assigns =
assigns
|> assign(:show_logo, show_logo)
|> assign(:show_site_name, show_site_name)
~H"""
<%= case @theme_settings.logo_mode do %>
<% "text-only" -> %>
<span class="shop-logo-text">
{@site_name}
</span>
<% "logo-text" -> %>
<%= if @logo_image do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@site_name}
class="shop-logo-img"
style={"height: #{@theme_settings.logo_size}px;"}
/>
<% end %>
<span class="shop-logo-text">
{@site_name}
</span>
<% "logo-only" -> %>
<%= if @logo_image do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@site_name}
class="shop-logo-img"
style={"height: #{@theme_settings.logo_size}px;"}
/>
<% else %>
<span class="shop-logo-text">
{@site_name}
</span>
<% end %>
<% _ -> %>
<span class="shop-logo-text">
{@site_name}
</span>
<%= if @show_logo do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@site_name}
class="shop-logo-img"
style={"height: #{@theme_settings.logo_size}px;"}
/>
<% end %>
<%= if @show_site_name do %>
<span class="shop-logo-text">
{@site_name}
</span>
<% end %>
"""
end

View File

@@ -3,7 +3,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
alias Berrypod.{Pages, Settings}
alias Berrypod.Media
alias Berrypod.Theme.{CSSGenerator, Presets, PreviewData}
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets, PreviewData}
alias Berrypod.Workers.FaviconGeneratorWorker
@impl true
@@ -39,6 +39,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|> assign(:customise_open, false)
|> assign(:sidebar_collapsed, false)
|> assign(:cart_drawer_open, false)
|> compute_header_contrast_warning()
|> allow_upload(:logo_upload,
accept: ~w(.png .jpg .jpeg .webp .svg),
max_entries: 1,
@@ -112,7 +113,12 @@ defmodule BerrypodWeb.Admin.Theme.Index do
end)
|> case do
[image | _] ->
{:noreply, assign(socket, :header_image, image)}
socket =
socket
|> assign(:header_image, image)
|> compute_header_contrast_warning()
{:noreply, socket}
_ ->
{:noreply, socket}
@@ -160,6 +166,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, preset_atom)
|> compute_header_contrast_warning()
|> put_flash(:info, "Applied #{preset_name} preset")
{:noreply, socket}
@@ -206,6 +213,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
|> maybe_recompute_contrast_warning(field)
{:noreply, socket}
@@ -246,6 +254,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
|> maybe_recompute_contrast_warning(field)
{:noreply, socket}
@@ -284,31 +293,43 @@ defmodule BerrypodWeb.Admin.Theme.Index do
def handle_event("toggle_setting", %{"field" => field}, socket) do
field_atom = String.to_existing_atom(field)
current_value = Map.get(socket.assigns.theme_settings, field_atom)
attrs = %{field_atom => !current_value}
new_value = !current_value
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
# Prevent turning off show_site_name when there's no logo to display
if field_atom == :show_site_name && new_value == false && !has_valid_logo?(socket) do
{:noreply, put_flash(socket, :error, "Upload a logo first to hide the shop name")}
else
attrs = %{field_atom => new_value}
# Trigger favicon regeneration when the icon source changes
if field_atom == :use_logo_as_icon do
maybe_enqueue_favicon_from_settings(theme_settings, socket.assigns)
end
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
active_preset = Presets.detect_preset(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
# Trigger favicon regeneration when the icon source changes
if field_atom == :use_logo_as_icon do
maybe_enqueue_favicon_from_settings(theme_settings, socket.assigns)
end
{:noreply, socket}
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:active_preset, active_preset)
|> maybe_recompute_contrast_warning(field)
{:error, _} ->
{:noreply, socket}
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end
end
defp has_valid_logo?(socket) do
socket.assigns.theme_settings.show_logo && socket.assigns.logo_image != nil
end
@impl true
def handle_event("save_theme", _params, socket) do
socket = put_flash(socket, :info, "Theme saved successfully")
@@ -351,11 +372,17 @@ defmodule BerrypodWeb.Admin.Theme.Index do
Media.delete_image(logo)
end
Settings.update_theme_settings(%{logo_image_id: nil})
# Re-enable shop name when removing logo to ensure header isn't empty
{:ok, theme_settings} =
Settings.update_theme_settings(%{logo_image_id: nil, show_site_name: true})
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:logo_image, nil)
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> put_flash(:info, "Logo removed")
{:noreply, socket}
@@ -372,6 +399,7 @@ defmodule BerrypodWeb.Admin.Theme.Index do
socket =
socket
|> assign(:header_image, nil)
|> assign(:header_contrast_warning, :ok)
|> put_flash(:info, "Header image removed")
{:noreply, socket}
@@ -574,4 +602,29 @@ defmodule BerrypodWeb.Admin.Theme.Index do
urls -> urls
end
end
# Compute header contrast warning based on image colors and theme mood
defp compute_header_contrast_warning(socket) do
header_image = socket.assigns.header_image
theme_settings = socket.assigns.theme_settings
warning =
if theme_settings.header_background_enabled && header_image do
text_color = Contrast.text_color_for_mood(theme_settings.mood)
colors = Contrast.parse_dominant_colors(header_image.dominant_colors)
Contrast.analyze_header_contrast(colors, text_color)
else
:ok
end
assign(socket, :header_contrast_warning, warning)
end
# Only recompute when mood or header_background_enabled changes
defp maybe_recompute_contrast_warning(socket, field)
when field in ["mood", "header_background_enabled"] do
compute_header_contrast_warning(socket)
end
defp maybe_recompute_contrast_warning(socket, _field), do: socket
end

View File

@@ -90,54 +90,34 @@
<!-- Branding Section -->
<div class="theme-panel">
<fieldset class="theme-radio-fieldset">
<legend class="theme-section-label">Logo & header</legend>
<span class="theme-section-label">Logo & header</span>
<div class="admin-stack admin-stack-sm theme-field">
<%= for {value, title, desc} <- [
{"text-only", "Shop name only", "Your name in the heading font"},
{"logo-text", "Logo + shop name", "Your logo image with name beside it"},
{"logo-only", "Logo only", "Just your logo (with text built in)"}
] do %>
<label class={[
"theme-radio-card",
@theme_settings.logo_mode == value && "theme-radio-card-active"
]}>
<input
type="radio"
id={"logo-mode-#{value}"}
name="logo_mode"
value={value}
checked={@theme_settings.logo_mode == value}
phx-click="update_setting"
phx-value-field="logo_mode"
phx-value-setting_value={value}
class="theme-radio-input"
/>
<span
class={[
"theme-radio-dot",
@theme_settings.logo_mode == value && "theme-radio-dot-active"
]}
aria-hidden="true"
>
<span class={[
"theme-radio-dot-inner",
@theme_settings.logo_mode == value && "theme-radio-dot-inner-active"
]}>
</span>
</span>
<div class="admin-fill">
<div class="theme-radio-title">{title}</div>
<div class="admin-text-secondary">{desc}</div>
</div>
</label>
<% end %>
</div>
</fieldset>
<div class="admin-stack admin-stack-sm theme-field">
<label class="admin-toggle-label">
<input
type="checkbox"
checked={@theme_settings.show_site_name}
phx-click="toggle_setting"
phx-value-field="show_site_name"
class="admin-toggle admin-toggle-sm"
/>
<span class="theme-slider-label">Show shop name</span>
</label>
<label class="admin-toggle-label">
<input
type="checkbox"
checked={@theme_settings.show_logo}
phx-click="toggle_setting"
phx-value-field="show_logo"
class="admin-toggle admin-toggle-sm"
/>
<span class="theme-slider-label">Show logo</span>
</label>
</div>
<!-- Logo Upload (for logo-text and logo-only modes) -->
<%= if @theme_settings.logo_mode in ["logo-text", "logo-only"] do %>
<!-- Logo Upload (when logo enabled) -->
<%= if @theme_settings.show_logo do %>
<div class="theme-subsection">
<span class="theme-slider-label theme-block-label">
Upload logo (SVG or PNG)
@@ -153,7 +133,7 @@
<div class="theme-thumb theme-thumb-logo">
<img
src={"/image_cache/#{@logo_image.id}.webp"}
alt="Current logo"
alt={@site_name}
/>
<button
type="button"
@@ -164,29 +144,6 @@
×
</button>
</div>
<form
phx-change="update_image_alt"
phx-value-image-id={@logo_image.id}
class="theme-subfield-sm"
>
<label class="admin-row">
<span class="admin-text-secondary shrink-0">Alt text</span>
<input
type="text"
name="alt"
value={@logo_image.alt || ""}
placeholder="Describe this image"
class="admin-input admin-input-sm admin-fill"
phx-debounce="blur"
/>
</label>
<p
:if={!@logo_image.alt || @logo_image.alt == ""}
class="theme-alt-warning"
>
Missing alt text — add a description for accessibility
</p>
</form>
<% end %>
</div>
@@ -372,10 +329,10 @@
<!-- Short name -->
<div class="theme-subfield">
<form phx-change="update_setting" phx-value-field="favicon_short_name">
<div class="theme-slider-header">
<span class="theme-slider-label">Short name</span>
<span class="admin-text-tertiary">Home screen label</span>
</div>
<label class="theme-slider-label theme-block-label">
Short name
<span class="admin-text-tertiary">— appears under home screen icon</span>
</label>
<input
type="text"
name="favicon_short_name"
@@ -448,7 +405,7 @@
<div class="theme-thumb theme-thumb-cover theme-thumb-header">
<img
src={"/image_cache/#{@header_image.id}.webp"}
alt="Current header background"
alt=""
/>
<button
type="button"
@@ -459,30 +416,32 @@
×
</button>
</div>
<form
phx-change="update_image_alt"
phx-value-image-id={@header_image.id}
class="theme-subfield-sm"
>
<label class="admin-row">
<span class="admin-text-secondary shrink-0">Alt text</span>
<input
type="text"
name="alt"
value={@header_image.alt || ""}
placeholder="Describe this image"
class="admin-input admin-input-sm admin-fill"
phx-debounce="blur"
/>
</label>
<p
:if={!@header_image.alt || @header_image.alt == ""}
class="theme-alt-warning"
>
Missing alt text — add a description for accessibility
</p>
</form>
<%= if @header_contrast_warning != :ok do %>
<div class="theme-contrast-warning">
<.icon name="hero-exclamation-triangle" class="size-5" />
<div>
<strong>
<%= if @header_contrast_warning == :poor do %>
Text may be hard to read
<% else %>
Text contrast could be better
<% end %>
</strong>
<p>
The header text might blend into this background.
Try switching to a
<%= if @theme_settings.mood == "dark" do %>
lighter mood
<% else %>
dark mood
<% end %>
or choosing a different image.
</p>
</div>
</div>
<% end %>
<!-- Header Image Controls -->
<div class="admin-stack admin-stack-md theme-subfield">
<form phx-change="update_setting" phx-value-field="header_zoom">
@@ -499,34 +458,36 @@
class="admin-range"
/>
</form>
<form phx-change="update_setting" phx-value-field="header_position_x">
<div class="theme-slider-header">
<span class="theme-slider-label">Horizontal position</span>
<span class="theme-slider-value">{@theme_settings.header_position_x}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={@theme_settings.header_position_x}
name="header_position_x"
class="admin-range"
/>
</form>
<form phx-change="update_setting" phx-value-field="header_position_y">
<div class="theme-slider-header">
<span class="theme-slider-label">Vertical position</span>
<span class="theme-slider-value">{@theme_settings.header_position_y}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={@theme_settings.header_position_y}
name="header_position_y"
class="admin-range"
/>
</form>
<%= if @theme_settings.header_zoom > 100 do %>
<form phx-change="update_setting" phx-value-field="header_position_x">
<div class="theme-slider-header">
<span class="theme-slider-label">Horizontal position</span>
<span class="theme-slider-value">{@theme_settings.header_position_x}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={@theme_settings.header_position_x}
name="header_position_x"
class="admin-range"
/>
</form>
<form phx-change="update_setting" phx-value-field="header_position_y">
<div class="theme-slider-header">
<span class="theme-slider-label">Vertical position</span>
<span class="theme-slider-value">{@theme_settings.header_position_y}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={@theme_settings.header_position_y}
name="header_position_y"
class="admin-range"
/>
</form>
<% end %>
</div>
<% end %>
@@ -560,7 +521,7 @@
<% end %>
</div>
<% end %>
<!-- Presets Section -->
<div class="theme-section">
<label class="theme-section-label">Start with a preset</label>
@@ -1117,23 +1078,6 @@
</div>
</div>
</details>
<!-- Current Combination Display -->
<div class="theme-combination">
<div class="theme-combination-label">Current combination</div>
<div class="theme-combination-value">
{String.capitalize(@theme_settings.mood)} · {String.capitalize(
@theme_settings.typography
)} · {String.capitalize(@theme_settings.shape)} · {String.capitalize(
@theme_settings.density
)} · {@theme_settings.grid_columns}-up · {String.capitalize(
@theme_settings.header_layout
)}
</div>
<div class="theme-combination-footnote">
One of 100,000+ possible combinations
</div>
</div>
<% end %>
</div>