Theming¶
Vibetuner separates build-time brand palette (one set of role colors
baked into bundle.css) from runtime per-tenant overrides (a small
<style> block injected per request). Both pivot on the same cascade
trick — overriding the variables DaisyUI emits, not the plugin that emits
them.
Build-time brand palette¶
DaisyUI's light and dark themes are emitted by @plugin "daisyui"
inside @alltuner/vibetuner/core.css. To customise the four role colors
across the whole bundle, override the [data-theme="…"] selectors
directly in your project's config.css:
@import "@alltuner/vibetuner/core.css";
@source "templates";
@source "assets/statics/js";
[data-theme="light"] {
--color-primary: oklch(64% 0.21 24);
--color-primary-content: oklch(98% 0 0);
--color-accent: oklch(82% 0.18 95);
}
[data-theme="dark"] {
--color-primary: oklch(64% 0.21 24);
--color-primary-content: oklch(98% 0 0);
--color-accent: oklch(82% 0.18 95);
}
DaisyUI emits a rule like
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { … }.
The override above has the same specificity as the standalone
[data-theme=light] matcher and lands later in the file — cascade
picks the consumer rules.
Caveat — DaisyUI theme-controller radio inputs. If your UI uses
DaisyUI's <input class="theme-controller" value="light"> switcher
pattern (without setting data-theme on the root element), the
selector that matches at runtime is
:root:has(input.theme-controller[value=light]:checked), which has
higher specificity than [data-theme="light"]. The override is then
bypassed for that path. Most projects set data-theme on <html>
directly, where the override pattern works as expected.
Escape hatch — brand-new named themes. DaisyUI's documented
@plugin "daisyui/theme" { name: "my-brand"; … } form lets projects
register entirely new themes. It is not reachable from a
consumer-side config.css by default: daisyui is a private
transitive of @alltuner/vibetuner, so bun's isolated linker keeps
it out of the project's root node_modules. Add it explicitly when
you need this:
The top-level symlink makes @plugin "daisyui/theme" resolvable from
config.css. Most projects don't need this — overriding
[data-theme="…"] is enough.
Light / dark / system toggle¶
Vibetuner's skeleton ships a tiny, CSP-nonced no-flash theme setter
(base/theme_init.html.jinja) that runs synchronously high in <head>,
before bundle.css. It sets data-theme on <html> before first
paint, so the page never flashes the wrong theme (the "flash of
inaccurate theme" you get when JS switches the theme after the first
render).
Three pieces of state:
localStorage['theme']— the persisted preference. Holds"light"or"dark"; absent means system.data-theme-modeon<html>— the chosen mode (system,light, ordark).data-themeon<html>— the concrete theme DaisyUI renders (lightordark). Insystemmode this resolves againstprefers-color-schemeand live-updates if the OS theme changes.
The default mode is system, so a fresh visitor sees their OS
preference. The setter also exposes window.cycleTheme(), which cycles
system → light → dark and persists the choice.
Wire a toggle with htmx's nonced hx-on path (a raw inline
onclick="…" would be blocked by the project's script-src CSP):
For the toggle to have a visible effect, define both a light and a
dark palette (DaisyUI ships both by default — see the build-time
palette section above).
Projects that want different theming logic can override just
base/theme_init.html.jinja instead of the whole skeleton.
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 — the no-flash setter goes before the stylesheet, the per-tenant overrides go after it:
{% include "base/theme_init.html.jinja" %}
<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.
4. Tailwind v3 raw-RGB tokens in @theme. If you extend your project's
config.css with a custom brand palette using the pre-Tailwind-v4
raw-triplet pattern, the tokens look fine for direct utility usage but
collapse the moment they flow through @apply:
@theme {
--color-brand-red: 239 68 68;
}
/* Cascade override that recovers a valid color for direct class usage */
.bg-brand-red { background-color: rgb(var(--color-brand-red)); }
class="bg-brand-red" resolves correctly because the cascade-override
wins on source order. But Tailwind v4 expands
@apply bg-brand-red by inlining its own auto-generated rule for the
token (background-color: var(--color-brand-red)), not the
override:
@utility btn-brand-primary {
@apply bg-brand-red text-white;
}
/* Compiles to: */
.btn-brand-primary {
background-color: var(--color-brand-red); /* resolves to "239 68 68" — invalid */
color: var(--color-white);
}
239 68 68 isn't a valid color value, so the property is
invalid-at-computed-value-time and dropped — the element renders fully
transparent. Define @theme tokens as full color values instead, and
drop the cascade-override classes (Tailwind v4 auto-generates the
.bg-* / .text-* utilities from @theme):
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.