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 prices (shared between cards and PDP) ── */
.product-price--sale { .product-price--sale {
color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-accent);
} }
.product-price--compare { .product-price--compare {
@ -254,7 +254,7 @@
.hero-pre-title { .hero-pre-title {
font-family: var(--t-font-heading); font-family: var(--t-font-heading);
font-weight: var(--t-heading-weight); 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); font-size: var(--t-heading-display);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -398,7 +398,7 @@
} }
.accent-link { .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; text-decoration: none;
cursor: pointer; cursor: pointer;
font-size: var(--t-text-small); font-size: var(--t-text-small);
@ -457,7 +457,7 @@
.collection-empty-link { .collection-empty-link {
display: inline-block; display: inline-block;
margin-top: 1rem; 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; text-decoration: underline;
} }
@ -518,7 +518,7 @@
} }
.pdp-thumbnail-active { .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 ── */ /* ── Variant selector ── */
@ -556,8 +556,8 @@
padding: 0; padding: 0;
&[aria-pressed="true"] { &[aria-pressed="true"] {
border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); border-color: var(--t-accent);
--tw-ring-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); --tw-ring-color: var(--t-accent);
} }
} }
@ -572,8 +572,8 @@
cursor: pointer; cursor: pointer;
&[aria-pressed="true"] { &[aria-pressed="true"] {
border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); border-color: var(--t-accent);
background: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1); background: color-mix(in oklch, var(--t-accent) 10%, transparent);
} }
} }
@ -654,7 +654,7 @@
} }
.atc-btn { .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); color: var(--t-text-inverse);
border-radius: var(--t-radius-button); border-radius: var(--t-radius-button);
border: none; border: none;
@ -762,7 +762,7 @@
/* ── Announcement bar ── */ /* ── Announcement bar ── */
.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); color: var(--t-text-inverse);
text-align: center; text-align: center;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
@ -867,7 +867,7 @@
position: absolute; position: absolute;
top: -4px; top: -4px;
right: -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); color: var(--t-text-inverse);
font-size: var(--t-text-caption); font-size: var(--t-text-caption);
font-weight: 600; font-weight: 600;
@ -889,7 +889,7 @@
&[aria-current="page"] { &[aria-current="page"] {
color: var(--t-text-primary); 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"] { &[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; 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 { & svg {
width: 1.5rem; width: 1.5rem;
@ -1273,7 +1273,7 @@
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
font-weight: 600; font-weight: 600;
transition: all 0.2s ease; 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); color: var(--t-text-inverse);
border-radius: var(--t-radius-button); border-radius: var(--t-radius-button);
border: none; border: none;
@ -1692,7 +1692,7 @@
/* ── Accent email link ── */ /* ── Accent email link ── */
.accent-email { .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) ── */ /* ── Card shared styles (info, tracking, newsletter, social cards) ── */
@ -1767,7 +1767,7 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 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; text-decoration: none;
& svg { & svg {
@ -1879,7 +1879,7 @@
.trust-badge-icon { .trust-badge-icon {
flex-shrink: 0; flex-shrink: 0;
margin-top: 0.125rem; margin-top: 0.125rem;
color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); color: var(--t-accent);
& svg { & svg {
width: 1.25rem; width: 1.25rem;

View File

@ -157,19 +157,21 @@
/* Button Style Variants */ /* Button Style Variants */
&[data-button-style="outline"] { &[data-button-style="outline"] {
& button[style*="background-color: hsl(var(--t-accent"], & .themed-button,
& a[style*="background-color: hsl(var(--t-accent"] { & .atc-btn,
& .cart-drawer-checkout {
background-color: transparent !important; background-color: transparent !important;
color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)) !important; color: var(--t-accent) !important;
border: 2px solid hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)) !important; border: 2px solid var(--t-accent) !important;
} }
} }
&[data-button-style="soft"] { &[data-button-style="soft"] {
& button[style*="background-color: hsl(var(--t-accent"], & .themed-button,
& a[style*="background-color: hsl(var(--t-accent"] { & .atc-btn,
background-color: hsl(var(--t-accent-h) var(--t-accent-s) 90%) !important; & .cart-drawer-checkout {
color: hsl(var(--t-accent-h) var(--t-accent-s) 30%) !important; 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; border: 2px solid transparent !important;
} }
} }
@ -231,12 +233,12 @@
} }
& .filter-pill-active { & .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); color: var(--t-text-inverse);
border-color: transparent; border-color: transparent;
&:hover { &: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); color: var(--t-text-inverse);
} }
} }
@ -250,7 +252,7 @@
&:focus { &:focus {
outline: none; outline: none;
border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); border-color: var(--t-accent);
} }
&::placeholder { &::placeholder {
@ -269,12 +271,12 @@
&:focus { &:focus {
outline: none; outline: none;
border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); border-color: var(--t-accent);
} }
} }
& .themed-button { & .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); color: var(--t-text-inverse);
border-radius: var(--t-radius-button); border-radius: var(--t-radius-button);
border: none; border: none;
@ -322,7 +324,7 @@
} }
.badge-new { .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); color: var(--t-text-inverse);
} }

