Theme Creation for PropertyWebBuilder
Theme System Overview
PropertyWebBuilder uses a multi-tenant theme system where each website can have its own theme. The system supports:
- Theme inheritance - Child themes extend parent themes
- Color palettes - Multiple pre-defined color schemes per theme
- Page Part Library - 20+ pre-built, customizable sections
- CSS custom properties - Native CSS variables for easy customization
- Per-tenant customization - Each website can override theme defaults
- WCAG AA accessibility - Built-in contrast checking utilities
- Dark mode support - Automatic or explicit dark mode colors
Current Themes (January 2025)
| Theme | Parent | Status | Palettes | Description |
|---|---|---|---|---|
default | None | Active | 6 | Base Tailwind/Flowbite theme |
brisbane | default | Active | 6 | Luxury real estate (gold/navy) |
bologna | default | Active | 4 | Traditional European style |
barcelona | default | Disabled | 4 | Incomplete - needs work |
biarritz | default | Disabled | 4 | Needs accessibility fixes |
Key Components
| Component | Location | Purpose |
|---|---|---|
| Theme Registry | app/themes/config.json | Theme definitions |
| Theme Model | app/models/pwb/theme.rb | ActiveJSON model with inheritance |
| Palette Loader | app/services/pwb/palette_loader.rb | Load palettes from JSON |
| Palette Validator | app/services/pwb/palette_validator.rb | Validate against schema |
| Color Utils | app/services/pwb/color_utils.rb | WCAG contrast, shade generation |
| Palette Compiler | app/services/pwb/palette_compiler.rb | Compile CSS for production |
| Website Styleable | app/models/concerns/pwb/website_styleable.rb | Per-website styles |
| CSS Templates | app/views/pwb/custom_css/_*.css.erb | Dynamic CSS generation |
Theme Resolution Flow
- Request comes in with subdomain (tenant identification)
ApplicationController#set_theme_pathdetermines theme from:- URL parameter
?theme=name(if whitelisted) - Website's
theme_namefield - Fallback to "default"
- URL parameter
- Theme view paths are prepended (child first, then parent)
- Views render from theme directory, falling back through inheritance chain
Creating a New Theme
Step 1: Register the Theme in config.json
Add to app/themes/config.json:
json{ "name": "mytheme", "friendly_name": "My Custom Theme", "id": "mytheme", "version": "1.0.0", "enabled": true, "parent_theme": "default", "description": "A custom theme for my agency", "author": "Your Name", "tags": ["modern", "clean"], "supports": { "page_parts": [ "heroes/hero_centered", "heroes/hero_split", "features/feature_grid_3col", "testimonials/testimonial_carousel", "cta/cta_banner" ], "layouts": ["default", "landing", "full_width"], "color_schemes": ["light", "dark"], "features": { "sticky_header": true, "back_to_top": true, "animations": true } }, "style_variables": { "colors": { "primary_color": { "type": "color", "default": "#your-brand-color", "label": "Primary Color" }, "secondary_color": { "type": "color", "default": "#your-secondary-color", "label": "Secondary Color" } }, "typography": { "font_primary": { "type": "font_select", "default": "Open Sans", "label": "Primary Font", "options": ["Open Sans", "Roboto", "Montserrat"] } } } }
Step 2: Create Directory Structure
bashmkdir -p app/themes/mytheme/views/layouts/pwb mkdir -p app/themes/mytheme/views/pwb/welcome mkdir -p app/themes/mytheme/views/pwb/components mkdir -p app/themes/mytheme/views/pwb/sections mkdir -p app/themes/mytheme/views/pwb/pages mkdir -p app/themes/mytheme/views/pwb/props mkdir -p app/themes/mytheme/views/pwb/search mkdir -p app/themes/mytheme/views/pwb/shared mkdir -p app/themes/mytheme/palettes # For color palette JSON files mkdir -p app/themes/mytheme/page_parts # For custom page part templates
Step 3: Create Default Palette
Create app/themes/mytheme/palettes/default.json:
json{ "id": "default", "name": "Default", "description": "Default color scheme for mytheme", "is_default": true, "preview_colors": ["#3498db", "#2c3e50", "#e74c3c"], "colors": { "primary_color": "#3498db", "secondary_color": "#2c3e50", "accent_color": "#e74c3c", "background_color": "#ffffff", "text_color": "#333333", "header_background_color": "#ffffff", "header_text_color": "#333333", "footer_background_color": "#2c3e50", "footer_text_color": "#ffffff", "light_color": "#f8f9fa", "link_color": "#3498db", "action_color": "#3498db" } }
Step 4: Copy and Customize Layout
Copy from parent theme:
bashcp app/themes/default/views/layouts/pwb/application.html.erb app/themes/mytheme/views/layouts/pwb/
Edit app/themes/mytheme/views/layouts/pwb/application.html.erb:
erb<!DOCTYPE html> <html lang="<%= I18n.locale %>"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><%= yield(:page_title) || @current_website&.site_name %></title> <%= yield(:page_head) %> <%# Tailwind CSS for this theme %> <%= stylesheet_link_tag "tailwind-mytheme", "data-turbo-track": "reload" %> <%# Flowbite components %> <link href="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.3.0/flowbite.min.css" rel="stylesheet" /> <%# Material Symbols for icons %> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" rel="stylesheet" /> <%# Dynamic CSS variables %> <style> <%= custom_styles("mytheme") %> </style> <%= javascript_include_tag "pwb/application", async: false %> <script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.3.0/flowbite.min.js"></script> <%= csrf_meta_tags %> </head> <body class="tnt-body mytheme-theme <%= @current_website&.body_style %> bg-gray-50 text-gray-900"> <div class="flex flex-col min-h-screen"> <%= render partial: '/pwb/header', locals: { not_devise: true } %> <main class="flex-grow"> <%= render 'devise/shared/messages' %> <%= yield %> </main> <%= render partial: '/pwb/footer', locals: {} %> </div> <%= yield(:page_script) %> </body> </html>
Step 5: Create Theme CSS Partial
Create app/views/pwb/custom_css/_mytheme.css.erb:
erb/* Theme: mytheme */ <% # Get palette colors merged with website overrides styles = @current_website&.style_variables || {} primary_color = styles["primary_color"] || "#3498db" secondary_color = styles["secondary_color"] || "#2c3e50" accent_color = styles["accent_color"] || "#e74c3c" background_color = styles["background_color"] || "#ffffff" text_color = styles["text_color"] || "#333333" header_bg = styles["header_background_color"] || "#ffffff" header_text = styles["header_text_color"] || "#333333" footer_bg = styles["footer_background_color"] || "#2c3e50" footer_text = styles["footer_text_color"] || "#ffffff" font_primary = styles["font_primary"] || "Open Sans" border_radius = styles["border_radius"] || "0.5rem" %> <%= render partial: 'pwb/custom_css/base_variables', locals: { primary_color: primary_color, secondary_color: secondary_color, accent_color: accent_color, background_color: background_color, text_color: text_color, font_primary: font_primary, border_radius: border_radius } %> :root { --header-bg: <%= header_bg %>; --header-text: <%= header_text %>; --footer-bg: <%= footer_bg %>; --footer-text: <%= footer_text %>; } /* Theme-specific overrides */ .mytheme-theme header { background-color: var(--header-bg); color: var(--header-text); } .mytheme-theme footer { background-color: var(--footer-bg); color: var(--footer-text); } /* Custom raw CSS from admin */ <%= @current_website&.raw_css %>
Step 6: Create Tailwind Input File
Create app/assets/stylesheets/tailwind-mytheme.css:
css@import "tailwindcss"; /* Font imports */ @font-face { font-family: 'Open Sans'; font-weight: 400; src: url('https://cdn.jsdelivr.net/npm/@fontsource/open-sans@5.2.5/files/open-sans-latin-400-normal.woff2'); } /* Theme configuration */ @theme { --color-primary: var(--primary-color, #3498db); --color-secondary: var(--secondary-color, #2c3e50); --color-accent: var(--accent-color, #e74c3c); --font-family-sans: 'Open Sans', var(--font-primary, system-ui, sans-serif); --radius: var(--border-radius, 0.375rem); } /* PWB utility classes */ @layer utilities { .bg-pwb-primary { background-color: var(--pwb-primary); } .bg-pwb-secondary { background-color: var(--pwb-secondary); } .text-pwb-primary { color: var(--pwb-primary); } .text-pwb-secondary { color: var(--pwb-secondary); } .border-pwb-primary { border-color: var(--pwb-primary); } }
Step 7: Add Build Scripts
Add to package.json:
json{ "scripts": { "tailwind:mytheme": "npx @tailwindcss/cli -i ./app/assets/stylesheets/tailwind-mytheme.css -o ./app/assets/builds/tailwind-mytheme.css --watch", "tailwind:mytheme:prod": "npx @tailwindcss/cli -i ./app/assets/stylesheets/tailwind-mytheme.css -o ./app/assets/builds/tailwind-mytheme.css --minify" } }
Step 8: Test the Theme
ruby# Via Rails console theme = Pwb::Theme.find_by(name: 'mytheme') theme.view_paths # Verify path resolution theme.palettes # Check palettes loaded theme.default_palette_id # Verify default palette # Update a website to use the theme website = Pwb::Website.first website.update(theme_name: 'mytheme')
bash# Build Tailwind CSS npm run tailwind:mytheme:prod # Via URL parameter (if enabled) http://localhost:3000/?theme=mytheme
Creating Color Palettes
Palette File Structure
Palettes are stored in app/themes/[theme]/palettes/*.json:
json{ "id": "my_palette", "name": "My Palette", "description": "A beautiful color palette", "is_default": false, "preview_colors": ["#primary", "#secondary", "#accent"], "colors": { "primary_color": "#e91b23", "secondary_color": "#2c3e50", "accent_color": "#3498db", "background_color": "#ffffff", "text_color": "#333333", "header_background_color": "#ffffff", "header_text_color": "#333333", "footer_background_color": "#2c3e50", "footer_text_color": "#ffffff", "light_color": "#f8f9fa", "link_color": "#e91b23", "action_color": "#e91b23" } }
Required Colors (9 mandatory)
| Key | Purpose |
|---|---|
primary_color | Main brand color for CTAs and links |
secondary_color | Supporting color for secondary elements |
accent_color | Highlight color for special elements |
background_color | Main page background |
text_color | Primary text color |
header_background_color | Header/nav background |
header_text_color | Header/nav text |
footer_background_color | Footer background |
footer_text_color | Footer text |
Dark Mode Support
For explicit dark mode colors, use the modes structure:
json{ "id": "modern_dark", "name": "Modern with Dark Mode", "modes": { "light": { "primary_color": "#3498db", "background_color": "#ffffff", "text_color": "#333333" }, "dark": { "primary_color": "#5dade2", "background_color": "#121212", "text_color": "#e8e8e8" } } }
If you only provide colors, dark mode is auto-generated using ColorUtils.generate_dark_mode_colors().
Validation & Tools
bash# Validate all palettes rake palettes:validate # List available palettes for a theme rake palettes:list[mytheme] # Check WCAG contrast compliance rake palettes:contrast[mytheme,my_palette] # Generate shade scale for a color rake palettes:shades[#3498db]
ruby# In Rails console loader = Pwb::PaletteLoader.new palettes = loader.load_theme_palettes("mytheme") light = loader.get_light_colors("mytheme", "my_palette") dark = loader.get_dark_colors("mytheme", "my_palette") # Validate a palette validator = Pwb::PaletteValidator.new result = validator.validate(palette_hash) result.valid? # => true/false result.errors # => ["Missing required color: primary_color"]
Search Page Layout Requirements
IMPORTANT: Search pages MUST follow responsive layout requirements.
Desktop Layout (>=1024px)
Filters MUST be displayed BESIDE results (side-by-side), NOT above them:
+--------------------------------------------------+ | +------------+ +----------------------------+ | | | Filters | | Search Results | | | | (1/4) | | (3/4 width) | | | +------------+ +----------------------------+ | +--------------------------------------------------+
Required HTML Structure
erb<div class="flex flex-wrap -mx-4"> <!-- Sidebar Filters (1/4 on desktop, full on mobile) --> <div class="w-full lg:w-1/4 px-4 mb-6 lg:mb-0"> <button class="lg:hidden w-full ..." data-controller="search-form" data-action="click->search-form#toggleFilters"> Filter Properties </button> <div id="sidebar-filters" class="hidden lg:block"> <%= render 'pwb/searches/search_form_for_sale' %> </div> </div> <!-- Search Results (3/4 on desktop, full on mobile) --> <div class="w-full lg:w-3/4 px-4"> <div id="inmo-search-results"> <%= render 'search_results' %> </div> </div> </div>
PWB CSS Class Naming
Use semantic PWB classes for consistency:
css/* Colors */ .bg-pwb-primary { background-color: var(--pwb-primary); } .bg-pwb-secondary { background-color: var(--pwb-secondary); } .text-pwb-primary { color: var(--pwb-primary); } /* Buttons */ .pwb-btn--primary { background-color: var(--pwb-primary); } .pwb-btn--secondary { background-color: var(--pwb-secondary); } .pwb-btn--outline { border: 2px solid var(--pwb-primary); } /* Cards */ .pwb-card { border-radius: var(--pwb-border-radius); } /* Grid */ .pwb-grid--2col { grid-template-columns: repeat(2, 1fr); } .pwb-grid--3col { grid-template-columns: repeat(3, 1fr); } .pwb-grid--4col { grid-template-columns: repeat(4, 1fr); }
WCAG Accessibility Requirements
Contrast Ratios (WCAG 2.1 AA)
| Text Type | Minimum Ratio |
|---|---|
| Normal text (<18px) | 4.5:1 |
| Large text (>=18px bold or >=24px) | 3:1 |
| UI components & graphics | 3:1 |
Check Contrast in Ruby
ruby# Check if colors meet WCAG AA Pwb::ColorUtils.wcag_aa_compliant?('#ffffff', '#333333') # => true (14.0:1 ratio) # Get exact contrast ratio Pwb::ColorUtils.contrast_ratio('#ffffff', '#9ca3af') # => 2.9 (fails AA - needs 4.5:1) # Get suggested text color for a background Pwb::ColorUtils.suggest_text_color('#1a2744') # => '#ffffff' (white for dark backgrounds)
Theme Inheritance
How It Works
Child themes inherit from parent themes:
rubytheme = Pwb::Theme.find_by(name: 'brisbane') theme.parent_theme # => "default" theme.parent # => <Pwb::Theme name="default"> theme.inheritance_chain # => [brisbane, default] theme.view_paths # => [brisbane/views, default/views, app/views]
View Resolution Order
- Check child theme:
app/themes/brisbane/views/ - Check parent theme:
app/themes/default/views/ - Check application:
app/views/
Troubleshooting
Theme Not Loading
- Check entry exists in
app/themes/config.json - Verify
"enabled": trueis set - Verify JSON syntax is valid
- Restart Rails server after config changes
- Check:
Pwb::Theme.find_by(name: 'mytheme')
Styles Not Applying
- Verify CSS partial exists:
app/views/pwb/custom_css/_mytheme.css.erb - Verify Tailwind CSS is built:
app/assets/builds/tailwind-mytheme.css - Check body class matches theme name (
.mytheme-theme) - Clear Rails cache:
Rails.cache.clear
Palette Not Found
- Check file exists:
app/themes/mytheme/palettes/default.json - Validate JSON syntax
- Run:
rake palettes:validate - Check:
Pwb::PaletteLoader.new.load_theme_palettes('mytheme')
Documentation Reference
docs/theming/README.md- Documentation indexdocs/theming/THEME_AND_COLOR_SYSTEM.md- Complete architecturedocs/theming/color-palettes/COLOR_PALETTES_ARCHITECTURE.md- Palette systemdocs/theming/THEME_CREATION_CHECKLIST.md- Step-by-step checklistapp/themes/shared/color_schema.json- Palette JSON schema


