berrypod/lib/berrypod/theme/contrast.ex
jamey 476da8121a
All checks were successful
deploy / deploy (push) Successful in 1m12s
add header background contrast warning and improve branding UX
- 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>
2026-03-08 22:40:08 +00:00

101 lines
2.8 KiB
Elixir

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