Context
Multi-tenant vibetuner apps (radio, bulletin, future ones) routinely need per-tenant visual identity — at minimum the four DaisyUI role colors (`--color-primary` / `-secondary` / `-accent` / `-neutral`), often their `*-content` text colors, and eventually fonts and logo accents.
Today every app reinvents the same wiring:
- A pydantic embedded model on the tenant document with optional hex strings + `#rrggbb` validators.
- Plumbing through that app's site/template context to expose a `{css_var: hex}` map.
- A `<style nonce>` block in `base/skeleton.html.jinja` that injects `:root { ... }` after `bundle.css`.
- Audit pass on `config.css` to make sure every utility used in templates resolves to `var(--color-…)` and not a baked literal — `@theme` mostly handles this but custom tokens like `--shadow-glow-*` need explicit `color-mix(in oklab, var(--color-primary) 20%, transparent)` to follow the theme.
This works (radio just shipped it in alltuner/radio#377) but it's a copy-paste pattern with quiet failure modes. Worth landing as a vibetuner primitive.
Proposal
- A `TenantTheme` pydantic embedded model in `vibetuner.models` with the eight role/role-content fields and a `.overrides()` helper returning the `{css_var: hex}` dict. Apps embed it on their tenant doc as `theme: TenantTheme = TenantTheme()`.
- A `base/theme.html.jinja` partial vibetuner ships, included from `base/skeleton.html.jinja` between the `bundle.css` link and any `{% block head %}`. Reads a `theme_overrides` context key, emits a CSP-noncified `<style>:root { ... }</style>` block, escapes hex values defensively.
- A `register_context_provider`-style `tenant_theme_context()` helper that apps can call with their tenant doc to plumb the dict through without writing the bridge code each time.
- One paragraph in the docs explaining the runtime-injection model: `bundle.css` stays tenant-agnostic and cached, theming is per-request HTML — not per-tenant CSS rebuilds. Mention the cascade ordering (style block must come after `bundle.css` link) and that DaisyUI scopes its theme via `:where(:root)` so a plain `:root { ... }` block wins the cascade.
Footguns to bake into the helper / docs
- Build-time literals in custom tokens. `--shadow-glow-primary: 0 0 80px rgba(0, 157, 220, 0.2)` looks fine but won't follow theming because the rgba is a literal, not a var. Document the `color-mix(in oklab, var(--color-primary) 20%, transparent)` pattern so apps don't have to discover it the hard way.
- Hardcoded scale colors in templates. `bg-linear-to-br from-accent/70 to-tiger-flame-700` mixes a role color with a fixed-palette shade — breaks theming. Recommend `from-accent/70 to-accent/30` (role-only) or call this out in docs.
- CSP nonce on the injected `<style>`. Trivial to forget. The shipped partial would handle it.
Fonts are tracked separately in #1705 — they don't compose the same way colors do
(`@font-face` has to be loaded before `--font-display` can swap to it), and the catalogue /
asset-hosting story is its own surface. Keeping #1704 scoped to colors.
Optional extensions (later)
- A `build-prod:css` lint (tailwind plugin or post-build script) that fails CI when an emitted utility class baked a literal hex it shouldn't have. Catches `@theme` regressions automatically.
- Token audit lint wired into `build-prod:css`: scan `bundle.css` for hex/rgb literals adjacent to `--color-` references and fail CI. Catches the `--shadow-glow-` regression automatically. (Concrete form of the bullet above.)
- A small admin-side helper component (color picker + contrast checker) that talks to the tenant doc directly.
Why now
Radio just shipped this for podcasts. Bulletin's stations don't have it yet but probably want it. Filing this now while the pattern is fresh and before a third app diverges its own variant.
Filed by Claude Code (working on alltuner/radio).
Context
Multi-tenant vibetuner apps (radio, bulletin, future ones) routinely need per-tenant visual identity — at minimum the four DaisyUI role colors (`--color-primary` / `-secondary` / `-accent` / `-neutral`), often their `*-content` text colors, and eventually fonts and logo accents.
Today every app reinvents the same wiring:
This works (radio just shipped it in alltuner/radio#377) but it's a copy-paste pattern with quiet failure modes. Worth landing as a vibetuner primitive.
Proposal
Footguns to bake into the helper / docs
Optional extensions (later)
Why now
Radio just shipped this for podcasts. Bulletin's stations don't have it yet but probably want it. Filing this now while the pattern is fresh and before a third app diverges its own variant.
Filed by Claude Code (working on alltuner/radio).