feat: add CSS generation system with custom properties and ETS cache
- Create theme-primitives.css with spacing, fonts, radius scales - Create theme-semantic.css with semantic CSS variable aliases - Update app.css to import theme CSS files - Add CSSGenerator module for dynamic CSS token generation - Generates mood variables (neutral, warm, cool, dark) - Generates typography variables (7 font combinations) - Generates shape variables (sharp, soft, round, pill) - Generates density variables (spacious, balanced, compact) - Converts hex colors to HSL for flexible manipulation - Add CSSCache GenServer with ETS table for performance - Caches generated CSS to avoid regeneration - Warms cache on application startup - Provides invalidation for theme updates - Add CSSCache to application supervision tree - Add comprehensive tests for CSS generation (29 tests) - Add comprehensive tests for preset validation (14 tests) - All tests passing (58 total tests, 0 failures) - Verified CSS generation and caching work correctly in IEx
This commit is contained in:
107
lib/simpleshop_theme/theme/css_cache.ex
Normal file
107
lib/simpleshop_theme/theme/css_cache.ex
Normal file
@@ -0,0 +1,107 @@
|
||||
defmodule SimpleshopTheme.Theme.CSSCache do
|
||||
@moduledoc """
|
||||
GenServer that maintains an ETS table for caching generated theme CSS.
|
||||
|
||||
This provides fast lookups for theme CSS without regenerating it on every request.
|
||||
The cache is invalidated when theme settings are updated.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
@table_name :theme_css_cache
|
||||
|
||||
## Client API
|
||||
|
||||
@doc """
|
||||
Starts the CSS cache GenServer.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets cached CSS for the site theme.
|
||||
|
||||
Returns `{:ok, css}` if found in cache, or `:miss` if not cached.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> CSSCache.get()
|
||||
{:ok, "/* Theme CSS ... */"}
|
||||
|
||||
iex> CSSCache.get()
|
||||
:miss
|
||||
|
||||
"""
|
||||
def get do
|
||||
case :ets.lookup(@table_name, :site_theme) do
|
||||
[{:site_theme, css}] -> {:ok, css}
|
||||
[] -> :miss
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Caches CSS for the site theme.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> CSSCache.put(css_string)
|
||||
:ok
|
||||
|
||||
"""
|
||||
def put(css) when is_binary(css) do
|
||||
:ets.insert(@table_name, {:site_theme, css})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Invalidates the cached CSS, forcing regeneration on next request.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> CSSCache.invalidate()
|
||||
:ok
|
||||
|
||||
"""
|
||||
def invalidate do
|
||||
:ets.delete(@table_name, :site_theme)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Warms the cache by generating and storing CSS from current theme settings.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> CSSCache.warm()
|
||||
:ok
|
||||
|
||||
"""
|
||||
def warm do
|
||||
alias SimpleshopTheme.Settings
|
||||
alias SimpleshopTheme.Theme.CSSGenerator
|
||||
|
||||
settings = Settings.get_theme_settings()
|
||||
css = CSSGenerator.generate(settings)
|
||||
put(css)
|
||||
:ok
|
||||
end
|
||||
|
||||
## Server Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
:ets.new(@table_name, [
|
||||
:set,
|
||||
:public,
|
||||
:named_table,
|
||||
read_concurrency: true,
|
||||
write_concurrency: false
|
||||
])
|
||||
|
||||
# Warm the cache on startup
|
||||
warm()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
end
|
||||
274
lib/simpleshop_theme/theme/css_generator.ex
Normal file
274
lib/simpleshop_theme/theme/css_generator.ex
Normal file
@@ -0,0 +1,274 @@
|
||||
defmodule SimpleshopTheme.Theme.CSSGenerator do
|
||||
@moduledoc """
|
||||
Generates CSS custom properties (Layer 2: Theme Tokens) from theme settings.
|
||||
|
||||
This module converts ThemeSettings into CSS variables that bridge the gap
|
||||
between fixed primitives (Layer 1) and semantic aliases (Layer 3).
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Settings.ThemeSettings
|
||||
|
||||
@doc """
|
||||
Generates CSS for theme settings.
|
||||
|
||||
Returns a string of CSS custom properties that can be injected into a <style> tag.
|
||||
"""
|
||||
def generate(%ThemeSettings{} = settings) do
|
||||
"""
|
||||
/* Theme Tokens - Layer 2 (dynamically generated) */
|
||||
.preview-frame, .shop-root {
|
||||
#{generate_accent(settings.accent_color)}
|
||||
#{generate_secondary_colors(settings)}
|
||||
#{generate_mood(settings.mood)}
|
||||
#{generate_typography(settings.typography)}
|
||||
#{generate_shape(settings.shape)}
|
||||
#{generate_density(settings.density)}
|
||||
}
|
||||
"""
|
||||
|> String.trim()
|
||||
end
|
||||
|
||||
# Accent color with HSL breakdown
|
||||
defp generate_accent(hex_color) do
|
||||
{h, s, l} = hex_to_hsl(hex_color)
|
||||
|
||||
"""
|
||||
--t-accent-h: #{h};
|
||||
--t-accent-s: #{s}%;
|
||||
--t-accent-l: #{l}%;
|
||||
"""
|
||||
end
|
||||
|
||||
# Secondary colors
|
||||
defp generate_secondary_colors(settings) do
|
||||
"""
|
||||
--t-secondary-accent: #{settings.secondary_accent_color};
|
||||
--t-sale-color: #{settings.sale_color};
|
||||
"""
|
||||
end
|
||||
|
||||
# Mood variations (color schemes)
|
||||
defp generate_mood("neutral") do
|
||||
"""
|
||||
--t-surface-base: #ffffff;
|
||||
--t-surface-raised: #ffffff;
|
||||
--t-surface-sunken: #f5f5f5;
|
||||
--t-surface-overlay: rgba(255, 255, 255, 0.95);
|
||||
--t-text-primary: #171717;
|
||||
--t-text-secondary: #525252;
|
||||
--t-text-tertiary: #a3a3a3;
|
||||
--t-text-inverse: #ffffff;
|
||||
--t-border-default: #e5e5e5;
|
||||
--t-border-subtle: #f0f0f0;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_mood("warm") do
|
||||
"""
|
||||
--t-surface-base: #fdf8f3;
|
||||
--t-surface-raised: #fffcf8;
|
||||
--t-surface-sunken: #f5ebe0;
|
||||
--t-surface-overlay: rgba(253, 248, 243, 0.95);
|
||||
--t-text-primary: #1c1917;
|
||||
--t-text-secondary: #57534e;
|
||||
--t-text-tertiary: #a8a29e;
|
||||
--t-text-inverse: #ffffff;
|
||||
--t-border-default: #e7e0d8;
|
||||
--t-border-subtle: #f0ebe4;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_mood("cool") do
|
||||
"""
|
||||
--t-surface-base: #f4f7fb;
|
||||
--t-surface-raised: #f8fafc;
|
||||
--t-surface-sunken: #e8eff7;
|
||||
--t-surface-overlay: rgba(244, 247, 251, 0.95);
|
||||
--t-text-primary: #0f172a;
|
||||
--t-text-secondary: #475569;
|
||||
--t-text-tertiary: #94a3b8;
|
||||
--t-text-inverse: #ffffff;
|
||||
--t-border-default: #d4dce8;
|
||||
--t-border-subtle: #e8eff5;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_mood("dark") do
|
||||
"""
|
||||
--t-surface-base: #0a0a0a;
|
||||
--t-surface-raised: #171717;
|
||||
--t-surface-sunken: #000000;
|
||||
--t-surface-overlay: rgba(23, 23, 23, 0.95);
|
||||
--t-text-primary: #fafafa;
|
||||
--t-text-secondary: #a3a3a3;
|
||||
--t-text-tertiary: #737373;
|
||||
--t-text-inverse: #171717;
|
||||
--t-border-default: #262626;
|
||||
--t-border-subtle: #1c1c1c;
|
||||
--p-shadow-strength: 0.25;
|
||||
"""
|
||||
end
|
||||
|
||||
# Typography styles
|
||||
defp generate_typography("clean") do
|
||||
"""
|
||||
--t-font-heading: var(--p-font-inter);
|
||||
--t-font-body: var(--p-font-inter);
|
||||
--t-heading-weight: 600;
|
||||
--t-heading-tracking: -0.025em;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("editorial") do
|
||||
"""
|
||||
--t-font-heading: var(--p-font-fraunces);
|
||||
--t-font-body: var(--p-font-source);
|
||||
--t-heading-weight: 600;
|
||||
--t-heading-tracking: -0.02em;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("modern") do
|
||||
"""
|
||||
--t-font-heading: var(--p-font-space);
|
||||
--t-font-body: var(--p-font-space);
|
||||
--t-heading-weight: 500;
|
||||
--t-heading-tracking: -0.03em;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("classic") do
|
||||
"""
|
||||
--t-font-heading: var(--p-font-baskerville);
|
||||
--t-font-body: var(--p-font-source);
|
||||
--t-heading-weight: 400;
|
||||
--t-heading-tracking: 0;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("friendly") do
|
||||
"""
|
||||
--t-font-heading: var(--p-font-nunito);
|
||||
--t-font-body: var(--p-font-nunito);
|
||||
--t-heading-weight: 700;
|
||||
--t-heading-tracking: -0.01em;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("minimal") do
|
||||
"""
|
||||
--t-font-heading: var(--p-font-outfit);
|
||||
--t-font-body: var(--p-font-outfit);
|
||||
--t-heading-weight: 300;
|
||||
--t-heading-tracking: 0;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("impulse") do
|
||||
"""
|
||||
--t-font-heading: var(--p-font-avenir);
|
||||
--t-font-body: var(--p-font-avenir);
|
||||
--t-heading-weight: 300;
|
||||
--t-heading-tracking: 0.02em;
|
||||
"""
|
||||
end
|
||||
|
||||
# Shape variations (border radius)
|
||||
defp generate_shape("sharp") do
|
||||
"""
|
||||
--t-radius-button: 0;
|
||||
--t-radius-card: 0;
|
||||
--t-radius-input: 0;
|
||||
--t-radius-image: 0;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_shape("soft") do
|
||||
"""
|
||||
--t-radius-button: var(--p-radius-md);
|
||||
--t-radius-card: var(--p-radius-lg);
|
||||
--t-radius-input: var(--p-radius-md);
|
||||
--t-radius-image: var(--p-radius-md);
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_shape("round") do
|
||||
"""
|
||||
--t-radius-button: var(--p-radius-lg);
|
||||
--t-radius-card: var(--p-radius-xl);
|
||||
--t-radius-input: var(--p-radius-lg);
|
||||
--t-radius-image: var(--p-radius-lg);
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_shape("pill") do
|
||||
"""
|
||||
--t-radius-button: var(--p-radius-full);
|
||||
--t-radius-card: var(--p-radius-xl);
|
||||
--t-radius-input: var(--p-radius-full);
|
||||
--t-radius-image: var(--p-radius-lg);
|
||||
"""
|
||||
end
|
||||
|
||||
# Density variations (spacing multiplier)
|
||||
defp generate_density("spacious") do
|
||||
"""
|
||||
--t-density: 1.25;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_density("balanced") do
|
||||
"""
|
||||
--t-density: 1;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_density("compact") do
|
||||
"""
|
||||
--t-density: 0.85;
|
||||
"""
|
||||
end
|
||||
|
||||
# Convert hex color to HSL
|
||||
defp hex_to_hsl("#" <> hex), do: hex_to_hsl(hex)
|
||||
|
||||
defp hex_to_hsl(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
|
||||
|
||||
max = Enum.max([r, g, b])
|
||||
min = Enum.min([r, g, b])
|
||||
delta = max - min
|
||||
|
||||
# Calculate lightness
|
||||
l = (max + min) / 2
|
||||
|
||||
# Calculate saturation and hue
|
||||
{h, s} =
|
||||
if delta == 0 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}
|
||||
end
|
||||
|
||||
{round(h), round(s * 100), round(l * 100)}
|
||||
end
|
||||
|
||||
# Handle invalid hex values
|
||||
defp hex_to_hsl(_), do: {0, 0, 50}
|
||||
end
|
||||
Reference in New Issue
Block a user