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