From 285aafa0b54741e3e1f9b4412e43345e68d7a2bf Mon Sep 17 00:00:00 2001 From: jamey Date: Fri, 20 Feb 2026 23:53:42 +0000 Subject: [PATCH] migrate accent colours from HSL to oklch, inject theme into admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Replace hex_to_hsl with hex_to_oklch in CSSGenerator, output --t-accent-l/c/h instead of --t-accent-h/s/l. All 46 HSL accent references across theme-semantic.css, theme-layer2-attributes.css, and shop/components.css replaced with oklch/color-mix equivalents. Dead style*= attribute selectors for button variants replaced with proper class-based selectors. Added color-scheme: light/dark to mood output. Phase 2: Add LoadTheme plug to admin pipeline, extend AdminLayoutHook with theme_settings and generated_css assigns, add font preloads and generated CSS injection to admin_root.html.heex. No visual changes to admin yet — .themed wrapper added in next phase. Co-Authored-By: Claude Opus 4.6 --- assets/css/shop/components.css | 38 ++++---- assets/css/theme-layer2-attributes.css | 30 ++++--- assets/css/theme-semantic.css | 36 ++++---- lib/berrypod/theme/css_generator.ex | 87 +++++++++++-------- lib/berrypod_web/admin_layout_hook.ex | 25 +++++- .../components/layouts/admin_root.html.heex | 11 +++ lib/berrypod_web/router.ex | 1 + test/berrypod/settings_test.exs | 8 +- test/berrypod/theme/css_generator_test.exs | 44 ++++++---- .../live/theme_css_consistency_test.exs | 4 +- 10 files changed, 169 insertions(+), 115 deletions(-) diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css index c8bd2d0..805447c 100644 --- a/assets/css/shop/components.css +++ b/assets/css/shop/components.css @@ -131,7 +131,7 @@ /* ── Product prices (shared between cards and PDP) ── */ .product-price--sale { - color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + color: var(--t-accent); } .product-price--compare { @@ -254,7 +254,7 @@ .hero-pre-title { font-family: var(--t-font-heading); font-weight: var(--t-heading-weight); - color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + color: var(--t-accent); font-size: var(--t-heading-display); margin-bottom: 1rem; } @@ -398,7 +398,7 @@ } .accent-link { - color: var(--t-accent-text, hsl(var(--t-accent-h) var(--t-accent-s) 38%)); + color: var(--t-accent-text); text-decoration: none; cursor: pointer; font-size: var(--t-text-small); @@ -457,7 +457,7 @@ .collection-empty-link { display: inline-block; margin-top: 1rem; - color: var(--t-text-accent, hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))); + color: var(--t-text-accent, var(--t-accent)); text-decoration: underline; } @@ -518,7 +518,7 @@ } .pdp-thumbnail-active { - border: 2px solid hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + border: 2px solid var(--t-accent); } /* ── Variant selector ── */ @@ -556,8 +556,8 @@ padding: 0; &[aria-pressed="true"] { - border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); - --tw-ring-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + border-color: var(--t-accent); + --tw-ring-color: var(--t-accent); } } @@ -572,8 +572,8 @@ cursor: pointer; &[aria-pressed="true"] { - border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); - background: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1); + border-color: var(--t-accent); + background: color-mix(in oklch, var(--t-accent) 10%, transparent); } } @@ -654,7 +654,7 @@ } .atc-btn { - background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + background-color: var(--t-accent); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; @@ -762,7 +762,7 @@ /* ── Announcement bar ── */ .announcement-bar { - background-color: var(--t-accent-button, hsl(var(--t-accent-h) var(--t-accent-s) 42%)); + background-color: var(--t-accent-button); color: var(--t-text-inverse); text-align: center; padding: 0.5rem 1rem; @@ -867,7 +867,7 @@ position: absolute; top: -4px; right: -4px; - background: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + background: var(--t-accent); color: var(--t-text-inverse); font-size: var(--t-text-caption); font-weight: 600; @@ -889,7 +889,7 @@ &[aria-current="page"] { color: var(--t-text-primary); - border-bottom-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + border-bottom-color: var(--t-accent); } } @@ -943,9 +943,9 @@ } &[aria-current="page"] { - color: hsl(var(--t-accent-h) var(--t-accent-s) calc(var(--t-accent-l) - 15%)); + color: color-mix(in oklch, var(--t-accent) 80%, black); font-weight: 600; - background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1); + background-color: color-mix(in oklch, var(--t-accent) 10%, transparent); & svg { width: 1.5rem; @@ -1273,7 +1273,7 @@ padding: 0.75rem 1.5rem; font-weight: 600; transition: all 0.2s ease; - background: var(--t-accent-button, hsl(var(--t-accent-h) var(--t-accent-s) 42%)); + background: var(--t-accent-button); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; @@ -1692,7 +1692,7 @@ /* ── Accent email link ── */ .accent-email { - color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + color: var(--t-accent); } /* ── Card shared styles (info, tracking, newsletter, social cards) ── */ @@ -1767,7 +1767,7 @@ align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; - color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + color: var(--t-accent); text-decoration: none; & svg { @@ -1879,7 +1879,7 @@ .trust-badge-icon { flex-shrink: 0; margin-top: 0.125rem; - color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + color: var(--t-accent); & svg { width: 1.25rem; diff --git a/assets/css/theme-layer2-attributes.css b/assets/css/theme-layer2-attributes.css index bff30c7..396ca96 100644 --- a/assets/css/theme-layer2-attributes.css +++ b/assets/css/theme-layer2-attributes.css @@ -157,19 +157,21 @@ /* Button Style Variants */ &[data-button-style="outline"] { - & button[style*="background-color: hsl(var(--t-accent"], - & a[style*="background-color: hsl(var(--t-accent"] { + & .themed-button, + & .atc-btn, + & .cart-drawer-checkout { background-color: transparent !important; - color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)) !important; - border: 2px solid hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)) !important; + color: var(--t-accent) !important; + border: 2px solid var(--t-accent) !important; } } &[data-button-style="soft"] { - & button[style*="background-color: hsl(var(--t-accent"], - & a[style*="background-color: hsl(var(--t-accent"] { - background-color: hsl(var(--t-accent-h) var(--t-accent-s) 90%) !important; - color: hsl(var(--t-accent-h) var(--t-accent-s) 30%) !important; + & .themed-button, + & .atc-btn, + & .cart-drawer-checkout { + background-color: color-mix(in oklch, var(--t-accent) 12%, var(--t-surface-base)) !important; + color: color-mix(in oklch, var(--t-accent) 80%, black) !important; border: 2px solid transparent !important; } } @@ -231,12 +233,12 @@ } & .filter-pill-active { - background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + background-color: var(--t-accent); color: var(--t-text-inverse); border-color: transparent; &:hover { - background-color: hsl(var(--t-accent-h) var(--t-accent-s) calc(var(--t-accent-l) - 5%)); + background-color: var(--t-accent-hover); color: var(--t-text-inverse); } } @@ -250,7 +252,7 @@ &:focus { outline: none; - border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + border-color: var(--t-accent); } &::placeholder { @@ -269,12 +271,12 @@ &:focus { outline: none; - border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + border-color: var(--t-accent); } } & .themed-button { - background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + background-color: var(--t-accent); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; @@ -322,7 +324,7 @@ } .badge-new { - background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + background-color: var(--t-accent); color: var(--t-text-inverse); } diff --git a/assets/css/theme-semantic.css b/assets/css/theme-semantic.css index 124a17a..a50c751 100644 --- a/assets/css/theme-semantic.css +++ b/assets/css/theme-semantic.css @@ -13,21 +13,19 @@ color: var(--t-text-primary); font-family: var(--t-font-body); - /* Accent color - HSL components set dynamically by CSS generator */ - --t-accent-h: 24; - --t-accent-s: 95%; - --t-accent-l: 53%; + /* Accent colour — oklch components set dynamically by CSSGenerator */ + --t-accent-l: 65%; + --t-accent-c: 0.2; + --t-accent-h: 47; - --t-accent: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); - --t-accent-hover: hsl(var(--t-accent-h) var(--t-accent-s) calc(var(--t-accent-l) - 8%)); - --t-accent-subtle: hsl(var(--t-accent-h) 40% 95%); - --t-accent-ring: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.4); + --t-accent: oklch(var(--t-accent-l) var(--t-accent-c) var(--t-accent-h)); + --t-accent-hover: color-mix(in oklch, var(--t-accent) 85%, black); + --t-accent-subtle: color-mix(in oklch, var(--t-accent) 8%, var(--t-surface-base)); + --t-accent-ring: oklch(var(--t-accent-l) var(--t-accent-c) var(--t-accent-h) / 0.4); /* WCAG AA compliant accent variants for better contrast */ - /* Darker accent for text on light backgrounds (4.5:1 with white) */ - --t-accent-text: hsl(var(--t-accent-h) var(--t-accent-s) 38%); - /* Darker accent for button backgrounds to ensure 4.5:1 with white text */ - --t-accent-button: hsl(var(--t-accent-h) var(--t-accent-s) 42%); + --t-accent-text: color-mix(in oklch, var(--t-accent) 80%, black); + --t-accent-button: color-mix(in oklch, var(--t-accent) 85%, black); /* Secondary colors */ --t-secondary-accent: #ea580c; @@ -117,7 +115,7 @@ /* Dark mode accent-subtle override */ &[data-mood="dark"] { - --t-accent-subtle: hsl(var(--t-accent-h) 30% 15%); + --t-accent-subtle: color-mix(in oklch, var(--t-accent) 15%, black); } } @@ -137,7 +135,7 @@ & textarea:focus-visible, & [tabindex]:focus-visible, & details summary:focus-visible { - outline: 2px solid hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + outline: 2px solid var(--t-accent); outline-offset: 2px; } @@ -230,9 +228,9 @@ } &.active { - background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.12); - color: hsl(var(--t-accent-h) var(--t-accent-s) calc(var(--t-accent-l) - 15%)); - border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.25); + background-color: color-mix(in oklch, var(--t-accent) 12%, transparent); + color: color-mix(in oklch, var(--t-accent) 80%, black); + border-color: color-mix(in oklch, var(--t-accent) 25%, transparent); font-weight: 600; } } @@ -273,7 +271,7 @@ top: -100px; left: 50%; transform: translateX(-50%); - background: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + background: var(--t-accent); color: var(--t-text-inverse); padding: 0.75rem 1.5rem; z-index: 9999; @@ -312,5 +310,5 @@ /* Active nav underline — must stay unlayered to match the base border above */ .shop-nav [aria-current="page"] { - border-bottom-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); + border-bottom-color: var(--t-accent); } diff --git a/lib/berrypod/theme/css_generator.ex b/lib/berrypod/theme/css_generator.ex index 158b670..93d80d6 100644 --- a/lib/berrypod/theme/css_generator.ex +++ b/lib/berrypod/theme/css_generator.ex @@ -61,7 +61,8 @@ defmodule Berrypod.Theme.CSSGenerator do # Mood colors - surface, text, and border colors defp generate_mood("neutral") do """ - --t-surface-base: #ffffff; + color-scheme: light; + --t-surface-base: #ffffff; --t-surface-raised: #ffffff; --t-surface-sunken: #f5f5f5; --t-surface-overlay: rgba(255, 255, 255, 0.95); @@ -76,7 +77,8 @@ defmodule Berrypod.Theme.CSSGenerator do defp generate_mood("warm") do """ - --t-surface-base: #fdf8f3; + color-scheme: light; + --t-surface-base: #fdf8f3; --t-surface-raised: #fffcf8; --t-surface-sunken: #f5ebe0; --t-surface-overlay: rgba(253, 248, 243, 0.95); @@ -91,7 +93,8 @@ defmodule Berrypod.Theme.CSSGenerator do defp generate_mood("cool") do """ - --t-surface-base: #f4f7fb; + color-scheme: light; + --t-surface-base: #f4f7fb; --t-surface-raised: #f8fafc; --t-surface-sunken: #e8eff7; --t-surface-overlay: rgba(244, 247, 251, 0.95); @@ -106,7 +109,8 @@ defmodule Berrypod.Theme.CSSGenerator do defp generate_mood("dark") do """ - --t-surface-base: #0a0a0a; + color-scheme: dark; + --t-surface-base: #0a0a0a; --t-surface-raised: #171717; --t-surface-sunken: #000000; --t-surface-overlay: rgba(23, 23, 23, 0.95); @@ -239,14 +243,14 @@ defmodule Berrypod.Theme.CSSGenerator do # Fallback for any other density value defp generate_density(_), do: generate_density("balanced") - # Accent color with HSL breakdown + # Accent color with oklch breakdown defp generate_accent(hex_color) do - {h, s, l} = hex_to_hsl(hex_color) + {l, c, h} = hex_to_oklch(hex_color) """ - --t-accent-h: #{h}; - --t-accent-s: #{s}%; --t-accent-l: #{l}%; + --t-accent-c: #{c}; + --t-accent-h: #{h}; """ end @@ -334,46 +338,55 @@ defmodule Berrypod.Theme.CSSGenerator do "--t-image-aspect-ratio: 4 / 3;" end - # Convert hex color to HSL - defp hex_to_hsl("#" <> hex), do: hex_to_hsl(hex) + # Convert hex colour to oklch (lightness%, chroma, hue°) + # Pipeline: hex → sRGB → linear RGB → XYZ (D65) → oklab → oklch + defp hex_to_oklch("#" <> hex), do: hex_to_oklch(hex) - defp hex_to_hsl(hex) when byte_size(hex) == 6 do + defp hex_to_oklch(hex) when byte_size(hex) == 6 do {r, ""} = Integer.parse(String.slice(hex, 0..1), 16) {g, ""} = Integer.parse(String.slice(hex, 2..3), 16) {b, ""} = Integer.parse(String.slice(hex, 4..5), 16) - # Normalize RGB values to 0-1 - r = r / 255 - g = g / 255 - b = b / 255 + # sRGB to linear RGB (inverse gamma) + lr = srgb_to_linear(r / 255) + lg = srgb_to_linear(g / 255) + lb = srgb_to_linear(b / 255) - max = Enum.max([r, g, b]) - min = Enum.min([r, g, b]) - delta = max - min + # Linear RGB to oklab via the Björn Ottosson method + # First: linear sRGB → LMS (cube root of cone response) + l_ = :math.pow(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb, 1 / 3) + m_ = :math.pow(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb, 1 / 3) + s_ = :math.pow(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb, 1 / 3) - # Calculate lightness - l = (max + min) / 2 + # LMS to oklab + ok_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + ok_a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + ok_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ - # Calculate saturation and hue - {h, s} = - if delta == 0 do - {0, 0} + # oklab to oklch + c = :math.sqrt(ok_a * ok_a + ok_b * ok_b) + + h = + if c < 0.0001 do + 0.0 else - s = if l > 0.5, do: delta / (2 - max - min), else: delta / (max + min) - - h = - cond do - max == r -> (g - b) / delta + if(g < b, do: 6, else: 0) - max == g -> (b - r) / delta + 2 - max == b -> (r - g) / delta + 4 - end - - {h * 60, s} + h_rad = :math.atan2(ok_b, ok_a) + h_deg = h_rad * 180 / :math.pi() + if h_deg < 0, do: h_deg + 360, else: h_deg end - {round(h), round(s * 100), round(l * 100)} + # Return as {lightness%, chroma, hue°} rounded for CSS + { + Float.round(ok_l * 100, 2), + Float.round(c, 4), + Float.round(h, 2) + } end - # Handle invalid hex values - defp hex_to_hsl(_), do: {0, 0, 50} + # Handle invalid hex values (neutral grey fallback) + defp hex_to_oklch(_), do: {50.0, 0.0, 0.0} + + # sRGB gamma decompression (IEC 61966-2-1) + defp srgb_to_linear(v) when v <= 0.04045, do: v / 12.92 + defp srgb_to_linear(v), do: :math.pow((v + 0.055) / 1.055, 2.4) end diff --git a/lib/berrypod_web/admin_layout_hook.ex b/lib/berrypod_web/admin_layout_hook.ex index 20cf44e..f8e0a24 100644 --- a/lib/berrypod_web/admin_layout_hook.ex +++ b/lib/berrypod_web/admin_layout_hook.ex @@ -1,21 +1,40 @@ defmodule BerrypodWeb.AdminLayoutHook do @moduledoc """ - LiveView on_mount hook that assigns the current path for admin sidebar navigation. + LiveView on_mount hook that assigns the current path for admin sidebar navigation + and loads theme settings for the admin layout. """ import Phoenix.Component + alias Berrypod.Settings + alias Berrypod.Theme.{CSSCache, CSSGenerator} + def on_mount(:assign_current_path, _params, _session, socket) do + theme_settings = Settings.get_theme_settings() + + generated_css = + case CSSCache.get() do + {:ok, css} -> + css + + :miss -> + css = CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1) + CSSCache.put(css) + css + end + socket = socket |> assign(:current_path, "") - |> assign(:site_live, Berrypod.Settings.site_live?()) + |> assign(:site_live, Settings.site_live?()) + |> assign(:theme_settings, theme_settings) + |> assign(:generated_css, generated_css) |> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params, uri, socket -> {:cont, socket |> assign(:current_path, URI.parse(uri).path) - |> assign(:site_live, Berrypod.Settings.site_live?())} + |> assign(:site_live, Settings.site_live?())} end) {:cont, socket} diff --git a/lib/berrypod_web/components/layouts/admin_root.html.heex b/lib/berrypod_web/components/layouts/admin_root.html.heex index 78a5c8b..8ddce52 100644 --- a/lib/berrypod_web/components/layouts/admin_root.html.heex +++ b/lib/berrypod_web/components/layouts/admin_root.html.heex @@ -7,12 +7,23 @@ <.live_title default="Admin" suffix=" · Berrypod"> {assigns[:page_title]} + + <%= for preload <- Berrypod.Theme.Fonts.preload_links( + @theme_settings.typography, + &BerrypodWeb.Endpoint.static_path/1 + ) do %> + + <% end %> + +