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

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