View File

@ -13,21 +13,19 @@
color: var(--t-text-primary); color: var(--t-text-primary);
font-family: var(--t-font-body); font-family: var(--t-font-body);
/* Accent color - HSL components set dynamically by CSS generator */ /* Accent colour — oklch components set dynamically by CSSGenerator */
--t-accent-h: 24; --t-accent-l: 65%;
--t-accent-s: 95%; --t-accent-c: 0.2;
--t-accent-l: 53%; --t-accent-h: 47;
--t-accent: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l)); --t-accent: oklch(var(--t-accent-l) var(--t-accent-c) var(--t-accent-h));
--t-accent-hover: hsl(var(--t-accent-h) var(--t-accent-s) calc(var(--t-accent-l) - 8%)); --t-accent-hover: color-mix(in oklch, var(--t-accent) 85%, black);
--t-accent-subtle: hsl(var(--t-accent-h) 40% 95%); --t-accent-subtle: color-mix(in oklch, var(--t-accent) 8%, var(--t-surface-base));
--t-accent-ring: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.4); --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 */ /* WCAG AA compliant accent variants for better contrast */
/* Darker accent for text on light backgrounds (4.5:1 with white) */ --t-accent-text: color-mix(in oklch, var(--t-accent) 80%, black);
--t-accent-text: hsl(var(--t-accent-h) var(--t-accent-s) 38%); --t-accent-button: color-mix(in oklch, var(--t-accent) 85%, black);
/* 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%);
/* Secondary colors */ /* Secondary colors */
--t-secondary-accent: #ea580c; --t-secondary-accent: #ea580c;
@ -117,7 +115,7 @@
/* Dark mode accent-subtle override */ /* Dark mode accent-subtle override */
&[data-mood="dark"] { &[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, & textarea:focus-visible,
& [tabindex]:focus-visible, & [tabindex]:focus-visible,
& details summary: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; outline-offset: 2px;
} }
@ -230,9 +228,9 @@
} }
&.active { &.active {
background-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.12); background-color: color-mix(in oklch, var(--t-accent) 12%, transparent);
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);
border-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.25); border-color: color-mix(in oklch, var(--t-accent) 25%, transparent);
font-weight: 600; font-weight: 600;
} }
} }
@ -273,7 +271,7 @@
top: -100px; top: -100px;
left: 50%; left: 50%;
transform: translateX(-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); color: var(--t-text-inverse);
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
z-index: 9999; z-index: 9999;
@ -312,5 +310,5 @@
/* Active nav underline — must stay unlayered to match the base border above */ /* Active nav underline — must stay unlayered to match the base border above */
.shop-nav [aria-current="page"] { .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 # Mood colors - surface, text, and border colors
defp generate_mood("neutral") do defp generate_mood("neutral") do
""" """
--t-surface-base: #ffffff; color-scheme: light;
--t-surface-base: #ffffff;
--t-surface-raised: #ffffff; --t-surface-raised: #ffffff;
--t-surface-sunken: #f5f5f5; --t-surface-sunken: #f5f5f5;
--t-surface-overlay: rgba(255, 255, 255, 0.95); --t-surface-overlay: rgba(255, 255, 255, 0.95);
@ -76,7 +77,8 @@ defmodule Berrypod.Theme.CSSGenerator do
defp generate_mood("warm") do defp generate_mood("warm") do
""" """
--t-surface-base: #fdf8f3; color-scheme: light;
--t-surface-base: #fdf8f3;
--t-surface-raised: #fffcf8; --t-surface-raised: #fffcf8;
--t-surface-sunken: #f5ebe0; --t-surface-sunken: #f5ebe0;
--t-surface-overlay: rgba(253, 248, 243, 0.95); --t-surface-overlay: rgba(253, 248, 243, 0.95);
@ -91,7 +93,8 @@ defmodule Berrypod.Theme.CSSGenerator do
defp generate_mood("cool") do defp generate_mood("cool") do
""" """
--t-surface-base: #f4f7fb; color-scheme: light;
--t-surface-base: #f4f7fb;
--t-surface-raised: #f8fafc; --t-surface-raised: #f8fafc;
--t-surface-sunken: #e8eff7; --t-surface-sunken: #e8eff7;
--t-surface-overlay: rgba(244, 247, 251, 0.95); --t-surface-overlay: rgba(244, 247, 251, 0.95);
@ -106,7 +109,8 @@ defmodule Berrypod.Theme.CSSGenerator do
defp generate_mood("dark") do defp generate_mood("dark") do
""" """
--t-surface-base: #0a0a0a; color-scheme: dark;
--t-surface-base: #0a0a0a;
--t-surface-raised: #171717; --t-surface-raised: #171717;
--t-surface-sunken: #000000; --t-surface-sunken: #000000;
--t-surface-overlay: rgba(23, 23, 23, 0.95); --t-surface-overlay: rgba(23, 23, 23, 0.95);
@ -239,14 +243,14 @@ defmodule Berrypod.Theme.CSSGenerator do
# Fallback for any other density value # Fallback for any other density value
defp generate_density(_), do: generate_density("balanced") defp generate_density(_), do: generate_density("balanced")
# Accent color with HSL breakdown # Accent color with oklch breakdown
defp generate_accent(hex_color) do 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-l: #{l}%;
--t-accent-c: #{c};
--t-accent-h: #{h};
""" """
end end
@ -334,46 +338,55 @@ defmodule Berrypod.Theme.CSSGenerator do
"--t-image-aspect-ratio: 4 / 3;" "--t-image-aspect-ratio: 4 / 3;"
end end
# Convert hex color to HSL # Convert hex colour to oklch (lightness%, chroma, hue°)
defp hex_to_hsl("#" <> hex), do: hex_to_hsl(hex) # 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) {r, ""} = Integer.parse(String.slice(hex, 0..1), 16)
{g, ""} = Integer.parse(String.slice(hex, 2..3), 16) {g, ""} = Integer.parse(String.slice(hex, 2..3), 16)
{b, ""} = Integer.parse(String.slice(hex, 4..5), 16) {b, ""} = Integer.parse(String.slice(hex, 4..5), 16)
# Normalize RGB values to 0-1 # sRGB to linear RGB (inverse gamma)
r = r / 255 lr = srgb_to_linear(r / 255)
g = g / 255 lg = srgb_to_linear(g / 255)
b = b / 255 lb = srgb_to_linear(b / 255)
max = Enum.max([r, g, b]) # Linear RGB to oklab via the Björn Ottosson method
min = Enum.min([r, g, b]) # First: linear sRGB → LMS (cube root of cone response)
delta = max - min 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 # LMS to oklab
l = (max + min) / 2 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 # oklab to oklch
{h, s} = c = :math.sqrt(ok_a * ok_a + ok_b * ok_b)
if delta == 0 do
{0, 0} h =
if c < 0.0001 do
0.0
else else
s = if l > 0.5, do: delta / (2 - max - min), else: delta / (max + min) h_rad = :math.atan2(ok_b, ok_a)
h_deg = h_rad * 180 / :math.pi()
h = if h_deg < 0, do: h_deg + 360, else: h_deg
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}
end 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 end
# Handle invalid hex values # Handle invalid hex values (neutral grey fallback)
defp hex_to_hsl(_), do: {0, 0, 50} 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 end

