berrypod/lib/simpleshop_theme/theme/css_generator.ex

413 lines
10 KiB
Elixir
Raw Normal View History

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).
For the shop (public pages), this generates ALL theme tokens inline, so the
shop doesn't need the attribute-based selectors in theme-layer2-attributes.css.
The theme editor still uses those selectors for live preview switching.
"""
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.
This includes ALL theme tokens (mood, typography, shape, density) so the shop
pages don't need the attribute-based CSS selectors.
"""
def generate(%ThemeSettings{} = settings) do
"""
/* Theme Tokens - Layer 2 (dynamically generated) */
.themed {
/* Mood colors */
#{generate_mood(settings.mood)}
/* Typography */
#{generate_typography(settings.typography)}
/* Shape (border radii) */
#{generate_shape(settings.shape)}
/* Density */
#{generate_density(settings.density)}
/* Slider-controlled values */
#{generate_accent(settings.accent_color)}
#{generate_secondary_colors(settings)}
#{generate_font_size(settings.font_size)}
#{generate_heading_weight(settings.heading_weight)}
#{generate_layout_width(settings.layout_width)}
#{generate_button_style(settings.button_style)}
#{generate_product_text_align(settings.product_text_align)}
#{generate_image_aspect_ratio(settings.image_aspect_ratio)}
}
"""
|> String.trim()
end
# Mood colors - surface, text, and border colors
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: #737373;
--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: #78716c;
--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: #64748b;
--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;
"""
end
# Fallback for any other mood value
defp generate_mood(_), do: generate_mood("neutral")
# Typography - font families, weights, and tracking
defp generate_typography("clean") do
"""
--t-font-heading: 'Manrope', system-ui, sans-serif;
--t-font-body: 'Inter', system-ui, sans-serif;
--t-heading-weight: 600;
--t-heading-tracking: -0.02em;
"""
end
defp generate_typography("editorial") do
"""
--t-font-heading: 'Playfair Display', Georgia, serif;
--t-font-body: 'Raleway', system-ui, sans-serif;
--t-heading-weight: 500;
--t-heading-tracking: -0.01em;
"""
end
defp generate_typography("modern") do
"""
--t-font-heading: 'Space Grotesk', system-ui, sans-serif;
--t-font-body: 'Inter', system-ui, sans-serif;
--t-heading-weight: 500;
--t-heading-tracking: -0.03em;
"""
end
defp generate_typography("classic") do
"""
--t-font-heading: 'Cormorant Garamond', Georgia, serif;
--t-font-body: 'Source Serif 4', Georgia, serif;
--t-heading-weight: 500;
--t-heading-tracking: 0;
"""
end
defp generate_typography("friendly") do
"""
--t-font-heading: 'Fraunces', Georgia, serif;
--t-font-body: 'Work Sans', system-ui, sans-serif;
--t-heading-weight: 600;
--t-heading-tracking: -0.01em;
"""
end
defp generate_typography("minimal") do
"""
--t-font-heading: 'DM Sans', system-ui, sans-serif;
--t-font-body: 'Source Serif 4', Georgia, serif;
--t-heading-weight: 500;
--t-heading-tracking: 0;
"""
end
defp generate_typography("impulse") do
"""
--t-font-heading: 'Raleway', system-ui, sans-serif;
--t-font-body: 'Inter', system-ui, sans-serif;
--t-heading-weight: 300;
--t-heading-tracking: 0.02em;
"""
end
# Fallback for any other typography value
defp generate_typography(_), do: generate_typography("clean")
# Shape - border radii
defp generate_shape("soft") do
"""
--t-radius-sm: 0.25rem;
--t-radius-md: 0.5rem;
--t-radius-lg: 0.75rem;
--t-radius-button: 0.5rem;
--t-radius-card: 0.75rem;
--t-radius-input: 0.5rem;
--t-radius-image: 0.5rem;
"""
end
defp generate_shape("sharp") do
"""
--t-radius-sm: 0;
--t-radius-md: 0;
--t-radius-lg: 0;
--t-radius-button: 0;
--t-radius-card: 0;
--t-radius-input: 0;
--t-radius-image: 0;
"""
end
defp generate_shape("round") do
"""
--t-radius-sm: 0.5rem;
--t-radius-md: 0.75rem;
--t-radius-lg: 1rem;
--t-radius-button: 0.75rem;
--t-radius-card: 1rem;
--t-radius-input: 0.75rem;
--t-radius-image: 0.75rem;
"""
end
defp generate_shape("pill") do
"""
--t-radius-sm: 9999px;
--t-radius-md: 9999px;
--t-radius-lg: 1rem;
--t-radius-button: 9999px;
--t-radius-card: 1rem;
--t-radius-input: 9999px;
--t-radius-image: 0.75rem;
"""
end
# Fallback for any other shape value
defp generate_shape(_), do: generate_shape("soft")
# Density - spacing multiplier
defp generate_density("balanced") do
"""
--t-density: 1;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
"""
end
defp generate_density("spacious") do
"""
--t-density: 1.25;
--space-xs: 0.625rem;
--space-sm: 0.9375rem;
--space-md: 1.25rem;
--space-lg: 1.875rem;
--space-xl: 2.5rem;
--space-2xl: 3.75rem;
"""
end
defp generate_density("compact") do
"""
--t-density: 0.85;
--space-xs: 0.425rem;
--space-sm: 0.6375rem;
--space-md: 0.85rem;
--space-lg: 1.275rem;
--space-xl: 1.7rem;
--space-2xl: 2.55rem;
"""
end
# Fallback for any other density value
defp generate_density(_), do: generate_density("balanced")
# 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
# Font size variations
# Using 18px as base for better accessibility (WCAG recommends 18px+)
# Small: 18px, Medium: 19px, Large: 20px
defp generate_font_size("small") do
"--t-font-size-scale: 1.125;"
end
defp generate_font_size("medium") do
"--t-font-size-scale: 1.1875;"
end
defp generate_font_size("large") do
"--t-font-size-scale: 1.25;"
end
# Heading weight (override typography default)
defp generate_heading_weight("regular") do
"--t-heading-weight-override: 400;"
end
defp generate_heading_weight("medium") do
"--t-heading-weight-override: 500;"
end
defp generate_heading_weight("bold") do
"--t-heading-weight-override: 700;"
end
# Layout width
defp generate_layout_width("contained") do
"--t-layout-max-width: 1100px;"
end
defp generate_layout_width("wide") do
"--t-layout-max-width: 1400px;"
end
defp generate_layout_width("full") do
"--t-layout-max-width: 100%;"
end
# Button style
defp generate_button_style("filled") do
"--t-button-style: filled;"
end
defp generate_button_style("outline") do
"--t-button-style: outline;"
end
defp generate_button_style("soft") do
"--t-button-style: soft;"
end
# Product text alignment
defp generate_product_text_align("left") do
"--t-product-text-align: left;"
end
defp generate_product_text_align("center") do
"--t-product-text-align: center;"
end
# Image aspect ratio
defp generate_image_aspect_ratio("square") do
"--t-image-aspect-ratio: 1 / 1;"
end
defp generate_image_aspect_ratio("portrait") do
"--t-image-aspect-ratio: 3 / 4;"
end
defp generate_image_aspect_ratio("landscape") do
"--t-image-aspect-ratio: 4 / 3;"
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