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:
Jamey Greenwood 2025-12-30 21:41:25 +00:00
parent a401365943
commit 878d8f63ac
8 changed files with 813 additions and 2 deletions

View File

@ -102,4 +102,10 @@
/* Make LiveView wrapper divs transparent for layout */ /* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents } [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 */ /* This file is for your main application CSS */

View File

@ -14,8 +14,8 @@ defmodule SimpleshopTheme.Application do
repos: Application.fetch_env!(:simpleshop_theme, :ecto_repos), skip: skip_migrations?()}, repos: Application.fetch_env!(:simpleshop_theme, :ecto_repos), skip: skip_migrations?()},
{DNSCluster, query: Application.get_env(:simpleshop_theme, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:simpleshop_theme, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: SimpleshopTheme.PubSub}, {Phoenix.PubSub, name: SimpleshopTheme.PubSub},
# Start a worker by calling: SimpleshopTheme.Worker.start_link(arg) # Theme CSS cache
# {SimpleshopTheme.Worker, arg}, SimpleshopTheme.Theme.CSSCache,
# Start to serve requests, typically the last entry # Start to serve requests, typically the last entry
SimpleshopThemeWeb.Endpoint SimpleshopThemeWeb.Endpoint
] ]

View 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

View 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

View 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;
}

View 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);
}

View 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

View 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