add header background contrast warning and improve branding UX
All checks were successful
deploy / deploy (push) Successful in 1m12s
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user