From 476da8121a651a6cb81d29342a3910a1a194ea09 Mon Sep 17 00:00:00 2001 From: jamey Date: Sun, 8 Mar 2026 22:40:08 +0000 Subject: [PATCH] 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 --- assets/css/admin/components.css | 59 ++++- assets/css/shop/components.css | 1 + assets/css/shop/utilities.css | 4 + lib/berrypod/images/optimizer.ex | 19 ++ lib/berrypod/media.ex | 33 ++- lib/berrypod/media/image.ex | 4 +- lib/berrypod/settings.ex | 27 ++ lib/berrypod/settings/theme_settings.ex | 7 +- lib/berrypod/theme/contrast.ex | 100 ++++++++ .../components/shop_components/layout.ex | 59 ++--- lib/berrypod_web/live/admin/theme/index.ex | 93 +++++-- .../live/admin/theme/index.html.heex | 234 +++++++----------- ...08214935_add_dominant_colors_to_images.exs | 9 + 13 files changed, 429 insertions(+), 220 deletions(-) create mode 100644 lib/berrypod/theme/contrast.ex create mode 100644 priv/repo/migrations/20260308214935_add_dominant_colors_to_images.exs 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 %> - {@site_name} - <% end %> - - {@site_name} - - <% "logo-only" -> %> - <%= if @logo_image do %> - {@site_name} - <% else %> - - {@site_name} - - <% end %> - <% _ -> %> - - {@site_name} - + <%= if @show_logo do %> + {@site_name} + <% 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 @@
-
- + -
- <%= 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 %> - - <% end %> -
-
+
+ + + +
- - <%= if @theme_settings.logo_mode in ["logo-text", "logo-only"] do %> + + <%= if @theme_settings.show_logo do %>
Upload logo (SVG or PNG) @@ -153,7 +133,7 @@ -
- -

- Missing alt text — add a description for accessibility -

-
<% end %>
@@ -372,10 +329,10 @@
-
- Short name - Home screen label -
+ Current header background
- - -

- Missing alt text — add a description for accessibility -

- - + + <%= if @header_contrast_warning != :ok do %> +
+ <.icon name="hero-exclamation-triangle" class="size-5" /> +
+ + <%= if @header_contrast_warning == :poor do %> + Text may be hard to read + <% else %> + Text contrast could be better + <% end %> + +

+ 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. +

+
+
+ <% end %> +
@@ -499,34 +458,36 @@ class="admin-range" />
-
-
- Horizontal position - {@theme_settings.header_position_x}% -
- -
-
-
- Vertical position - {@theme_settings.header_position_y}% -
- -
+ <%= if @theme_settings.header_zoom > 100 do %> +
+
+ Horizontal position + {@theme_settings.header_position_x}% +
+ +
+
+
+ Vertical position + {@theme_settings.header_position_y}% +
+ +
+ <% end %>
<% end %> @@ -560,7 +521,7 @@ <% end %>
<% end %> - +
@@ -1117,23 +1078,6 @@
- - -
-
Current combination
-
- {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 - )} -
-
- One of 100,000+ possible combinations -
-
<% end %> diff --git a/priv/repo/migrations/20260308214935_add_dominant_colors_to_images.exs b/priv/repo/migrations/20260308214935_add_dominant_colors_to_images.exs new file mode 100644 index 0000000..cbe074f --- /dev/null +++ b/priv/repo/migrations/20260308214935_add_dominant_colors_to_images.exs @@ -0,0 +1,9 @@ +defmodule Berrypod.Repo.Migrations.AddDominantColorsToImages do + use Ecto.Migration + + def change do + alter table(:images) do + add :dominant_colors, :string + end + end +end