Skip to content

feat: per-tenant theming via runtime CSS-variable injection#1707

Merged
davidpoblador merged 1 commit into
mainfrom
feat/tenant-theme
Apr 28, 2026
Merged

feat: per-tenant theming via runtime CSS-variable injection#1707
davidpoblador merged 1 commit into
mainfrom
feat/tenant-theme

Conversation

@davidpoblador

Copy link
Copy Markdown
Member

Closes #1704.

Summary

  • New embedded model vibetuner.models.TenantTheme with eight optional #rrggbb fields for DaisyUI role / role-content colors plus .overrides(){css_var: hex}.
  • New opt-in helper vibetuner.register_tenant_theme_provider(getter, *, attribute="theme") that registers a context provider exposing theme_overrides per request. Fail-soft: getter/type errors log and render with no overrides.
  • New shipped partial base/theme.html.jinja that emits a CSP-noncified <style>:root { ... }</style> only when theme_overrides is non-empty. Wired into base/skeleton.html.jinja between the bundle.css link and {% block head %}.
  • Docs: new theming.md page (added to User Guide nav), feature entry in llms.txt, detailed section in llms-full.txt covering cascade ordering, the two color footguns (build-time literals in custom tokens, hardcoded scale shades), and the per-tenant fonts carve-out (feat: per-tenant fonts via curated @font-face catalogue #1705).

bundle.css stays tenant-agnostic and cached; theming happens at request time in HTML — no per-tenant CSS rebuilds.

Backwards compatibility

  • The helper is opt-in: nothing fires until an app calls register_tenant_theme_provider(...). Apps that don't multi-tenant pay zero overhead.
  • 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; keep_nulls=False keeps unset fields out of the database).
  • base/skeleton.html.jinja now includes base/theme.html.jinja, which renders nothing when theme_overrides is empty — apps that haven't registered a provider see no extra markup.
  • Apps that have already shipped a bespoke per-tenant theming block (e.g. radio's refactor: remove template backwards compatibility symlink #377) keep working until they swap to the primitive in their own follow-up PR.

Test plan

  • uv run python -m pytest tests/ (full suite, 717 passed)
  • uv run python -m pytest tests/unit/test_tenant_theme.py (22 new tests covering the model validator, the provider helper, the rendered partial output, and HTML-escape defenses)
  • just format && just lint && just type-check clean
  • Smoke-test in a scaffolded project against a real tenant document
  • Once merged, file a follow-up to migrate radio's bespoke implementation to this primitive

🤖 Generated with Claude Code

Closes #1704.

Adds a small framework primitive so multi-tenant apps stop reinventing the
same per-tenant color-theming wiring:

- ``vibetuner.models.TenantTheme`` — embedded pydantic model with eight
  optional ``#rrggbb`` fields for the four DaisyUI role colors and their
  ``*-content`` foreground variants. ``.overrides()`` returns the
  ``{css_var: hex}`` map.
- ``vibetuner.register_tenant_theme_provider(getter)`` — opt-in helper that
  wraps a synchronous tenant getter and exposes ``theme_overrides`` in the
  template context. Fail-soft: getter / type errors log and render with no
  overrides.
- ``base/theme.html.jinja`` — shipped partial that emits a CSP-noncified
  ``<style>:root { ... }</style>`` block when ``theme_overrides`` is
  non-empty. Wired into ``base/skeleton.html.jinja`` between the
  ``bundle.css`` link and ``{% block head %}``.
- Docs: new ``theming.md`` page (added to the User Guide nav), feature
  entry in ``llms.txt``, detailed section in ``llms-full.txt`` covering
  the cascade ordering, footguns, and the per-tenant fonts carve-out
  (#1705).

``bundle.css`` stays tenant-agnostic and cached; theming happens at
request time in HTML, not via per-tenant CSS rebuilds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@davidpoblador davidpoblador marked this pull request as ready for review April 28, 2026 07:56
@davidpoblador davidpoblador merged commit 6a44e6c into main Apr 28, 2026
3 checks passed
@davidpoblador davidpoblador deleted the feat/tenant-theme branch April 28, 2026 07:57
davidpoblador pushed a commit that referenced this pull request Apr 28, 2026
🤖 I have created a release *beep* *boop*
---


##
[10.6.0](v10.5.0...v10.6.0)
(2026-04-28)


### Features

* enforce CSP in debug by default with opt-out flag
([#1702](#1702))
([2d939ea](2d939ea))
* per-tenant theming via runtime CSS-variable injection
([#1707](#1707))
([6a44e6c](6a44e6c))


### Miscellaneous Chores

* add `vibetuner core-templates-path` CLI for setup-tw-sources
([#1708](#1708))
([b095d32](b095d32))
* **deps:** bump gitpython from 3.1.46 to 3.1.47
([#1689](#1689))
([b44149d](b44149d))
* **deps:** bump gitpython from 3.1.46 to 3.1.47 in /vibetuner-py
([#1688](#1688))
([14aaf96](14aaf96))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: first-class per-tenant theming via runtime CSS-variable injection

1 participant