berrypod/lib/simpleshop_theme/theme/css_generator.ex

377 lines
9.8 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
alias SimpleshopTheme.Theme.Fonts
@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.
Also includes @font-face declarations for the fonts used by the typography preset.
"""
def generate(%ThemeSettings{} = settings) do
"""
/* Font faces for #{settings.typography} typography */
#{Fonts.generate_font_faces(settings.typography)}
/* 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 style settings (weight and tracking per preset)
@typography_styles %{
"clean" => %{weight: 600, tracking: "-0.02em"},
"editorial" => %{weight: 500, tracking: "-0.01em"},
"modern" => %{weight: 500, tracking: "-0.03em"},
"classic" => %{weight: 500, tracking: "0"},
"friendly" => %{weight: 600, tracking: "-0.01em"},
"minimal" => %{weight: 500, tracking: "0"},
"impulse" => %{weight: 300, tracking: "0.02em"}
}
# Typography - font families, weights, and tracking
# Uses Fonts module for DRY font-family declarations
defp generate_typography(typography) do
%{heading: heading_key, body: body_key} = Fonts.fonts_for_typography(typography)
style = Map.get(@typography_styles, typography, @typography_styles["clean"])
"""
--t-font-heading: #{Fonts.font_family(heading_key)};
--t-font-body: #{Fonts.font_family(body_key)};
--t-heading-weight: #{style.weight};
--t-heading-tracking: #{style.tracking};
"""
end
# 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