diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css
index ff1c848..9d37704 100644
--- a/assets/css/admin/components.css
+++ b/assets/css/admin/components.css
@@ -4017,21 +4017,30 @@
transition: width 0.2s;
}
-/* Small round remove button (overlaid on thumbnails) */
+/* Round remove button (overlaid on thumbnails) */
.theme-remove-btn {
position: absolute;
- top: -0.375rem;
- inset-inline-end: -0.375rem;
- width: 1.125rem;
- height: 1.125rem;
- background: var(--t-text-primary);
- color: var(--t-surface-base);
+ top: -0.5rem;
+ inset-inline-end: -0.5rem;
+ width: 1.75rem;
+ height: 1.75rem;
+ background: var(--t-status-error);
+ color: white;
border-radius: 9999px;
- font-size: 0.75rem;
+ font-size: 1rem;
+ font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
+ cursor: pointer;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+ transition: transform 0.15s ease, background-color 0.15s ease;
+
+ &:hover {
+ background: color-mix(in oklch, var(--t-status-error) 85%, black);
+ transform: scale(1.1);
+ }
}
/* Thumbnail preview boxes */
@@ -4043,7 +4052,10 @@
background: var(--t-surface-base);
border: 1px solid var(--t-border-default);
border-radius: 0.5rem;
- overflow: hidden;
+}
+
+.theme-thumb img {
+ border-radius: 0.375rem;
}
.theme-thumb-logo {
@@ -4342,6 +4354,35 @@
margin-top: 0.25rem;
}
+/* Contrast warning for header backgrounds */
+.theme-contrast-warning {
+ display: flex;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ margin-top: 0.75rem;
+ border-radius: 0.375rem;
+ background: color-mix(in oklch, var(--t-status-warning) 12%, var(--t-surface-base));
+ font-size: 0.8125rem;
+ line-height: 1.5;
+
+ & .size-5 {
+ color: var(--t-status-warning);
+ flex-shrink: 0;
+ margin-top: 0.125rem;
+ }
+
+ & strong {
+ display: block;
+ color: var(--t-text-primary);
+ font-weight: 500;
+ }
+
+ & p {
+ margin-top: 0.25rem;
+ color: var(--admin-text-muted);
+ }
+}
+
/* Radio card selector (logo mode picker) */
.theme-radio-fieldset {
border: none;
diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css
index f68b0d2..f5c04b3 100644
--- a/assets/css/shop/components.css
+++ b/assets/css/shop/components.css
@@ -1045,6 +1045,7 @@
/* ── Shop header ── */
.shop-header {
+ position: relative;
background-color: var(--t-surface-raised);
border-bottom: 1px solid var(--t-border-default);
display: flex;
diff --git a/assets/css/shop/utilities.css b/assets/css/shop/utilities.css
index 3a79c8b..a068d8b 100644
--- a/assets/css/shop/utilities.css
+++ b/assets/css/shop/utilities.css
@@ -32,6 +32,10 @@
text-wrap: balance;
}
+ .hidden {
+ display: none;
+ }
+
/* Hide visually but keep in DOM (for phx-update="stream" empty states etc.) */
.visually-hidden:not(:focus):not(:active) {
position: absolute;
diff --git a/lib/berrypod/images/optimizer.ex b/lib/berrypod/images/optimizer.ex
index 394e997..19686b4 100644
--- a/lib/berrypod/images/optimizer.ex
+++ b/lib/berrypod/images/optimizer.ex
@@ -113,12 +113,31 @@ defmodule Berrypod.Images.Optimizer do
)
|> Stream.run()
+ # Extract dominant colors for header images (used for contrast checking)
+ maybe_extract_dominant_colors(image, vips_image)
+
Repo.update!(ImageSchema.changeset(image, %{variants_status: "complete"}))
{:ok, widths}
end
end
end
+ # Extract and store dominant colors for header images.
+ # Used to calculate text contrast warnings in the theme editor.
+ defp maybe_extract_dominant_colors(%{image_type: "header"} = image, vips_image) do
+ case Image.dominant_color(vips_image, top_n: 5) do
+ {:ok, colors} when is_list(colors) ->
+ # Wrap single color in list if needed
+ colors = if is_list(hd(colors)), do: colors, else: [colors]
+ Repo.update!(ImageSchema.changeset(image, %{dominant_colors: Jason.encode!(colors)}))
+
+ _ ->
+ :ok
+ end
+ end
+
+ defp maybe_extract_dominant_colors(_image, _vips_image), do: :ok
+
defp generate_thumbnail(image, id) do
path = Path.join(cache_dir(), "#{id}-thumb.jpg")
diff --git a/lib/berrypod/media.ex b/lib/berrypod/media.ex
index aa8bebc..059ada6 100644
--- a/lib/berrypod/media.ex
+++ b/lib/berrypod/media.ex
@@ -57,6 +57,7 @@ defmodule Berrypod.Media do
|> Map.put(:source_width, width)
|> Map.put(:source_height, height)
|> Map.put(:variants_status, "pending")
+ |> maybe_extract_dominant_colors(webp_data)
{:error, _reason} ->
# If conversion fails, store original image
@@ -67,6 +68,26 @@ defmodule Berrypod.Media do
defp prepare_image_attrs(attrs), do: attrs
+ # Extract dominant colors during upload for header images (used for contrast checking)
+ defp maybe_extract_dominant_colors(%{image_type: "header"} = attrs, webp_data) do
+ case Image.from_binary(webp_data) do
+ {:ok, vips_image} ->
+ case Image.dominant_color(vips_image, top_n: 5) do
+ {:ok, colors} when is_list(colors) ->
+ colors = if is_list(hd(colors)), do: colors, else: [colors]
+ Map.put(attrs, :dominant_colors, Jason.encode!(colors))
+
+ _ ->
+ attrs
+ end
+
+ _ ->
+ attrs
+ end
+ end
+
+ defp maybe_extract_dominant_colors(attrs, _webp_data), do: attrs
+
defp is_svg?(content_type, filename) do
content_type == "image/svg+xml" or
String.ends_with?(filename || "", ".svg")
@@ -167,7 +188,7 @@ defmodule Berrypod.Media do
where: i.image_type == "header",
order_by: [desc: i.inserted_at],
limit: 1,
- select: struct(i, [:id, :image_type, :source_width])
+ select: struct(i, [:id, :image_type, :source_width, :dominant_colors])
)
SettingsCache.put_cached(:header, header)
@@ -185,14 +206,10 @@ defmodule Berrypod.Media do
"""
def delete_image(%ImageSchema{} = image) do
- result = Repo.delete(image)
+ # Invalidate cache before delete to ensure stale data isn't served
+ invalidate_media_cache(image.image_type)
- case result do
- {:ok, _} -> invalidate_media_cache(image.image_type)
- _ -> :ok
- end
-
- result
+ Repo.delete(image)
end
@doc """
diff --git a/lib/berrypod/media/image.ex b/lib/berrypod/media/image.ex
index 312e1e2..2e144a0 100644
--- a/lib/berrypod/media/image.ex
+++ b/lib/berrypod/media/image.ex
@@ -19,6 +19,7 @@ defmodule Berrypod.Media.Image do
field :alt, :string
field :caption, :string
field :tags, :string
+ field :dominant_colors, :string
timestamps(type: :utc_datetime)
end
@@ -41,7 +42,8 @@ defmodule Berrypod.Media.Image do
:variants_status,
:alt,
:caption,
- :tags
+ :tags,
+ :dominant_colors
])
|> validate_required([:image_type, :filename, :content_type, :file_size, :data])
|> validate_inclusion(:image_type, ~w(logo header product icon media))
diff --git a/lib/berrypod/settings.ex b/lib/berrypod/settings.ex
index 8233d7d..ec61f06 100644
--- a/lib/berrypod/settings.ex
+++ b/lib/berrypod/settings.ex
@@ -79,10 +79,37 @@ defmodule Berrypod.Settings do
settings_map when is_map(settings_map) ->
settings_map
|> atomize_keys()
+ |> migrate_logo_mode()
|> then(&struct(ThemeSettings, &1))
end
end
+ # Migrate old logo_mode field to new show_site_name/show_logo booleans
+ defp migrate_logo_mode(settings) do
+ case Map.get(settings, :logo_mode) do
+ "text-only" ->
+ settings
+ |> Map.put(:show_site_name, true)
+ |> Map.put(:show_logo, false)
+ |> Map.delete(:logo_mode)
+
+ "logo-text" ->
+ settings
+ |> Map.put(:show_site_name, true)
+ |> Map.put(:show_logo, true)
+ |> Map.delete(:logo_mode)
+
+ "logo-only" ->
+ settings
+ |> Map.put(:show_site_name, false)
+ |> Map.put(:show_logo, true)
+ |> Map.delete(:logo_mode)
+
+ _ ->
+ settings
+ end
+ end
+
@doc "Returns the shop name from Settings, falling back to a default."
def site_name do
get_setting("site_name") || "Store Name"
diff --git a/lib/berrypod/settings/theme_settings.ex b/lib/berrypod/settings/theme_settings.ex
index 22b12e7..b80c59a 100644
--- a/lib/berrypod/settings/theme_settings.ex
+++ b/lib/berrypod/settings/theme_settings.ex
@@ -15,7 +15,8 @@ defmodule Berrypod.Settings.ThemeSettings do
field :accent_color, :string, default: "#f97316"
# Branding
- field :logo_mode, :string, default: "text-only"
+ field :show_site_name, :boolean, default: true
+ field :show_logo, :boolean, default: false
field :logo_image_id, :binary_id
field :logo_size, :integer, default: 36
field :logo_recolor, :boolean, default: false
@@ -66,7 +67,8 @@ defmodule Berrypod.Settings.ThemeSettings do
:grid_columns,
:header_layout,
:accent_color,
- :logo_mode,
+ :show_site_name,
+ :show_logo,
:logo_image_id,
:logo_size,
:logo_recolor,
@@ -107,7 +109,6 @@ defmodule Berrypod.Settings.ThemeSettings do
|> validate_inclusion(:density, ~w(spacious balanced compact))
|> validate_inclusion(:grid_columns, ~w(2 3 4))
|> validate_inclusion(:header_layout, ~w(standard centered left))
- |> validate_inclusion(:logo_mode, ~w(text-only logo-text logo-only))
|> validate_number(:logo_size, greater_than_or_equal_to: 24, less_than_or_equal_to: 120)
|> validate_number(:header_zoom, greater_than_or_equal_to: 100, less_than_or_equal_to: 200)
|> validate_number(:header_position_x,
diff --git a/lib/berrypod/theme/contrast.ex b/lib/berrypod/theme/contrast.ex
new file mode 100644
index 0000000..70fedf8
--- /dev/null
+++ b/lib/berrypod/theme/contrast.ex
@@ -0,0 +1,100 @@
+defmodule Berrypod.Theme.Contrast do
+ @moduledoc """
+ WCAG contrast ratio calculations for accessibility checking.
+
+ Used to warn shop owners when header background images might make text hard to read.
+ """
+
+ @doc """
+ Calculate relative luminance from sRGB values (0-255).
+ Uses the WCAG 2.1 formula.
+ """
+ def relative_luminance([r, g, b]) do
+ [r, g, b]
+ |> Enum.map(fn c ->
+ c = c / 255
+
+ if c <= 0.03928 do
+ c / 12.92
+ else
+ :math.pow((c + 0.055) / 1.055, 2.4)
+ end
+ end)
+ |> then(fn [r, g, b] -> 0.2126 * r + 0.7152 * g + 0.0722 * b end)
+ end
+
+ @doc """
+ Calculate WCAG contrast ratio between two colors.
+ Returns a ratio >= 1.0 (higher = better contrast).
+ """
+ def contrast_ratio(color1, color2) do
+ l1 = relative_luminance(color1)
+ l2 = relative_luminance(color2)
+ {lighter, darker} = if l1 > l2, do: {l1, l2}, else: {l2, l1}
+ (lighter + 0.05) / (darker + 0.05)
+ end
+
+ @doc """
+ Analyze header image colors against text color, return warning level.
+
+ Returns:
+ - `:ok` - All dominant colors have acceptable contrast
+ - `:caution` - Some colors may be problematic
+ - `:poor` - Most colors have poor contrast
+ """
+ def analyze_header_contrast(nil, _text_color), do: :ok
+ def analyze_header_contrast([], _text_color), do: :ok
+
+ def analyze_header_contrast(dominant_colors, text_color) when is_list(dominant_colors) do
+ text_rgb = hex_to_rgb(text_color)
+
+ ratios = Enum.map(dominant_colors, &contrast_ratio(&1, text_rgb))
+ min_ratio = Enum.min(ratios)
+ avg_ratio = Enum.sum(ratios) / length(ratios)
+
+ cond do
+ # All colors have good contrast (3:1 is WCAG AA for large text)
+ Enum.all?(ratios, &(&1 >= 3.0)) -> :ok
+ # Most colors are problematic
+ avg_ratio < 3.0 -> :poor
+ # Some colors might be problematic
+ min_ratio < 2.0 -> :caution
+ true -> :ok
+ end
+ end
+
+ @doc """
+ Parse JSON-encoded dominant colors string.
+ """
+ def parse_dominant_colors(nil), do: nil
+ def parse_dominant_colors(""), do: nil
+
+ def parse_dominant_colors(json) when is_binary(json) do
+ case Jason.decode(json) do
+ {:ok, colors} when is_list(colors) -> colors
+ _ -> nil
+ end
+ end
+
+ @doc """
+ Get the text color for a given mood setting.
+ """
+ def text_color_for_mood("dark"), do: "#fafafa"
+ def text_color_for_mood("neutral"), do: "#171717"
+ def text_color_for_mood("warm"), do: "#1c1917"
+ def text_color_for_mood("cool"), do: "#0f172a"
+ def text_color_for_mood(_), do: "#171717"
+
+ @doc """
+ Convert hex color to RGB list.
+ """
+ def hex_to_rgb("#" <> hex), do: hex_to_rgb(hex)
+
+ def hex_to_rgb(hex) when byte_size(hex) == 6 do
+ [
+ String.slice(hex, 0..1) |> Integer.parse(16) |> elem(0),
+ String.slice(hex, 2..3) |> Integer.parse(16) |> elem(0),
+ String.slice(hex, 4..5) |> Integer.parse(16) |> elem(0)
+ ]
+ end
+end
diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex
index b0646ea..ac72b43 100644
--- a/lib/berrypod_web/components/shop_components/layout.ex
+++ b/lib/berrypod_web/components/shop_components/layout.ex
@@ -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" -> %>
-
- {@site_name}
-
- <% "logo-text" -> %>
- <%= if @logo_image do %>
-
- <% end %>
-
- {@site_name}
-
- <% "logo-only" -> %>
- <%= if @logo_image do %>
-
- <% else %>
-
- {@site_name}
-
- <% end %>
- <% _ -> %>
-
- {@site_name}
-
+ <%= if @show_logo do %>
+
+ <% end %>
+ <%= if @show_site_name do %>
+
+ {@site_name}
+
<% end %>
"""
end
diff --git a/lib/berrypod_web/live/admin/theme/index.ex b/lib/berrypod_web/live/admin/theme/index.ex
index 7f059e6..61e4316 100644
--- a/lib/berrypod_web/live/admin/theme/index.ex
+++ b/lib/berrypod_web/live/admin/theme/index.ex
@@ -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
diff --git a/lib/berrypod_web/live/admin/theme/index.html.heex b/lib/berrypod_web/live/admin/theme/index.html.heex
index 9d61f38..31f1020 100644
--- a/lib/berrypod_web/live/admin/theme/index.html.heex
+++ b/lib/berrypod_web/live/admin/theme/index.html.heex
@@ -90,54 +90,34 @@
+ 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. +
+