⚠️ CSS Framework Migration: Bootstrap CSS is deprecated as of December 2024. All new themes should use Tailwind CSS. Legacy Bootstrap styles remain for backward compatibility but will not receive updates. See vendor/assets/stylesheets/bootstrap/DEPRECATED.md for migration guidance.
- Overview
- Architecture
- Theme Configuration
- Page Part Library
- CSS Custom Properties
- Theme Settings Schema
- Custom Liquid Tags
- Theme Inheritance
- Per-Tenant Customization
- Creating a New Theme
- API Reference
The PropertyWebBuilder theming system provides a flexible, extensible architecture for creating and customizing website themes. The system supports:
- Theme inheritance: Child themes can extend parent themes
- Page part library: 20+ pre-built, customizable page sections
- CSS custom properties: Native CSS variables for easy customization
- Per-tenant customization: Each website can customize theme variables
- Custom Liquid tags: Dynamic content rendering within templates
| Component | Location | Purpose |
|---|---|---|
| Theme Model | app/models/pwb/theme.rb |
Theme metadata, inheritance, capabilities |
| Page Part Library | app/lib/pwb/page_part_library.rb |
Registry of available page parts |
| Theme Settings Schema | app/lib/pwb/theme_settings_schema.rb |
UI schema for theme customization |
| CSS Variables | app/views/pwb/custom_css/_base_variables.css.erb |
Core CSS custom properties |
| Liquid Tags | app/lib/pwb/liquid_tags/ |
Custom Liquid template tags |
| Theme Config | app/themes/config.json |
Theme definitions and metadata |
app/
├── themes/
│ ├── config.json # Theme definitions
│ ├── default/
│ │ └── views/pwb/ # Default theme views
│ └── brisbane/
│ └── views/pwb/ # Brisbane theme overrides
├── views/pwb/
│ ├── page_parts/ # Page part templates
│ │ ├── heroes/
│ │ ├── features/
│ │ ├── testimonials/
│ │ ├── cta/
│ │ ├── stats/
│ │ ├── teams/
│ │ ├── galleries/
│ │ ├── faqs/
│ │ └── pricing/
│ └── custom_css/
│ ├── _base_variables.css.erb
│ └── _component_styles.css.erb
├── lib/pwb/
│ ├── page_part_library.rb
│ ├── theme_settings_schema.rb
│ └── liquid_tags/
│ ├── property_card_tag.rb
│ ├── featured_properties_tag.rb
│ ├── contact_form_tag.rb
│ └── page_part_tag.rb
└── models/pwb/
└── theme.rb
┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐
│ Theme Config │────▶│ Theme Model │────▶│ View Paths │
│ (config.json) │ │ (theme.rb) │ │ (prepended) │
└─────────────────┘ └──────────────────┘ └────────────────┘
│
▼
┌──────────────────┐
│ Website Model │
│ (style_variables)│
└──────────────────┘
│
▼
┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐
│ Page Part │────▶│ Liquid Template │────▶│ Rendered HTML │
│ Library │ │ (with tags) │ │ │
└─────────────────┘ └──────────────────┘ └────────────────┘
Themes are defined in app/themes/config.json:
{
"name": "default",
"friendly_name": "Default Theme",
"id": "default",
"version": "2.0.0",
"description": "A clean, modern theme suitable for any real estate website",
"author": "PropertyWebBuilder",
"tags": ["modern", "minimal", "responsive"],
"parent_theme": null,
"screenshots": ["url/to/screenshot.png"],
"supports": {
"page_parts": ["heroes/hero_centered", "features/feature_grid_3col"],
"layouts": ["default", "landing", "full_width", "sidebar"],
"color_schemes": ["light", "dark"],
"features": {
"sticky_header": true,
"back_to_top": true,
"preloader": false,
"animations": true
}
},
"style_variables": {
"colors": {
"primary_color": {
"type": "color",
"default": "#e91b23",
"label": "Primary Color",
"description": "Main brand color"
}
},
"typography": {
"font_primary": {
"type": "font_select",
"default": "Open Sans",
"label": "Primary Font",
"options": ["Open Sans", "Roboto", "Lato"]
}
}
},
"page_parts_config": {
"heroes": {
"default_variant": "hero_centered",
"available_variants": ["hero_centered", "hero_split", "hero_search"]
}
}
}| Field | Type | Description |
|---|---|---|
name |
String | Internal theme identifier |
friendly_name |
String | Display name for UI |
version |
String | Semantic version number |
parent_theme |
String/null | Parent theme name for inheritance |
supports.page_parts |
Array | List of supported page part keys |
supports.layouts |
Array | Available layout options |
supports.color_schemes |
Array | Color scheme variants |
supports.features |
Object | Feature flags |
style_variables |
Object | Customizable style variables by category |
page_parts_config |
Object | Category-specific page part configuration |
The Page Part Library (Pwb::PagePartLibrary) provides a registry of all available page part templates.
| Category | Label | Description |
|---|---|---|
heroes |
Hero Sections | Large banner sections for page tops |
features |
Features | Sections showcasing services/benefits |
testimonials |
Testimonials | Customer reviews and testimonials |
cta |
Call to Action | Sections encouraging user action |
stats |
Statistics | Number counters and statistics |
teams |
Team | Team member profiles |
galleries |
Galleries | Image galleries and portfolios |
pricing |
Pricing | Pricing tables and comparisons |
faqs |
FAQs | Frequently asked questions |
content |
Content | General content sections |
contact |
Contact | Contact forms and information |
heroes/hero_centered- Full-width hero with centered contentheroes/hero_split- Two-column hero with imageheroes/hero_search- Hero with property search form
features/feature_grid_3col- Three feature cards in gridfeatures/feature_cards_icons- Four icon cards with colors
testimonials/testimonial_carousel- Sliding carouseltestimonials/testimonial_grid- Grid of testimonial cards
cta/cta_banner- Full-width CTA bannercta/cta_split_image- Split CTA with image
stats/stats_counter- Animated number counters
teams/team_grid- Team member grid with social links
galleries/image_gallery- Grid gallery with lightbox
faqs/faq_accordion- Expandable FAQ section
pricing/pricing_table- Three-column pricing comparison
# Get all page part keys
Pwb::PagePartLibrary.all_keys
# => ["heroes/hero_centered", "heroes/hero_split", ...]
# Get page parts by category
Pwb::PagePartLibrary.for_category(:heroes)
# => { "heroes/hero_centered" => { category: :heroes, ... } }
# Get definition for specific page part
Pwb::PagePartLibrary.definition("heroes/hero_centered")
# => { category: :heroes, label: "Centered Hero", fields: [...] }
# Get template path
Pwb::PagePartLibrary.template_path("heroes/hero_centered")
# => #<Pathname:app/views/pwb/page_parts/heroes/hero_centered.liquid>
# Get JSON schema for API
Pwb::PagePartLibrary.to_json_schemaThe CSS custom properties system uses native CSS variables defined in _base_variables.css.erb:
:root {
/* Color System */
--pwb-primary: <%= primary_color %>;
--pwb-primary-light: color-mix(in srgb, <%= primary_color %> 70%, white);
--pwb-primary-dark: color-mix(in srgb, <%= primary_color %> 70%, black);
--pwb-secondary: <%= secondary_color %>;
--pwb-accent: <%= accent_color %>;
/* Typography */
--pwb-font-primary: <%= font_primary %>;
--pwb-font-heading: <%= font_heading %>;
--pwb-font-size-base: <%= font_size_base %>;
/* Layout */
--pwb-container-width: <%= container_width %>;
--pwb-border-radius: <%= border_radius %>;
/* Spacing Scale */
--pwb-space-xs: 0.25rem;
--pwb-space-sm: 0.5rem;
--pwb-space-md: 1rem;
--pwb-space-lg: 1.5rem;
--pwb-space-xl: 2rem;
}Component styles in _component_styles.css.erb use these variables:
/* Buttons */
.pwb-btn--primary {
background-color: var(--pwb-primary);
border-radius: var(--pwb-border-radius);
}
/* Cards */
.pwb-card {
border-radius: var(--pwb-border-radius);
box-shadow: var(--pwb-shadow-md);
}
/* Hero Sections */
.pwb-hero {
font-family: var(--pwb-font-heading);
}.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); }
@media (max-width: 768px) {
.pwb-grid--2col,
.pwb-grid--3col,
.pwb-grid--4col {
grid-template-columns: 1fr;
}
}The Pwb::ThemeSettingsSchema defines the structure for theme customization UIs.
SCHEMA = {
colors: {
label: "Colors",
description: "Customize your website's color palette",
icon: "palette",
order: 1,
fields: {
primary_color: {
type: :color,
default: "#e91b23",
label: "Primary Color",
description: "Main brand color",
css_variable: "--pwb-primary"
},
secondary_color: {
type: :color,
default: "#2c3e50",
label: "Secondary Color"
}
}
},
typography: {
label: "Typography",
fields: {
font_primary: {
type: :font_select,
default: "Open Sans",
label: "Primary Font",
options: ["Open Sans", "Roboto", "Lato", ...]
}
}
}
}| Type | Description | Properties |
|---|---|---|
:color |
Color picker | default, css_variable |
:font_select |
Font dropdown | options, default |
:select |
Generic dropdown | options, default |
:number |
Numeric input | min, max, step, unit |
:toggle |
Boolean switch | default |
:text |
Text input | default, placeholder |
# Get full schema
Pwb::ThemeSettingsSchema::SCHEMA
# Get schema for specific section
Pwb::ThemeSettingsSchema::SCHEMA[:colors]
# Access field metadata
field = Pwb::ThemeSettingsSchema::SCHEMA[:colors][:fields][:primary_color]
field[:type] # => :color
field[:default] # => "#e91b23"Custom Liquid tags extend template functionality with PropertyWebBuilder-specific features.
Renders a property card for a specific property.
{% property_card 123 %}
{% property_card property_id %}
{% property_card 123, style: "compact" %}Options:
style: Card style variant ("default", "compact")
Renders a grid of featured properties.
{% featured_properties %}
{% featured_properties limit: 6 %}
{% featured_properties limit: 4, type: "sale" %}
{% featured_properties limit: 3, style: "compact", columns: 3 %}
{% featured_properties highlighted: "true" %}Options:
limit: Number of properties (default: 6)type: Filter by type ("sale", "rent", "all")style: Grid style ("default", "compact", "card", "grid")columns: Number of columns (default: 3)highlighted: Show only highlighted propertiesshow_price: Show/hide price (default: true)show_location: Show/hide location (default: true)
Renders a contact form.
{% contact_form %}
{% contact_form style: "compact" %}
{% contact_form style: "inline", property_id: 123 %}Options:
style: Form style ("default", "compact", "inline", "sidebar")property_id: Associate with a propertyshow_phone: Show phone field (default: true)show_message: Show message field (default: true)button_text: Custom button textsuccess_message: Custom success message
Renders another page part inline.
{% page_part "heroes/hero_centered" %}
{% page_part "cta/cta_banner", style: "primary" %}Behavior:
- First tries to find a saved PagePart in the database
- Falls back to rendering directly from template file
- Supports nested page part rendering
Themes can inherit from parent themes, allowing customization without duplication.
{
"name": "brisbane",
"parent_theme": "default",
...
}When Brisbane theme is active:
- Views are searched in Brisbane theme first
- If not found, falls back to default theme
- If not found there, uses application defaults
theme = Pwb::Theme.find("brisbane")
# Check inheritance
theme.has_parent?
# => true
theme.parent
# => #<Pwb::Theme name="default">
# Get full inheritance chain
theme.inheritance_chain
# => [#<Pwb::Theme name="brisbane">, #<Pwb::Theme name="default">]
# Get view paths (child theme first)
theme.view_paths
# => ["app/themes/brisbane/views", "app/themes/default/views"]Child themes can:
- Override specific page part templates
- Add new page part variants
- Use parent theme's page parts as-is
# Check if theme has custom template
theme.has_custom_template?("heroes/hero_centered")
# => false (uses parent's template)
# Get available page parts (including inherited)
theme.available_page_parts
# => ["heroes/hero_centered", "heroes/hero_split", ...]Each website can customize theme variables without affecting other tenants.
website = Pwb::Website.find(1)
# Get current style variables
website.style_variables
# => { "primary_color" => "#ff0000", "font_primary" => "Roboto" }
# Update style variables
website.update(style_variables: {
"primary_color" => "#00ff00",
"secondary_color" => "#333333"
})# Get theme defaults
theme = Pwb::Theme.find(website.theme_name)
defaults = theme.style_variable_defaults
# => { "primary_color" => "#e91b23", ... }
# Merge with website overrides
effective_styles = defaults.merge(website.style_variables || {})<%# app/views/pwb/custom_css/custom.css.erb %>
<%
theme = Pwb::Theme.find(current_website.theme_name)
defaults = theme.style_variable_defaults
styles = defaults.merge(current_website.style_variables || {})
primary_color = styles["primary_color"]
font_primary = styles["font_primary"]
%>
<%= render "pwb/custom_css/base_variables",
primary_color: primary_color,
font_primary: font_primary %>Add to app/themes/config.json:
{
"name": "my_theme",
"friendly_name": "My Custom Theme",
"id": "my_theme",
"version": "1.0.0",
"parent_theme": "default",
"description": "A custom theme for my agency",
"supports": {
"page_parts": ["heroes/hero_centered", "features/feature_grid_3col"],
"layouts": ["default", "landing"],
"color_schemes": ["light"]
},
"style_variables": {
"colors": {
"primary_color": {
"type": "color",
"default": "#your-brand-color"
}
}
}
}mkdir -p app/themes/my_theme/views/pwbCopy and modify views from parent theme:
cp -r app/themes/default/views/pwb/layouts app/themes/my_theme/views/pwb/mkdir -p app/themes/my_theme/views/pwb/page_parts/heroesCreate hero_custom.liquid:
<section class="my-theme-hero">
<div class="container">
<h1>{{ page_part.title.content }}</h1>
<p>{{ page_part.subtitle.content }}</p>
</div>
</section>Create app/views/pwb/custom_css/_my_theme.css.erb:
/* My Theme Custom Styles */
.my-theme-hero {
background: linear-gradient(135deg, var(--pwb-primary), var(--pwb-secondary));
padding: var(--pwb-space-xl) 0;
}# In Rails console
website = Pwb::Website.first
website.update(theme_name: "my_theme")
# Verify theme loads
theme = Pwb::Theme.find("my_theme")
theme.view_paths
theme.available_page_partsclass Pwb::Theme
# Class Methods
Theme.all # => Array of all themes
Theme.find(name) # => Theme instance or nil
Theme.find!(name) # => Theme instance or raises
Theme.default # => Default theme
# Instance Methods
theme.name # => "brisbane"
theme.friendly_name # => "Brisbane Luxury Theme"
theme.version # => "2.0.0"
theme.description # => "A luxurious theme..."
theme.author # => "PropertyWebBuilder"
theme.tags # => ["luxury", "elegant"]
theme.screenshots # => ["url1", "url2"]
# Inheritance
theme.parent_theme # => "default" or nil
theme.parent # => Theme instance or nil
theme.has_parent? # => true/false
theme.inheritance_chain # => [child, parent, grandparent, ...]
# Paths
theme.root_path # => Pathname to theme directory
theme.view_paths # => Array of view paths
# Capabilities
theme.supported_page_parts # => ["heroes/hero_centered", ...]
theme.supported_layouts # => ["default", "landing", ...]
theme.supported_color_schemes # => ["light", "dark"]
theme.supported_features # => { sticky_header: true, ... }
# Page Parts
theme.has_custom_template?(key) # => true/false
theme.available_page_parts # => All available parts
theme.page_part_variants(category) # => Variants for category
# Style Variables
theme.style_variable_schema # => Full schema from config
theme.style_variable_defaults # => Default values hash
# Serialization
theme.as_api_json # => Hash for API responses
endmodule Pwb::PagePartLibrary
# Categories
CATEGORIES # => Hash of category definitions
# Definitions
DEFINITIONS # => Hash of all page part definitions
# Query Methods
all_keys # => Array of all keys
by_category # => Hash grouped by category
for_category(cat) # => Parts for specific category
definition(key) # => Definition hash for key
exists?(key) # => true/false
# Templates
template_exists?(key) # => true/false
template_path(key) # => Pathname or nil
# Categories
categories # => All category definitions
category_info(cat) # => Single category info
# Filtering
modern_parts # => Non-legacy parts
legacy_parts # => Legacy parts only
# API
to_json_schema # => Full schema for API
endmodule Pwb::ThemeSettingsSchema
SCHEMA # => Full schema hash
# Structure:
# {
# section_key: {
# label: "Section Name",
# description: "...",
# icon: "icon-name",
# order: 1,
# fields: {
# field_key: {
# type: :color|:font_select|:select|:number|:toggle|:text,
# default: "value",
# label: "Field Label",
# description: "...",
# options: [...], # for select types
# min: 0, max: 100, step: 1, unit: "px" # for number
# }
# }
# }
# }
end<!-- Good -->
<div class="hero-section">
<h1 class="hero-title">{{ title }}</h1>
</div>
<!-- Avoid -->
<div class="bg-gray-900 h-[600px] flex items-center">
<h1 class="text-4xl font-bold text-white">{{ title }}</h1>
</div>/* Good - Uses theme variables */
.hero-title {
color: var(--pwb-primary);
font-family: var(--pwb-font-heading);
}
/* Avoid - Hardcoded values */
.hero-title {
color: #e91b23;
font-family: "Montserrat", sans-serif;
}When creating themes, consider:
- Only override what needs to change
- Use parent theme's components where possible
- Keep customizations minimal and focused
# Ensure tenant isolation
Pwb::Website.find_each do |website|
Pwb::Current.website = website
theme = Pwb::Theme.find(website.theme_name)
# Verify theme loads correctly
assert theme.present?
assert theme.view_paths.all? { |p| File.directory?(p) }
endWhen adding custom page parts, update the library:
# In app/lib/pwb/page_part_library.rb
DEFINITIONS = {
'my_theme/custom_hero' => {
category: :heroes,
label: 'Custom Hero',
description: 'Theme-specific hero variant',
fields: %w[title subtitle background_image]
}
}- Check theme exists in config.json
- Verify website's
theme_namematches config - Check view paths are correct:
theme = Pwb::Theme.find(current_website.theme_name) puts theme.view_paths
- Clear Rails cache:
Rails.cache.clear - Check CSS variables are defined in
_base_variables.css.erb - Verify website's
style_variablesJSON is valid
- Check template exists:
Pwb::PagePartLibrary.template_exists?(key) - Verify Liquid syntax in template
- Check
block_contentshas data for current locale
- Ensure tags are loaded: check
config/initializers/liquid.rb - Verify tag syntax matches documentation
- Check Rails logs for Liquid parsing errors
If migrating from the legacy theming system:
Convert old YAML configs to new JSON format in config.json.
# Old format (in website model)
website.custom_css_styles # => "primary_color: #ff0000\n..."
# New format
website.style_variables # => { "primary_color" => "#ff0000" }Legacy page parts are supported but marked:
Pwb::PagePartLibrary.legacy_parts
# => { "our_agency" => { legacy: true, ... } }Consider migrating to modern equivalents for better support.