Metadata-Version: 2.4
Name: wagtail-visual-themes
Version: 0.2.1
Summary: Reusable visual themes (colors, dark/light mode, radii, shadows, typography) for Wagtail pages.
Project-URL: Homepage, https://github.com/ujeenet/wagtail-visual-themes
Project-URL: Documentation, https://github.com/ujeenet/wagtail-visual-themes#readme
Project-URL: Repository, https://github.com/ujeenet/wagtail-visual-themes
Project-URL: Issues, https://github.com/ujeenet/wagtail-visual-themes/issues
Project-URL: Changelog, https://github.com/ujeenet/wagtail-visual-themes/blob/main/CHANGELOG.md
Author: Ievgenii Svyryd
License: MIT License
        
        Copyright (c) 2026 Ievgenii Svyryd
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: dark-mode,design-tokens,django,themes,wagtail
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Wagtail
Classifier: Framework :: Wagtail :: 7
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.11
Requires-Dist: django>=4.2
Requires-Dist: wagtail>=7.0
Provides-Extra: dev
Requires-Dist: django-stubs[compatible-mypy]>=5.0; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest-django>=4.8; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# wagtail-visual-themes

Reusable visual themes for [Wagtail](https://wagtail.org/) pages — colors, dark/light mode, typography, border radii and shadows. Themes are managed as snippets, rendered as a single `<style>` block of CSS variables, and resolved automatically per page.

> ⚠️ Alpha. The API may change before 1.0.

---

## Table of contents

- [What you get](#what-you-get)
- [Installation](#installation)
- [Quickstart (5 minutes)](#quickstart-5-minutes)
- [Concepts](#concepts)
- [Wiring up your base template](#wiring-up-your-base-template)
- [Attaching themes to pages](#attaching-themes-to-pages)
- [Theme switcher](#theme-switcher)
- [Using the CSS variables](#using-the-css-variables)
- [Tailwind integration](#tailwind-integration)
- [Brand colors](#brand-colors)
- [Theme resolution rules](#theme-resolution-rules)
- [Permissions](#permissions)
- [Template tag reference](#template-tag-reference)
- [Python API reference](#python-api-reference)
- [Recipes](#recipes)
- [Testing your integration](#testing-your-integration)
- [Troubleshooting / FAQ](#troubleshooting--faq)
- [Development](#development)
- [License](#license)

> **Editors:** see [docs/editor-guide.md](docs/editor-guide.md) for a non-technical walkthrough of creating themes and brand colors in the Wagtail admin.

---

## What you get

| | |
|---|---|
| **Editors** | Create one or more `Theme` snippets in the Wagtail admin. Each theme has its own colors (light + dark), typography, radii, shadows, and a list of named brand colors. Live preview shows them all. |
| **Developers** | Drop `{% theme_css %}` in your `<head>` and consume `var(--color-bg)`, `var(--color-primary)`, `var(--radius-md)`, `var(--shadow-md)` in plain CSS, Tailwind, or anywhere else. |
| **Visitors** | Toggle between light, dark and system (OS) modes. The choice survives page reloads. No flash of wrong theme on first paint. |

The package is **zero-coupling**: it doesn't know about your tenant model, your CMS structure, or your build pipeline. It just emits CSS variables.

---

## Installation

```bash
pip install wagtail-visual-themes
```

Add to `INSTALLED_APPS` (after `wagtail.snippets`):

```python
INSTALLED_APPS = [
    # …
    "wagtail.snippets",
    "wagtail_themes",
]
```

Add the context processor so `{% theme_css %}` works without arguments:

```python
TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [...],
    "APP_DIRS": True,
    "OPTIONS": {
        "context_processors": [
            "django.template.context_processors.request",
            "django.contrib.auth.context_processors.auth",
            # …
            "wagtail_themes.context_processors.active_theme",
        ],
    },
}]
```

Run migrations:

```bash
python manage.py migrate
```

That's it. There are **no required settings**.

---

## Quickstart (5 minutes)

After installing:

**1. Create a theme.** Go to *Snippets → Themes* in the Wagtail admin, click *Add Theme*, fill in a name (e.g. `Default`) and slug (`default`), tick **is_default**, and save. Tweak colors as you like, or accept the defaults.

**2. Add a brand color** (optional). *Snippets → Brand Colors → Add*. Pick the theme you just created, name it `Primary`, give it `#3b82f6`. Save.

**3. Update your base template.**

```django
{# templates/base.html #}
{% load wagtail_themes %}
<!DOCTYPE html>
<html {% theme_html_attrs %}>
<head>
    <meta charset="utf-8">
    {% theme_no_flash %}
    {% theme_css %}
    <link rel="stylesheet" href="{% static 'css/site.css' %}">
</head>
<body>
    <header style="background: var(--color-surface); color: var(--color-text-primary); padding: 1rem;">
        My site
        {% theme_switcher %}
    </header>
    <main>{% block content %}{% endblock %}</main>
</body>
</html>
```

**4. Reload the page.** You should see your theme applied. Click the switcher to toggle light/system/dark — the page rerenders instantly with no flash on reload.

That's the whole minimum integration. Read on for the details.

---

## Concepts

### Theme

A `Theme` is a snippet that holds **all design tokens**. It has:

- **Surface colors** (light and dark): background, surface, text-primary, text-secondary, text-muted, border.
- **Semantic colors**: success, warning, error, info, link, focus-ring (each with optional dark override).
- **Brand colors**: many-to-one — see below.
- **Typography**: heading font stack, body font stack, weights, scale (small/normal/large), optional URL to an external font CSS (e.g. Google Fonts).
- **Radii**: `--radius-sm`, `--radius-md`, `--radius-lg`, `--radius-full`.
- **Shadows**: `--shadow-sm`, `--shadow-md`, `--shadow-lg`.
- **Default mode**: `light`, `dark`, or `system`. The mode the site lands on for visitors with no saved preference.
- **`is_default` flag**: when set, this theme is used for any page that doesn't otherwise have one. Only one theme can be default at a time — saving a new default automatically clears it on others.

### BrandColor

A `BrandColor` is a *named* color owned by a Theme — `Primary`, `Accent`, `Aurora`, whatever you want. Each BrandColor emits **three** CSS variables:

| Variable | Example | Notes |
|---|---|---|
| `--color-<slug>` | `--color-primary: #3b82f6;` | The raw value as authored. |
| `--color-<slug>-rgb` | `--color-primary-rgb: 59 130 246;` | RGB triplet, **only if** the value is a solid color (not a gradient). Powers Tailwind opacity. |
| `--color-<slug>-contrast` | `--color-primary-contrast: #ffffff;` | Auto-computed via WCAG luminance. Use for text on top of the brand color. |

You can give a brand color a separate dark-mode value:

- `color_value`: light mode — required.
- `color_value_dark`: dark mode — optional. Falls back to the light value when blank.

Gradients are supported (`linear-gradient(...)`, `radial-gradient(...)`) — they just don't get an `-rgb` companion.

### Modes

Three modes, controlled via `data-theme` on `<html>`:

- `data-theme="light"` — light tokens.
- `data-theme="dark"` — dark tokens.
- `data-theme="system"` — light by default, swaps to dark inside `@media (prefers-color-scheme: dark)`.

The **theme** has a `default_mode`. The **visitor** can override it via the switcher; the choice is persisted in `localStorage` under `wagtail-themes:mode`. The `{% theme_no_flash %}` tag reads that key synchronously before paint.

### Resolution

Each request needs to find one Theme to render. The resolver walks this order:

1. The current page's own `theme` (if it inherits from `ThemedPageMixin`).
2. Each ancestor page's theme, walking up to the root.
3. The Wagtail Site's `ThemeSiteSetting.theme` (if `wagtail.contrib.settings` is installed).
4. The `Theme` row with `is_default=True`.
5. Nothing — the tags render empty strings; your CSS still works using its own fallbacks.

---

## Wiring up your base template

The single most important block of your project's HTML, in full:

```django
{% load wagtail_themes static %}
<!DOCTYPE html>
<html {% theme_html_attrs %}>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    {# Read saved mode from localStorage and apply data-theme BEFORE paint. #}
    {% theme_no_flash %}

    {# Render the active theme as <style>…</style> with all CSS variables. #}
    {% theme_css %}

    {# Your own stylesheets — they can already use var(--…) below this point. #}
    <link rel="stylesheet" href="{% static 'css/site.css' %}">

    <title>{% block title %}{{ page.title }}{% endblock %}</title>
</head>
<body>
    {% block body %}{% endblock %}
</body>
</html>
```

`{% theme_html_attrs %}` expands to something like:

```html
data-theme="system" data-theme-name="default" class="theme-default"
```

You can author CSS that targets a specific theme by slug:

```css
.theme-marketing .hero { background: var(--color-primary); }
.theme-blog      .hero { background: var(--color-surface); }
```

### Order matters

Three rules:

1. **`{% theme_no_flash %}` must come before any visible HTML.** Otherwise the visitor's saved mode applies *after* paint, causing a flash. It's a tiny synchronous `<script>` — that's intentional.
2. **`{% theme_css %}` must come before your own `<link rel="stylesheet">`.** The variables need to be defined before any rule references them.
3. **Don't put `{% theme_css %}` in a child template that renders late** (e.g. inside `{% block content %}`). It belongs in `<head>`.

---

## Attaching themes to pages

You have three patterns. Pick whichever fits.

### Pattern A — page mixin (most flexible)

Every editorial page can pick its own theme; child pages inherit unless they override. Best for sites where different sections (marketing, docs, blog) want different looks.

```python
# myapp/models.py
from wagtail.models import Page
from wagtail_themes.models import ThemedPageMixin


class HomePage(ThemedPageMixin, Page):
    # Show the theme picker in the editor's "Promote" tab,
    # or wherever you want.
    promote_panels = Page.promote_panels + ThemedPageMixin.theme_panels


class BlogIndexPage(ThemedPageMixin, Page):
    promote_panels = Page.promote_panels + ThemedPageMixin.theme_panels


class BlogPostPage(ThemedPageMixin, Page):
    promote_panels = Page.promote_panels + ThemedPageMixin.theme_panels
```

Then run `makemigrations` + `migrate`. The page now has a `theme` FK; setting it on `BlogIndexPage` propagates to all `BlogPostPage` children automatically (resolver walks the tree).

### Pattern B — site setting (simplest, one theme per Wagtail Site)

For a one-site, one-look project. Add `wagtail.contrib.settings` to `INSTALLED_APPS`:

```python
INSTALLED_APPS = [
    # …
    "wagtail.contrib.settings",
    "wagtail_themes",
]
```

…and the contrib's context processor:

```python
TEMPLATES[0]["OPTIONS"]["context_processors"].append(
    "wagtail.contrib.settings.context_processors.settings",
)
```

Editors will now see *Settings → Themes* in the admin where they can pick one theme per site. No code changes to your Page models needed.

### Pattern C — both

You can use both at the same time. Page-level wins; site-level is the fallback when no page in the tree has a theme. This is what the resolver does by default.

### Pattern D — request-time override

If you want to flip themes on certain conditions (preview, A/B test, query string), set `request.active_theme` somewhere — middleware, a view, anywhere — and the resolver will use it. Example:

```python
# middleware.py
from wagtail_themes.models import Theme

class PreviewThemeMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.GET.get("theme"):
            try:
                request.active_theme = Theme.objects.get(slug=request.GET["theme"])
            except Theme.DoesNotExist:
                pass
        return self.get_response(request)
```

---

## Theme switcher

The package ships a minimal three-state switcher (light / system / dark):

```django
{% load wagtail_themes %}
{% theme_switcher %}
```

It writes to `localStorage` under the key `wagtail-themes:mode` and updates `data-theme` on `<html>` instantly.

### Custom switcher

The contract is small. Write your own UI; the only job is to:

1. Read the current state from `<html data-theme>` or `localStorage["wagtail-themes:mode"]`.
2. On user action, set `data-theme="light"|"dark"|"system"` on `<html>` and write the same value to `localStorage`.

A minimal hand-rolled toggle in vanilla JS:

```html
<button id="toggle">🌓</button>
<script>
const KEY = "wagtail-themes:mode";
const root = document.documentElement;
const cycle = { light: "dark", dark: "system", system: "light" };
document.getElementById("toggle").addEventListener("click", () => {
    const next = cycle[localStorage.getItem(KEY) || root.dataset.theme || "system"];
    root.dataset.theme = next;
    localStorage.setItem(KEY, next);
});
</script>
```

Or React/Vue/Svelte — same contract. You don't need to use `{% theme_switcher %}`.

---

## Using the CSS variables

The full set, available on `:root` (light) and overridden under `[data-theme="dark"]`:

### Surface

```css
var(--color-bg)              /* page background */
var(--color-surface)         /* cards, panels */
var(--color-text-primary)
var(--color-text-secondary)
var(--color-text-muted)
var(--color-border)
```

### Semantic

```css
var(--color-success)
var(--color-warning)
var(--color-error)
var(--color-info)
var(--color-link)
var(--color-focus-ring)
```

### Brand

```css
var(--color-<slug>)            /* e.g. var(--color-primary) */
var(--color-<slug>-rgb)        /* RGB triplet, solid colors only */
var(--color-<slug>-contrast)   /* foreground color */

/* Tailwind-aligned 50→950 shade scale, auto-derived from each base color: */
var(--color-<slug>-50)         /* lightest tint */
var(--color-<slug>-100)
…
var(--color-<slug>-500)        /* same as --color-<slug>, the base */
…
var(--color-<slug>-900)
var(--color-<slug>-950)        /* darkest shade */
```

### Typography

```css
var(--font-heading)
var(--font-body)
var(--font-weight-heading)
var(--font-weight-body)
var(--font-size-base)          /* in px, controlled by font_scale */

/* Modular size scale (rems, anchored to font-size-base) */
var(--font-size-xs)            /* 0.75rem  */
var(--font-size-sm)            /* 0.875rem */
var(--font-size-base)          /* 1rem     */
var(--font-size-lg)            /* 1.125rem */
var(--font-size-xl)            /* 1.25rem  */
var(--font-size-2xl)           /* 1.5rem   */
var(--font-size-3xl)           /* 1.875rem */
var(--font-size-4xl)           /* 2.25rem  */

/* Line height & letter spacing */
var(--leading-tight)           /* 1.25 */
var(--leading-normal)          /* 1.5  */
var(--leading-relaxed)         /* 1.75 */

var(--tracking-tight)          /* -0.025em */
var(--tracking-normal)         /* 0em      */
var(--tracking-wide)           /* 0.05em   */
```

### Spacing

```css
var(--space-0)                 /* 0       */
var(--space-px)                /* 1px     */
var(--space-1)                 /* 0.25rem */
var(--space-2)                 /* 0.5rem  */
var(--space-3)                 /* 0.75rem */
var(--space-4)                 /* 1rem    */
var(--space-5)                 /* 1.25rem */
var(--space-6)                 /* 1.5rem  */
var(--space-8)                 /* 2rem    */
var(--space-10)                /* 2.5rem  */
var(--space-12)                /* 3rem    */
var(--space-16)                /* 4rem    */
var(--space-20)                /* 5rem    */
var(--space-24)                /* 6rem    */
```

### Geometry

```css
var(--radius-sm)
var(--radius-md)
var(--radius-lg)
var(--radius-full)             /* 9999px — for pills/circles */

var(--shadow-sm)
var(--shadow-md)
var(--shadow-lg)

var(--border-1)                /* 1px */
var(--border-2)                /* 2px */
var(--border-4)                /* 4px */
var(--border-8)                /* 8px */
```

### Z-index scale

```css
var(--z-base)                  /* 0  */
var(--z-dropdown)              /* 10 */
var(--z-sticky)                /* 20 */
var(--z-fixed)                 /* 30 */
var(--z-overlay)               /* 40 */
var(--z-modal)                 /* 50 */
var(--z-popover)               /* 60 */
var(--z-tooltip)               /* 70 */
var(--z-toast)                 /* 80 */
```

### Transitions

```css
var(--duration-fast)           /* 150ms */
var(--duration-normal)         /* 200ms */
var(--duration-slow)           /* 300ms */

var(--ease-out)                /* cubic-bezier(0, 0, 0.2, 1)     */
var(--ease-in-out)             /* cubic-bezier(0.4, 0, 0.2, 1)   */
```

### State overlays

Opacity values for hover / active / disabled states. Combine with the `-rgb` color companions or with `opacity:`.

```css
var(--state-hover-overlay)     /* 0.08 */
var(--state-active-overlay)    /* 0.16 */
var(--state-disabled-opacity)  /* 0.5  */

/* Practical use: */
.btn-primary:hover {
    background: rgb(var(--color-primary-rgb) / calc(1 - var(--state-hover-overlay)));
}
.btn[disabled] { opacity: var(--state-disabled-opacity); }
```

### Practical example

```css
body {
    background: var(--color-bg);
    color: var(--color-text-primary);
    font-family: var(--font-body);
    font-size: var(--font-size-base);
    font-weight: var(--font-weight-body);
}

h1, h2, h3 {
    font-family: var(--font-heading);
    font-weight: var(--font-weight-heading);
}

.btn-primary {
    background: var(--color-primary);
    color: var(--color-primary-contrast);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-sm);
    transition: opacity 0.2s;
}

.btn-primary:hover {
    /* Tailwind-style opacity: requires --color-primary-rgb (auto-emitted for solids) */
    background: rgb(var(--color-primary-rgb) / 0.9);
}

.card {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-md);
}

a { color: var(--color-link); }
*:focus-visible { outline: 2px solid var(--color-focus-ring); }
```

---

## Tailwind integration

`wagtail-visual-themes` plays cleanly with Tailwind v3+. The trick: tell Tailwind your theme tokens are CSS variables containing RGB triplets.

In `tailwind.config.js`:

```js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./templates/**/*.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {
      colors: {
        bg:      "rgb(var(--color-bg-rgb) / <alpha-value>)",
        surface: "rgb(var(--color-surface-rgb) / <alpha-value>)",
        border:  "rgb(var(--color-border-rgb) / <alpha-value>)",
        primary: "rgb(var(--color-primary-rgb) / <alpha-value>)",
        success: "rgb(var(--color-success-rgb) / <alpha-value>)",
        // …add the rest of your brand color slugs
      },
      borderRadius: {
        sm:   "var(--radius-sm)",
        DEFAULT: "var(--radius-md)",
        lg:   "var(--radius-lg)",
        full: "var(--radius-full)",
      },
      boxShadow: {
        sm: "var(--shadow-sm)",
        DEFAULT: "var(--shadow-md)",
        lg: "var(--shadow-lg)",
      },
      fontFamily: {
        heading: "var(--font-heading)",
        body:    "var(--font-body)",
      },
    },
  },
  // For dark-mode utilities (`dark:bg-surface`):
  darkMode: ["class", '[data-theme="dark"]', '[data-theme="system"]'],
};
```

You can now write Tailwind that respects the theme:

```html
<button class="bg-primary text-white hover:bg-primary/80 rounded px-4 py-2 shadow">
    Click me
</button>
<div class="bg-surface text-foreground border border-border rounded-lg p-6">
    A card
</div>
```

The `bg-primary/80` syntax requires the `-rgb` companion variables — `wagtail-visual-themes` emits these automatically for every solid color.

---

## Brand colors

### Naming & slugs

Brand color slugs are auto-derived from the name: `Primary` → `primary`, `Aurora Sunrise` → `aurora-sunrise`, `Brand 2025` → `brand-2025`. Pick names that read well in CSS.

Names must be unique within a Theme.

### Solid colors vs gradients

Both work. Solids accept hex (`#3b82f6`, `#fff`), `rgb()`, `rgba()`. Gradients accept any CSS gradient (`linear-gradient(...)`, `radial-gradient(...)`, `conic-gradient(...)`).

| Color type | Gets `-rgb` companion? | Gets `-contrast` companion? |
|---|---|---|
| Solid (hex/rgb) | ✅ | ✅ (computed from luminance) |
| Gradient | ❌ | ✅ (defaults to `#ffffff`) |

For gradient brand colors, set the `color_value_dark` to a different gradient if your light-mode gradient looks bad in dark mode.

### Brand color choosers (for FK fields)

If you have a model with FK fields pointing at `BrandColor`, use `BrandColorChooserPanel` so editors only see colors belonging to the active theme:

```python
from wagtail.models import Page
from wagtail.admin.panels import FieldPanel
from wagtail_themes.models import BrandColor, ThemedPageMixin
from wagtail_themes.panels import BrandColorChooserPanel
from django.db import models


class CampaignPage(ThemedPageMixin, Page):
    accent = models.ForeignKey(
        BrandColor, null=True, blank=True, on_delete=models.SET_NULL,
        related_name="+",
    )
    content_panels = Page.content_panels + [
        FieldPanel("theme"),
        BrandColorChooserPanel("accent"),  # filters by theme set above
    ]
```

The panel reads the theme from `instance.theme` (or, if the model *is* a Theme, from `instance` itself) and limits the dropdown.

---

## Theme resolution rules

When `{% theme_css %}` runs, it asks the resolver: "which theme should I render?" The decision tree:

```
Is `active_theme` already in the template context?         → use it
   │ no
   ▼
Is the current page a ThemedPageMixin with a theme set?   → use it
   │ no
   ▼
Walk up the page tree — does any ancestor have a theme?    → use the closest
   │ no
   ▼
Is wagtail.contrib.settings installed?
  Does the matching Wagtail Site have a ThemeSiteSetting?  → use its theme
   │ no
   ▼
Is there a Theme row with is_default=True?                 → use it
   │ no
   ▼
Render nothing (template tags emit empty strings).
```

The context processor (`wagtail_themes.context_processors.active_theme`) does steps 3–5 once per request and stores the result in the context as `active_theme`.

### Forcing a specific theme

Skip resolution by passing the theme explicitly:

```django
{% theme_css theme=my_theme %}
```

Or set `request.active_theme = my_theme` in middleware/a view.

---

## Permissions

The package distinguishes **editing a Theme record** (full CRUD) from **picking which Theme applies to a page or site**. That split lets you give editorial staff the ability to assign existing themes without trusting them to redesign the system.

### Permissions registered

Standard Django CRUD perms are auto-generated for both models:

- `wagtail_themes.add_theme` / `change_theme` / `delete_theme` / `view_theme`
- `wagtail_themes.add_brandcolor` / `change_brandcolor` / `delete_brandcolor` / `view_brandcolor`

Plus one custom permission:

- `wagtail_themes.set_active_theme` — grants the ability to *assign* a Theme to a `ThemedPageMixin` page or to the Wagtail Site setting. Without it, the **Theme** field on the page edit form is hidden.

### Preset groups

The package ships a management command that creates two pre-configured groups:

```bash
python manage.py wagtail_themes_setup_groups
# Re-run with --reset to overwrite any drift on the groups' permissions.
```

| Group | What members can do |
|---|---|
| **Theme Editor** | Full CRUD on `Theme` and `BrandColor`, plus `set_active_theme`. Use for designers and tech-aware editors. |
| **Theme Selector** | View-only on `Theme` and `BrandColor`, plus `set_active_theme`. Use for editorial staff who pick existing themes for pages but shouldn't be tweaking the design system. |

The command is idempotent — safe to re-run after package upgrades to reattach any newly added permissions.

### How the gating works

`ThemedPageMixin.theme_panels` already declares the gate:

```python
theme_panels = [FieldPanel("theme", permission="wagtail_themes.set_active_theme")]
```

So users without `set_active_theme` simply don't see the field. `ThemeSiteSetting.panels` does the same for the site-level FK.

For the snippet menu (where Theme/BrandColor records are edited), Wagtail's standard model-level permissions apply: a user without at least `view_theme` won't see the Themes menu group at all.

### Custom workflows

If neither preset fits, build your own group in the Django admin (or via fixtures) using any combination of the permissions above. Common variations:

- **Designer-only Theme Editor**, no `set_active_theme`. Designers iterate on themes; editors apply them.
- **Locked-down Selector**, only `set_active_theme`. No view of Theme records — they pick from the FK dropdown only.

---

## Template tag reference

All tags live under `{% load wagtail_themes %}`.

### `{% theme_css %}`

```django
{% theme_css %}                              {# auto-resolved theme, with fallback #}
{% theme_css theme=my_theme %}               {# explicit override   #}
{% theme_css include_style_tag=False %}      {# raw CSS body, no <style> #}
{% theme_css include_fonts=False %}          {# don't emit <link> for custom_fonts_css_url #}
{% theme_css fallback=False %}               {# emit nothing if no Theme is configured #}
```

Renders one `<style>` block (and optionally a `<link>` for custom fonts) containing every CSS variable. When no Theme matches the resolution chain, falls back to an in-memory `Theme()` so the model's field defaults still emit usable CSS — opt out with `fallback=False`.

### `{% theme_html_attrs %}`

```django
<html {% theme_html_attrs %}>
{# or, to emit nothing when no Theme is configured: #}
<html {% theme_html_attrs fallback=False %}>
```

Outputs `data-theme="<default_mode>" data-theme-name="<slug>" class="theme-<slug>"`. Use this on `<html>` so visitor mode preferences and theme-scoped CSS classes both work. Same fallback semantics as `{% theme_css %}` — emits `data-theme="system"` defaults rather than nothing, unless `fallback=False`.

### `{% theme_no_flash %}`

```django
<head>
    {% theme_no_flash %}
    {% theme_css %}
    …
</head>
```

Inline `<script>` that reads `localStorage["wagtail-themes:mode"]` and applies it to `<html data-theme>` synchronously. Place at the very top of `<head>`.

### `{% theme_switcher %}`

```django
{% theme_switcher %}
```

Built-in three-state toggle (light/system/dark). Pulls in its own CSS and a small JS file. Skip if you're rolling your own.

---

## Python API reference

```python
from wagtail_themes.models import Theme, BrandColor, ThemedPageMixin
from wagtail_themes.resolver import (
    resolve_theme_for_page,
    resolve_theme_for_request,
    resolve_theme_for_site,
)
from wagtail_themes.panels import BrandColorChooserPanel
from wagtail_themes.widgets import BrandColorChooserWidget
from wagtail_themes.constants import ThemeMode, FontWeight, FontScale
```

### `Theme.emit_css(selector_root=":root") -> str`

Returns the full CSS body (no `<style>` wrapper) for this theme. Useful for caching the CSS or generating it offline.

```python
theme = Theme.objects.get(slug="default")
print(theme.emit_css())
```

### `Theme.get_default() -> Theme | None`

Returns the row with `is_default=True`, or `None`.

### `BrandColor.css_var_name -> str`

Read-only — `--color-<slug>`.

### `BrandColor.contrast_color -> str` and `contrast_color_dark`

Read-only `#000000` or `#ffffff` chosen via WCAG luminance.

### `ThemedPageMixin.get_active_theme() -> Theme | None`

Walks the page tree + falls back to site/default. Same logic the template tags use.

### Resolver functions

```python
resolve_theme_for_page(page)        # walks up tree, then site, then default
resolve_theme_for_request(request)  # request.active_theme → site → default
resolve_theme_for_site(site)        # site setting only
```

---

## Recipes

### Cache the emitted CSS

For high-traffic sites, emit theme CSS once per theme version and serve it from cache:

```python
# views.py
from django.core.cache import cache
from django.http import HttpResponse
from wagtail_themes.models import Theme

def theme_css_view(request, slug):
    theme = Theme.objects.get(slug=slug)
    key = f"wagtail-themes:css:{theme.pk}:{theme.updated_at.timestamp()}"
    css = cache.get(key)
    if css is None:
        css = theme.emit_css()
        cache.set(key, css, timeout=60 * 60 * 24)
    return HttpResponse(css, content_type="text/css")
```

Then reference it as a `<link>` instead of inline `<style>`:

```django
<link rel="stylesheet" href="{% url 'theme_css' slug=active_theme.slug %}?v={{ active_theme.updated_at|date:'U' }}">
```

### Dump CSS to a static file at build time

Useful if you ship a static landing page or mobile app:

```python
# scripts/dump_theme_css.py
import django; django.setup()
from pathlib import Path
from wagtail_themes.models import Theme

out = Path("static/css/themes")
out.mkdir(parents=True, exist_ok=True)
for theme in Theme.objects.all():
    (out / f"{theme.slug}.css").write_text(theme.emit_css())
```

### Per-section themes (marketing vs blog)

```python
class MarketingIndexPage(ThemedPageMixin, Page):
    """Set theme=Marketing on this page; all marketing children inherit."""
    promote_panels = Page.promote_panels + ThemedPageMixin.theme_panels


class BlogIndexPage(ThemedPageMixin, Page):
    """Set theme=Blog here; all posts inherit."""
    promote_panels = Page.promote_panels + ThemedPageMixin.theme_panels
```

A `BlogPostPage` deep under `BlogIndexPage` will resolve to the Blog theme without each post needing to set it.

### A "preview theme" for stakeholders

Let editors preview an unpublished theme without affecting visitors:

```python
# middleware.py
from wagtail_themes.models import Theme

class PreviewThemeMiddleware:
    def __init__(self, get_response): self.get_response = get_response
    def __call__(self, request):
        slug = request.GET.get("preview_theme")
        if slug and request.user.is_staff:
            try:
                request.active_theme = Theme.objects.get(slug=slug)
            except Theme.DoesNotExist:
                pass
        return self.get_response(request)
```

Now `?preview_theme=halloween` overrides the live theme for staff only.

### Customising the default theme on first install

Use a data migration:

```python
# myapp/migrations/0002_seed_theme.py
from django.db import migrations


def seed_theme(apps, schema_editor):
    Theme = apps.get_model("wagtail_themes", "Theme")
    BrandColor = apps.get_model("wagtail_themes", "BrandColor")
    theme, _ = Theme.objects.get_or_create(
        slug="default",
        defaults={"name": "Default", "is_default": True, "default_mode": "system"},
    )
    BrandColor.objects.get_or_create(
        theme=theme, name="Primary", defaults={"color_value": "#3b82f6"}
    )


class Migration(migrations.Migration):
    dependencies = [("wagtail_themes", "0001_initial"), ("myapp", "0001_initial")]
    operations = [migrations.RunPython(seed_theme, migrations.RunPython.noop)]
```

---

## Testing your integration

The package ships its own pytest suite. To test *your* code:

```python
# tests/test_my_pages.py
import pytest
from wagtail_themes.models import Theme
from myapp.models import HomePage


@pytest.mark.django_db
def test_homepage_uses_marketing_theme(rf, root_page):
    marketing = Theme.objects.create(name="Marketing", slug="marketing")
    home = root_page.add_child(
        instance=HomePage(title="Home", slug="home", theme=marketing)
    )
    assert home.get_active_theme() == marketing


@pytest.mark.django_db
def test_homepage_inherits_default_theme(rf, root_page):
    default = Theme.objects.create(name="Default", slug="default", is_default=True)
    home = root_page.add_child(instance=HomePage(title="Home", slug="home"))
    assert home.get_active_theme() == default
```

---

## Troubleshooting / FAQ

**The page renders without a theme — `var(--color-bg)` is undefined.**
This shouldn't happen on a fresh install: when the resolver returns `None`, `{% theme_css %}` falls back to the model's baked-in field defaults (white surfaces, slate text, navy dark mode, etc.) and `{% theme_html_attrs %}` falls back to `data-theme="system"`. If you're still seeing undefined variables, either (a) you're inside a non-request context (the tag couldn't reach the resolver), or (b) you've explicitly opted out via `{% theme_css fallback=False %}`. To customise the fallback, create a `Theme` and tick **is_default**.

**Dark mode doesn't switch on click.**
- Check `<html>` actually changes `data-theme`. Open devtools → Elements.
- Verify `localStorage["wagtail-themes:mode"]` is being written. If not, the page is loaded from `file://` (localStorage may be sandboxed) or the switcher's JS hasn't loaded yet.
- Make sure you have `{% theme_no_flash %}` and `{% theme_switcher %}` *both* loaded; the switcher writes the value, the no-flash script reads it on next paint.

**Tailwind opacity (`bg-primary/50`) doesn't work.**
Tailwind needs the RGB triplet form. Use `rgb(var(--color-primary-rgb) / <alpha-value>)` in the config (see [Tailwind integration](#tailwind-integration)). Gradients don't get an `-rgb` companion — that's by design.

**Editors saved a brand color but it doesn't appear in CSS.**
Brand colors with `is_active=False` are skipped. Also confirm the brand color belongs to the active theme — they're scoped per-theme.

**Pages don't see my changes after editing a Theme.**
The CSS is regenerated on each request — no caching by default. If you added the [emit-once cache recipe](#cache-the-emitted-css), bump or invalidate the cache key.

**Custom fonts URL is loaded twice.**
Make sure `{% theme_css %}` appears only once per page. Or pass `include_fonts=False` to it and link the font file yourself.

**Why aren't button styles tokenised (shape, animation)?**
Intentional. v1 only ships pure tokens (colors, radii, shadows, typography). Components (buttons, cards) compose those tokens however your design system wants.

---

## Development

```bash
git clone https://github.com/ujeenet/wagtail-visual-themes.git
cd wagtail-visual-themes
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"

# Tests
PYTHONPATH=src python -m pytest tests/ -v

# Build wheel + sdist
python -m build

# Lint
ruff check src tests
```

PRs welcome. Keep changes tightly scoped to visual styling; component-specific logic (button shape enums, hero patterns, navigation, logos) is out of scope for this package.

---

## Releasing

CI runs on every push and PR via [.github/workflows/ci.yml](.github/workflows/ci.yml). Publishing is automated via [.github/workflows/publish.yml](.github/workflows/publish.yml) — pushing a `v*` tag triggers a build, runs tests, publishes to PyPI, and creates a GitHub Release.

**One-time setup** (PyPI Trusted Publishing — no API tokens needed):

1. Create a PyPI account at https://pypi.org/account/register/.
2. Go to https://pypi.org/manage/account/publishing/ and click *Add a new pending publisher*.
3. Fill in:
   - **PyPI Project Name**: `wagtail-visual-themes`
   - **Owner**: `ujeenet`
   - **Repository name**: `wagtail-visual-themes`
   - **Workflow name**: `publish.yml`
   - **Environment name**: `pypi`
4. In the GitHub repo, go to *Settings → Environments → New environment*, name it `pypi`. (You can add manual approval here if you want a gate before each release.)

**Cutting a release:**

```bash
# 1. Bump version in pyproject.toml (e.g. 0.1.0 → 0.1.1)
# 2. Update CHANGELOG.md
git add pyproject.toml CHANGELOG.md
git commit -m "release: v0.1.1"
git push

# 3. Tag and push the tag — this triggers publish.yml:
git tag v0.1.1
git push origin v0.1.1
```

Watch the *Actions* tab. On success, the package is on PyPI and a GitHub Release exists with the wheel + sdist attached.

**Without trusted publishing** (manual): replace the `pypa/gh-action-pypi-publish` step's `id-token: write` permission with a `PYPI_API_TOKEN` secret and pass it via `password: ${{ secrets.PYPI_API_TOKEN }}`. Trusted publishing is strictly better — recommended.

---

## License

MIT — see [LICENSE](./LICENSE).
