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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
100
lib/berrypod/theme/contrast.ex
Normal file
100
lib/berrypod/theme/contrast.ex
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user