Per-Tenant Theming¶
Multi-tenant Vibetuner apps often need per-tenant visual identity — at minimum
the four DaisyUI role colors and their content variants. Vibetuner ships a
small primitive for this: an embedded TenantTheme model, an opt-in context
provider, and a Jinja partial that injects the overrides at request time.
The model is simple: bundle.css stays tenant-agnostic and cached, and
theming is per-request HTML — no per-tenant CSS rebuilds.
How it composes¶
- Bundled CSS defines the role variables. Tailwind 4 + DaisyUI emit
--color-primary,--color-secondary,--color-accent,--color-neutral(and their*-contentforeground variants) intobundle.css. - Each request renders a tiny
<style>:root { ... }</style>block after the<link rel="stylesheet" href="bundle.css">, overriding only the variables the tenant has set. - Cascade does the rest. DaisyUI scopes its own theme via
:where(:root)(zero specificity), so a plain:root { ... }block always wins.
Add a TenantTheme to your tenant model¶
from beanie import Document
from pydantic import Field
from vibetuner.models import TenantTheme
from vibetuner.models.mixins import TimeStampMixin
class TenantModel(Document, TimeStampMixin):
name: str
slug: str
theme: TenantTheme = Field(default_factory=TenantTheme)
TenantTheme exposes eight optional #rrggbb fields:
| Field | CSS variable |
|---|---|
primary |
--color-primary |
secondary |
--color-secondary |
accent |
--color-accent |
neutral |
--color-neutral |
primary_content |
--color-primary-content |
secondary_content |
--color-secondary-content |
accent_content |
--color-accent-content |
neutral_content |
--color-neutral-content |
Hex strings are validated to match ^#[0-9a-fA-F]{6}$ — any other format
raises a Pydantic ValidationError at the model layer.
Backwards compatibility¶
Adding theme: TenantTheme = Field(default_factory=TenantTheme) to an existing
tenant document is a no-op for already-persisted records. MongoDB is
schema-on-read, so old documents load with a default-constructed TenantTheme
(all Nones) and keep_nulls=False keeps the field out of the database
until a value is set.
Wire up the context provider¶
The provider is opt-in — Vibetuner does not auto-register one, so apps that don't multi-tenant pay nothing. Register it once at startup, with a synchronous tenant getter that already has the tenant in hand:
from starlette.requests import Request
from vibetuner import register_tenant_theme_provider
def _tenant_from_request(request: Request):
# Whatever upstream middleware / dependency attached.
return getattr(request.state, "tenant", None)
register_tenant_theme_provider(_tenant_from_request)
The getter must be synchronous and cheap — it runs on every render. Do any
DB lookups in middleware (or a dependency) that fires before the route
executes, and stash the resulting object on request.state.
If your tenant model uses a different attribute name (e.g. branding
instead of theme), pass attribute="branding" to
register_tenant_theme_provider.
The provider is fail-soft: a getter exception, missing attribute, or wrong type is logged and the request renders with no overrides rather than 500ing.
Template integration¶
Vibetuner's base/skeleton.html.jinja already includes
base/theme.html.jinja between the bundled stylesheet link and
{% block head %}. The partial emits a CSP-noncified
<style>:root { ... }</style> only when theme_overrides is non-empty, so
apps that haven't registered the provider see no extra markup.
If you've replaced the skeleton wholesale in your project, mirror the same include order:
<link rel="stylesheet" href="{{ url_for('css', path='bundle.css').path }}" />
{% include "base/theme.html.jinja" %}
{% block head %}{% endblock head %}
Footguns¶
1. Build-time literals in custom tokens. A custom --shadow-glow-primary
that bakes an rgba literal will never follow theming, because the rgba is a
constant — not a var(...) reference:
/* Won't follow the theme */
--shadow-glow-primary: 0 0 80px rgba(0, 157, 220, 0.2);
/* Follows the theme */
--shadow-glow-primary: 0 0 80px color-mix(
in oklab, var(--color-primary) 20%, transparent
);
When you derive a custom token from a role color, always express it in terms of the role variable.
2. Hardcoded scale colors mixed into role-driven utilities. A class like
bg-linear-to-br from-accent/70 to-tiger-flame-700 mixes one role color with
a fixed-palette shade — only half of it follows the theme. Either keep the
gradient role-only (from-accent/70 to-accent/30) or accept that the scale
shade is a brand constant.
3. Inline <style> without a CSP nonce. The shipped partial sets
nonce="{{ csp_nonce }}" automatically; if you write a custom override
template, do the same.
What this primitive does not cover¶
- Fonts. Per-tenant font swapping is genuinely different from color
injection —
@font-facerules have to load the binary first, before any--font-displayvariable can switch to it. That's tracked separately in vibetuner#1705. - Logos / favicons / assets. These are app-specific and don't belong in a cross-cutting framework primitive. Wire them through your existing context.