migrate accent colours from HSL to oklch, inject theme into admin

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 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-20 23:53:42 +00:00
parent eb65b11e4d
commit 285aafa0b5
10 changed files with 169 additions and 115 deletions

View File

@ -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;

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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

View File

@ -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}

View File

@ -7,12 +7,23 @@
<.live_title default="Admin" suffix=" · Berrypod">
{assigns[:page_title]}
</.live_title>
<!-- Preload critical fonts for the current typography preset -->
<%= for preload <- Berrypod.Theme.Fonts.preload_links(
@theme_settings.typography,
&BerrypodWeb.Endpoint.static_path/1
) do %>
<link rel="preload" href={preload.href} as="font" type="font/woff2" crossorigin />
<% end %>
<!-- Pre-declare layer order so shop reset < Tailwind base regardless of load order -->
<style>
@layer properties, reset, primitives, tokens, theme, base, components, layout, utilities, overrides;
</style>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/admin.css"} />
<link phx-track-static rel="stylesheet" href={~p"/assets/css/shop.css"} />
<!-- Generated theme CSS with @font-face declarations -->
<style id="theme-css">
<%= Phoenix.HTML.raw(@generated_css) %>
</style>
<script defer phx-track-static src={~p"/assets/js/app.js"}>
</script>
<script>

View File

@ -40,6 +40,7 @@ defmodule BerrypodWeb.Router do
pipeline :admin do
plug :put_root_layout, html: {BerrypodWeb.Layouts, :admin_root}
plug BerrypodWeb.Plugs.LoadTheme
end
# Public storefront (root level)

View File

@ -76,15 +76,15 @@ defmodule Berrypod.SettingsTest do
# Cache should now contain new CSS with the red accent color
{:ok, updated_css} = CSSCache.get()
assert updated_css =~ ".themed {"
# Red = hue 0
assert updated_css =~ "--t-accent-h: 0"
# Red in oklch ≈ hue 29°
assert updated_css =~ "--t-accent-h: 29.23"
# Change to blue
{:ok, _settings} = Settings.update_theme_settings(%{accent_color: "#0000ff"})
{:ok, blue_css} = CSSCache.get()
# Blue = hue 240
assert blue_css =~ "--t-accent-h: 240"
# Blue in oklch ≈ hue 264°
assert blue_css =~ "--t-accent-h: 264.05"
# Restore default
CSSCache.warm()

View File

@ -12,44 +12,54 @@ defmodule Berrypod.Theme.CSSGeneratorTest do
assert is_binary(css)
# CSS targets .themed (used by both shop and preview)
assert css =~ ".themed {"
assert css =~ "--t-accent-h:"
assert css =~ "--t-accent-s:"
assert css =~ "--t-accent-l:"
assert css =~ "--t-accent-c:"
assert css =~ "--t-accent-h:"
# Should include all theme token categories
assert css =~ "--t-surface-base:"
assert css =~ "--t-font-heading:"
assert css =~ "--t-radius-sm:"
assert css =~ "--t-density:"
# Should include color-scheme
assert css =~ "color-scheme:"
end
test "converts hex colors to HSL" do
test "converts hex colours to oklch" do
settings = %ThemeSettings{accent_color: "#ff0000"}
css = CSSGenerator.generate(settings)
# Red should be H=0, S=100%, L=50%
assert css =~ "--t-accent-h: 0"
assert css =~ "--t-accent-s: 100%"
assert css =~ "--t-accent-l: 50%"
# Red in oklch: L≈62.8%, C≈0.2577, H≈29.23°
assert css =~ "--t-accent-l: 62.8"
assert css =~ "--t-accent-c: 0.257"
assert css =~ "--t-accent-h: 29.2"
end
test "handles blue accent color" do
test "handles blue accent colour" do
settings = %ThemeSettings{accent_color: "#0000ff"}
css = CSSGenerator.generate(settings)
# Blue should be H=240, S=100%, L=50%
assert css =~ "--t-accent-h: 240"
assert css =~ "--t-accent-s: 100%"
assert css =~ "--t-accent-l: 50%"
# Blue in oklch: L≈45.2%, C≈0.3132, H≈264.05°
assert css =~ "--t-accent-l: 45.2"
assert css =~ "--t-accent-c: 0.313"
assert css =~ "--t-accent-h: 264."
end
test "handles green accent color" do
test "handles green accent colour" do
settings = %ThemeSettings{accent_color: "#00ff00"}
css = CSSGenerator.generate(settings)
# Green should be H=120, S=100%, L=50%
assert css =~ "--t-accent-h: 120"
assert css =~ "--t-accent-s: 100%"
assert css =~ "--t-accent-l: 50%"
# Green in oklch: L≈86.6%, C≈0.2948, H≈142.5°
assert css =~ "--t-accent-l: 86.6"
assert css =~ "--t-accent-c: 0.294"
assert css =~ "--t-accent-h: 142."
end
test "sets color-scheme based on mood" do
light = CSSGenerator.generate(%ThemeSettings{mood: "neutral"})
dark = CSSGenerator.generate(%ThemeSettings{mood: "dark"})
assert light =~ "color-scheme: light"
assert dark =~ "color-scheme: dark"
end
test "includes secondary colors" do

View File

@ -145,9 +145,9 @@ defmodule BerrypodWeb.ThemeCSSConsistencyTest do
assert css =~ "--space-lg:"
# Slider-controlled values
assert css =~ "--t-accent-h:"
assert css =~ "--t-accent-s:"
assert css =~ "--t-accent-l:"
assert css =~ "--t-accent-c:"
assert css =~ "--t-accent-h:"
assert css =~ "--t-font-size-scale:"
assert css =~ "--t-heading-weight-override:"
end