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:
parent
a401365943
commit
878d8f63ac
@ -102,4 +102,10 @@
|
||||
/* Make LiveView wrapper divs transparent for layout */
|
||||
[data-phx-session], [data-phx-teleported-src] { display: contents }
|
||||
|
||||
/* Theme CSS - Layer 1: Primitives (fixed CSS variables) */
|
||||
@import url("/css/theme-primitives.css");
|
||||
|
||||
/* Theme CSS - Layer 3: Semantic aliases */
|
||||
@import url("/css/theme-semantic.css");
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
|
||||
@ -14,8 +14,8 @@ defmodule SimpleshopTheme.Application do
|
||||
repos: Application.fetch_env!(:simpleshop_theme, :ecto_repos), skip: skip_migrations?()},
|
||||
{DNSCluster, query: Application.get_env(:simpleshop_theme, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: SimpleshopTheme.PubSub},
|
||||
# Start a worker by calling: SimpleshopTheme.Worker.start_link(arg)
|
||||
# {SimpleshopTheme.Worker, arg},
|
||||
# Theme CSS cache
|
||||
SimpleshopTheme.Theme.CSSCache,
|
||||
# Start to serve requests, typically the last entry
|
||||
SimpleshopThemeWeb.Endpoint
|
||||
]
|
||||
|
||||
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
|
||||
58
priv/static/css/theme-primitives.css
Normal file
58
priv/static/css/theme-primitives.css
Normal file
@ -0,0 +1,58 @@
|
||||
/* ========================================
|
||||
THEME PRIMITIVES - Layer 1
|
||||
Fixed CSS custom properties
|
||||
======================================== */
|
||||
|
||||
:root {
|
||||
/* Spacing scale */
|
||||
--p-space-1: 0.25rem;
|
||||
--p-space-2: 0.5rem;
|
||||
--p-space-3: 0.75rem;
|
||||
--p-space-4: 1rem;
|
||||
--p-space-6: 1.5rem;
|
||||
--p-space-8: 2rem;
|
||||
--p-space-12: 3rem;
|
||||
--p-space-16: 4rem;
|
||||
--p-space-24: 6rem;
|
||||
|
||||
/* Border radius scale */
|
||||
--p-radius-none: 0;
|
||||
--p-radius-sm: 0.25rem;
|
||||
--p-radius-md: 0.5rem;
|
||||
--p-radius-lg: 0.75rem;
|
||||
--p-radius-xl: 1rem;
|
||||
--p-radius-full: 9999px;
|
||||
|
||||
/* Font families */
|
||||
--p-font-inter: 'Inter', system-ui, sans-serif;
|
||||
--p-font-fraunces: 'Fraunces', serif;
|
||||
--p-font-source: 'Source Sans 3', system-ui, sans-serif;
|
||||
--p-font-space: 'Space Grotesk', system-ui, sans-serif;
|
||||
--p-font-baskerville: 'Libre Baskerville', Georgia, serif;
|
||||
--p-font-nunito: 'Nunito', system-ui, sans-serif;
|
||||
--p-font-outfit: 'Outfit', system-ui, sans-serif;
|
||||
--p-font-avenir: 'Nunito Sans', 'Avenir Next', 'Avenir', system-ui, sans-serif;
|
||||
|
||||
/* Font size scale */
|
||||
--p-text-xs: 0.75rem;
|
||||
--p-text-sm: 0.875rem;
|
||||
--p-text-base: 1rem;
|
||||
--p-text-lg: 1.125rem;
|
||||
--p-text-xl: 1.25rem;
|
||||
--p-text-2xl: 1.5rem;
|
||||
--p-text-3xl: 1.875rem;
|
||||
--p-text-4xl: 2.25rem;
|
||||
|
||||
/* Animation durations */
|
||||
--p-duration-fast: 0.1s;
|
||||
--p-duration-normal: 0.2s;
|
||||
--p-duration-slow: 0.35s;
|
||||
|
||||
/* Easing functions */
|
||||
--p-ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
--p-ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* Shadow base */
|
||||
--p-shadow-color: 0 0% 0%;
|
||||
--p-shadow-strength: 0.06;
|
||||
}
|
||||
80
priv/static/css/theme-semantic.css
Normal file
80
priv/static/css/theme-semantic.css
Normal file
@ -0,0 +1,80 @@
|
||||
/* ========================================
|
||||
THEME SEMANTIC - Layer 3
|
||||
Semantic aliases for easy usage
|
||||
======================================== */
|
||||
|
||||
:root {
|
||||
/* Accent color (dynamic, set by theme) */
|
||||
--t-accent-h: 24;
|
||||
--t-accent-s: 95%;
|
||||
--t-accent-l: 53%;
|
||||
--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);
|
||||
|
||||
/* Secondary colors */
|
||||
--t-secondary-accent: #ea580c;
|
||||
--t-sale-color: #dc2626;
|
||||
|
||||
/* Density multiplier */
|
||||
--t-density: 1;
|
||||
|
||||
/* Layout */
|
||||
--t-layout-max-width: 1400px;
|
||||
--t-button-style: filled;
|
||||
--t-card-shadow: none;
|
||||
--t-product-text-align: left;
|
||||
|
||||
/* Page colors */
|
||||
--color-page: var(--t-surface-base);
|
||||
--color-card: var(--t-surface-raised);
|
||||
--color-input: var(--t-surface-raised);
|
||||
|
||||
/* Text colors */
|
||||
--color-heading: var(--t-text-primary);
|
||||
--color-body: var(--t-text-secondary);
|
||||
--color-caption: var(--t-text-tertiary);
|
||||
|
||||
/* Button colors */
|
||||
--color-button-primary: var(--t-accent);
|
||||
--color-button-primary-hover: var(--t-secondary-accent);
|
||||
--color-button-primary-text: var(--t-text-inverse);
|
||||
|
||||
/* Border colors */
|
||||
--color-border: var(--t-border-default);
|
||||
|
||||
/* Typography */
|
||||
--font-heading: var(--t-font-heading);
|
||||
--font-body: var(--t-font-body);
|
||||
--weight-heading: var(--t-heading-weight);
|
||||
--tracking-heading: var(--t-heading-tracking);
|
||||
|
||||
/* Responsive spacing (density-aware) */
|
||||
--space-xs: calc(var(--p-space-2) * var(--t-density));
|
||||
--space-sm: calc(var(--p-space-3) * var(--t-density));
|
||||
--space-md: calc(var(--p-space-4) * var(--t-density));
|
||||
--space-lg: calc(var(--p-space-6) * var(--t-density));
|
||||
--space-xl: calc(var(--p-space-8) * var(--t-density));
|
||||
--space-2xl: calc(var(--p-space-12) * var(--t-density));
|
||||
|
||||
/* Border radius */
|
||||
--radius-button: var(--t-radius-button);
|
||||
--radius-card: var(--t-radius-card);
|
||||
--radius-input: var(--t-radius-input);
|
||||
--radius-image: var(--t-radius-image);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm:
|
||||
0 1px 2px hsl(var(--p-shadow-color) / calc(var(--p-shadow-strength) * 0.5)),
|
||||
0 1px 3px hsl(var(--p-shadow-color) / var(--p-shadow-strength));
|
||||
--shadow-md:
|
||||
0 2px 4px hsl(var(--p-shadow-color) / calc(--p-shadow-strength) * 0.5)),
|
||||
0 4px 8px hsl(var(--p-shadow-color) / var(--p-shadow-strength)),
|
||||
0 8px 16px hsl(var(--p-shadow-color) / calc(var(--p-shadow-strength) * 0.5));
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: var(--p-duration-fast) var(--p-ease-out);
|
||||
--transition-normal: var(--p-duration-normal) var(--p-ease-out);
|
||||
--transition-bounce: var(--p-duration-normal) var(--p-ease-out-back);
|
||||
}
|
||||
153
test/simpleshop_theme/theme/css_generator_test.exs
Normal file
153
test/simpleshop_theme/theme/css_generator_test.exs
Normal file
@ -0,0 +1,153 @@
|
||||
defmodule SimpleshopTheme.Theme.CSSGeneratorTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias SimpleshopTheme.Theme.CSSGenerator
|
||||
alias SimpleshopTheme.Settings.ThemeSettings
|
||||
|
||||
describe "generate/1" do
|
||||
test "generates CSS for default theme settings" do
|
||||
settings = %ThemeSettings{}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert is_binary(css)
|
||||
assert css =~ ".preview-frame, .shop-root"
|
||||
assert css =~ "--t-accent-h:"
|
||||
assert css =~ "--t-accent-s:"
|
||||
assert css =~ "--t-accent-l:"
|
||||
end
|
||||
|
||||
test "generates correct mood variables for neutral" do
|
||||
settings = %ThemeSettings{mood: "neutral"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-surface-base: #ffffff"
|
||||
assert css =~ "--t-text-primary: #171717"
|
||||
assert css =~ "--t-border-default: #e5e5e5"
|
||||
end
|
||||
|
||||
test "generates correct mood variables for dark" do
|
||||
settings = %ThemeSettings{mood: "dark"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-surface-base: #0a0a0a"
|
||||
assert css =~ "--t-text-primary: #fafafa"
|
||||
assert css =~ "--t-border-default: #262626"
|
||||
assert css =~ "--p-shadow-strength: 0.25"
|
||||
end
|
||||
|
||||
test "generates correct mood variables for warm" do
|
||||
settings = %ThemeSettings{mood: "warm"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-surface-base: #fdf8f3"
|
||||
assert css =~ "--t-text-primary: #1c1917"
|
||||
end
|
||||
|
||||
test "generates correct mood variables for cool" do
|
||||
settings = %ThemeSettings{mood: "cool"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-surface-base: #f4f7fb"
|
||||
assert css =~ "--t-text-primary: #0f172a"
|
||||
end
|
||||
|
||||
test "generates correct typography for clean" do
|
||||
settings = %ThemeSettings{typography: "clean"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-font-heading: var(--p-font-inter)"
|
||||
assert css =~ "--t-font-body: var(--p-font-inter)"
|
||||
assert css =~ "--t-heading-weight: 600"
|
||||
end
|
||||
|
||||
test "generates correct typography for editorial" do
|
||||
settings = %ThemeSettings{typography: "editorial"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-font-heading: var(--p-font-fraunces)"
|
||||
assert css =~ "--t-font-body: var(--p-font-source)"
|
||||
end
|
||||
|
||||
test "generates correct typography for modern" do
|
||||
settings = %ThemeSettings{typography: "modern"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-font-heading: var(--p-font-space)"
|
||||
assert css =~ "--t-heading-weight: 500"
|
||||
end
|
||||
|
||||
test "generates correct shape for sharp" do
|
||||
settings = %ThemeSettings{shape: "sharp"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-radius-button: 0"
|
||||
assert css =~ "--t-radius-card: 0"
|
||||
end
|
||||
|
||||
test "generates correct shape for soft" do
|
||||
settings = %ThemeSettings{shape: "soft"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-radius-button: var(--p-radius-md)"
|
||||
assert css =~ "--t-radius-card: var(--p-radius-lg)"
|
||||
end
|
||||
|
||||
test "generates correct shape for round" do
|
||||
settings = %ThemeSettings{shape: "round"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-radius-button: var(--p-radius-lg)"
|
||||
assert css =~ "--t-radius-card: var(--p-radius-xl)"
|
||||
end
|
||||
|
||||
test "generates correct shape for pill" do
|
||||
settings = %ThemeSettings{shape: "pill"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-radius-button: var(--p-radius-full)"
|
||||
end
|
||||
|
||||
test "generates correct density for spacious" do
|
||||
settings = %ThemeSettings{density: "spacious"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-density: 1.25"
|
||||
end
|
||||
|
||||
test "generates correct density for balanced" do
|
||||
settings = %ThemeSettings{density: "balanced"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-density: 1"
|
||||
end
|
||||
|
||||
test "generates correct density for compact" do
|
||||
settings = %ThemeSettings{density: "compact"}
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-density: 0.85"
|
||||
end
|
||||
|
||||
test "converts hex colors to HSL" 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%"
|
||||
end
|
||||
|
||||
test "includes secondary colors" do
|
||||
settings = %ThemeSettings{
|
||||
secondary_accent_color: "#ea580c",
|
||||
sale_color: "#dc2626"
|
||||
}
|
||||
|
||||
css = CSSGenerator.generate(settings)
|
||||
|
||||
assert css =~ "--t-secondary-accent: #ea580c"
|
||||
assert css =~ "--t-sale-color: #dc2626"
|
||||
end
|
||||
end
|
||||
end
|
||||
133
test/simpleshop_theme/theme/presets_test.exs
Normal file
133
test/simpleshop_theme/theme/presets_test.exs
Normal file
@ -0,0 +1,133 @@
|
||||
defmodule SimpleshopTheme.Theme.PresetsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias SimpleshopTheme.Theme.Presets
|
||||
|
||||
describe "all/0" do
|
||||
test "returns all 9 presets" do
|
||||
presets = Presets.all()
|
||||
|
||||
assert is_map(presets)
|
||||
assert map_size(presets) == 9
|
||||
assert Map.has_key?(presets, :gallery)
|
||||
assert Map.has_key?(presets, :studio)
|
||||
assert Map.has_key?(presets, :boutique)
|
||||
assert Map.has_key?(presets, :bold)
|
||||
assert Map.has_key?(presets, :playful)
|
||||
assert Map.has_key?(presets, :minimal)
|
||||
assert Map.has_key?(presets, :night)
|
||||
assert Map.has_key?(presets, :classic)
|
||||
assert Map.has_key?(presets, :impulse)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get/1" do
|
||||
test "returns gallery preset with correct settings" do
|
||||
preset = Presets.get(:gallery)
|
||||
|
||||
assert preset.mood == "warm"
|
||||
assert preset.typography == "editorial"
|
||||
assert preset.shape == "soft"
|
||||
assert preset.density == "spacious"
|
||||
assert preset.grid_columns == "3"
|
||||
assert preset.header_layout == "centered"
|
||||
assert preset.accent_color == "#e85d04"
|
||||
end
|
||||
|
||||
test "returns studio preset with correct settings" do
|
||||
preset = Presets.get(:studio)
|
||||
|
||||
assert preset.mood == "neutral"
|
||||
assert preset.typography == "clean"
|
||||
assert preset.shape == "soft"
|
||||
assert preset.density == "balanced"
|
||||
assert preset.grid_columns == "4"
|
||||
assert preset.header_layout == "standard"
|
||||
assert preset.accent_color == "#3b82f6"
|
||||
end
|
||||
|
||||
test "returns boutique preset" do
|
||||
preset = Presets.get(:boutique)
|
||||
|
||||
assert preset.mood == "warm"
|
||||
assert preset.typography == "classic"
|
||||
assert preset.accent_color == "#b45309"
|
||||
end
|
||||
|
||||
test "returns bold preset" do
|
||||
preset = Presets.get(:bold)
|
||||
|
||||
assert preset.mood == "neutral"
|
||||
assert preset.typography == "modern"
|
||||
assert preset.shape == "sharp"
|
||||
assert preset.accent_color == "#dc2626"
|
||||
end
|
||||
|
||||
test "returns playful preset" do
|
||||
preset = Presets.get(:playful)
|
||||
|
||||
assert preset.typography == "friendly"
|
||||
assert preset.shape == "pill"
|
||||
assert preset.accent_color == "#8b5cf6"
|
||||
end
|
||||
|
||||
test "returns minimal preset" do
|
||||
preset = Presets.get(:minimal)
|
||||
|
||||
assert preset.mood == "cool"
|
||||
assert preset.typography == "minimal"
|
||||
assert preset.shape == "sharp"
|
||||
assert preset.accent_color == "#171717"
|
||||
end
|
||||
|
||||
test "returns night preset" do
|
||||
preset = Presets.get(:night)
|
||||
|
||||
assert preset.mood == "dark"
|
||||
assert preset.typography == "modern"
|
||||
assert preset.accent_color == "#f97316"
|
||||
end
|
||||
|
||||
test "returns classic preset" do
|
||||
preset = Presets.get(:classic)
|
||||
|
||||
assert preset.mood == "warm"
|
||||
assert preset.typography == "classic"
|
||||
assert preset.accent_color == "#166534"
|
||||
end
|
||||
|
||||
test "returns impulse preset with extended settings" do
|
||||
preset = Presets.get(:impulse)
|
||||
|
||||
assert preset.mood == "neutral"
|
||||
assert preset.typography == "impulse"
|
||||
assert preset.shape == "sharp"
|
||||
assert preset.accent_color == "#000000"
|
||||
assert preset.font_size == "medium"
|
||||
assert preset.heading_weight == "regular"
|
||||
assert preset.product_text_align == "center"
|
||||
end
|
||||
|
||||
test "returns nil for nonexistent preset" do
|
||||
assert Presets.get(:nonexistent) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_names/0" do
|
||||
test "returns list of all preset names" do
|
||||
names = Presets.list_names()
|
||||
|
||||
assert is_list(names)
|
||||
assert length(names) == 9
|
||||
assert :gallery in names
|
||||
assert :studio in names
|
||||
assert :boutique in names
|
||||
assert :bold in names
|
||||
assert :playful in names
|
||||
assert :minimal in names
|
||||
assert :night in names
|
||||
assert :classic in names
|
||||
assert :impulse in names
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user