View File

@ -1,21 +1,40 @@
defmodule BerrypodWeb.AdminLayoutHook do defmodule BerrypodWeb.AdminLayoutHook do
@moduledoc """ @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 import Phoenix.Component
alias Berrypod.Settings
alias Berrypod.Theme.{CSSCache, CSSGenerator}
def on_mount(:assign_current_path, _params, _session, socket) do 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 =
socket socket
|> assign(:current_path, "") |> 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, |> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,
uri, uri,
socket -> socket ->
{:cont, {:cont,
socket socket
|> assign(:current_path, URI.parse(uri).path) |> assign(:current_path, URI.parse(uri).path)
|> assign(:site_live, Berrypod.Settings.site_live?())} |> assign(:site_live, Settings.site_live?())}
end) end)
{:cont, socket} {:cont, socket}

View File

@ -7,12 +7,23 @@
<.live_title default="Admin" suffix=" · Berrypod"> <.live_title default="Admin" suffix=" · Berrypod">
{assigns[:page_title]} {assigns[:page_title]}
</.live_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 --> <!-- Pre-declare layer order so shop reset < Tailwind base regardless of load order -->
<style> <style>
@layer properties, reset, primitives, tokens, theme, base, components, layout, utilities, overrides; @layer properties, reset, primitives, tokens, theme, base, components, layout, utilities, overrides;
</style> </style>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/admin.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/admin.css"} />
<link phx-track-static rel="stylesheet" href={~p"/assets/css/shop.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 defer phx-track-static src={~p"/assets/js/app.js"}>
</script> </script>
<script> <script>

View File

@ -40,6 +40,7 @@ defmodule BerrypodWeb.Router do
pipeline :admin do pipeline :admin do
plug :put_root_layout, html: {BerrypodWeb.Layouts, :admin_root} plug :put_root_layout, html: {BerrypodWeb.Layouts, :admin_root}
plug BerrypodWeb.Plugs.LoadTheme
end end
# Public storefront (root level) # 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 # Cache should now contain new CSS with the red accent color
{:ok, updated_css} = CSSCache.get() {:ok, updated_css} = CSSCache.get()
assert updated_css =~ ".themed {" assert updated_css =~ ".themed {"
# Red = hue 0 # Red in oklch ≈ hue 29°
assert updated_css =~ "--t-accent-h: 0" assert updated_css =~ "--t-accent-h: 29.23"
# Change to blue # Change to blue
{:ok, _settings} = Settings.update_theme_settings(%{accent_color: "#0000ff"}) {:ok, _settings} = Settings.update_theme_settings(%{accent_color: "#0000ff"})
{:ok, blue_css} = CSSCache.get() {:ok, blue_css} = CSSCache.get()
# Blue = hue 240 # Blue in oklch ≈ hue 264°
assert blue_css =~ "--t-accent-h: 240" assert blue_css =~ "--t-accent-h: 264.05"
# Restore default # Restore default
CSSCache.warm() CSSCache.warm()

View File

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

View File

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