Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions vibetuner-docs/docs/development-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,49 @@ Vibetuner uses Tailwind CSS + DaisyUI. Edit `assets/config.css` for custom style

The build process automatically compiles to `assets/statics/css/bundle.css`.

### Brand Configuration

DaisyUI tokens and CSS variables cover everything that renders inside the
page, but a few brand surfaces are read before any CSS runs (favicon meta
tags, the PWA manifest) or in clients that ignore CSS variables (email
clients). `BrandSettings` is an app-level pydantic-settings surface that
drives those specific surfaces:

```bash
# .env (all three are optional; defaults shown)
BRAND_PRIMARY_COLOR=#5b2333
BRAND_BROWSER_THEME_COLOR=#ffffff
BRAND_EMAIL_BUTTON_COLOR= # falls back to BRAND_PRIMARY_COLOR when unset
```

- `BRAND_PRIMARY_COLOR` — Safari pinned-tab `mask-icon` color, Windows
tile color (`browserconfig.xml`), and the magic-link email button when
no override is set.
- `BRAND_BROWSER_THEME_COLOR` — mobile browser chrome
(`<meta name="theme-color">`) and the PWA manifest's `theme_color` /
`background_color`.
- `BRAND_EMAIL_BUTTON_COLOR` — override slot for the magic-link email
button when it needs to differ from the primary brand color.

Inputs accept any pydantic `Color` form (named, `rgb()`, hex short or
long); values canonicalise to long-form `#rrggbb` lowercase before
rendering.

```python
from vibetuner.config import settings

settings.brand.primary_color # HexColor("#5b2333")
settings.brand.browser_theme_color # HexColor("#ffffff")
settings.brand.email_button # email_button_color or primary_color
```

`settings.brand` is exposed in every Jinja render via the shipped
`_brand_context` provider, so templates read `{{ brand.primary_color }}`
without wiring anything up. `BrandSettings` is deliberately app-level
(favicon assets are static files served before tenant resolution; the
email service does not see request context). For per-tenant in-page
colors, use `TenantTheme`.

### Security Headers and CSP Nonce

Vibetuner includes `SecurityHeadersMiddleware` that sets security headers
Expand Down
69 changes: 69 additions & 0 deletions vibetuner-docs/docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,75 @@ Need native names but want a single source of truth? Call
`language_picker(code)` once per language instead of reading
`locale_names`.

### Brand Configuration

`TenantTheme` covers in-page DaisyUI role colors, but a few surfaces are
out of reach of CSS variables:

- **Favicon meta tags** (`mask-icon`, `msapplication-TileColor`,
`theme-color`) are read by the browser before any CSS runs.
- **PWA manifest** (`theme_color`, `background_color`) is consumed by the
OS install UI, also before CSS.
- **Magic-link emails** render in clients (Gmail, Outlook) that strip
`<style>` blocks and ignore CSS variables, so the button color must be
inlined at render time.

`BrandSettings` is an app-level pydantic-settings surface that drives those
specific surfaces. It is deliberately **not** per-tenant: favicon assets
are static files served before the request hits any tenant resolver, and
the email service does not see request context. For per-tenant in-page
colors, keep using `TenantTheme`.

**Configuration via environment variables:**

```bash
# Defaults shown; all three are optional.
BRAND_PRIMARY_COLOR=#5b2333
BRAND_BROWSER_THEME_COLOR=#ffffff
BRAND_EMAIL_BUTTON_COLOR= # falls back to BRAND_PRIMARY_COLOR when unset
```

`BRAND_PRIMARY_COLOR` drives the Safari pinned-tab `mask-icon` color,
the Windows tile color (`browserconfig.xml`), and the magic-link email
button (when `BRAND_EMAIL_BUTTON_COLOR` is unset).
`BRAND_BROWSER_THEME_COLOR` drives the mobile browser chrome
(`<meta name="theme-color">`) and the PWA manifest's `theme_color` /
`background_color`. `BRAND_EMAIL_BUTTON_COLOR` is the override slot for
when your brand button needs to differ from the favicon brand color
(e.g. for contrast on the email's white background).

**Inputs accept any pydantic-Color form:**

```bash
BRAND_PRIMARY_COLOR="#1DB954" # hex (long or short)
BRAND_PRIMARY_COLOR="rgb(29, 185, 84)" # rgb()
BRAND_PRIMARY_COLOR="dodgerblue" # CSS named colors
```

All values are canonicalised to long-form `#rrggbb` lowercase before
rendering. The underlying type is `HexColor`, a `pydantic_extra_types.color.Color`
subclass that pins `__str__` to `as_hex(format="long")` so HTML
interpolation is predictable (the bare `Color` type would render
`#ffffff` as the string `white`).

**Programmatic access:**

```python
from vibetuner.config import settings

settings.brand.primary_color # HexColor("#5b2333")
settings.brand.browser_theme_color # HexColor("#ffffff")
settings.brand.email_button # property: email_button_color or primary_color
```

`settings.brand` is exposed in every Jinja render via the shipped
`_brand_context` provider, so templates can read `{{ brand.primary_color }}`
without wiring anything up. The framework's bundled
`base/favicons.html.jinja`, `meta/site.webmanifest.jinja`, and
`meta/browserconfig.xml.jinja` already do this; the magic-link email
template receives `button_color=settings.brand.email_button` from
`send_magic_link_email()`.

### Service Dependency Injection

Vibetuner provides FastAPI `Depends()` wrappers for built-in services:
Expand Down
7 changes: 7 additions & 0 deletions vibetuner-docs/docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ Important notes:
context variables (`color_scheme`, `canonical_url`, `font_preloads`) so
projects can extend instead of wholesale-overriding the skeleton —
upstream changes (CSP nonce, theming, etc.) flow through automatically
- **Brand Configuration**: `BRAND_PRIMARY_COLOR`, `BRAND_BROWSER_THEME_COLOR`,
`BRAND_EMAIL_BUTTON_COLOR` env vars drive favicon meta tags, the PWA
manifest's `theme_color`/`background_color`, and the magic-link email button
— surfaces where DaisyUI/CSS-variable theming can't reach (favicon meta
is read before CSS runs; email clients ignore CSS variables). Accepts
pydantic-Color inputs (named, `rgb()`, hex). For per-tenant in-page
colors use `TenantTheme`; `BrandSettings` is deliberately app-level
- **Encrypted Fields**: `EncryptedFieldsMixin` and `EncryptedStr` type for
transparent Fernet encrypt-on-save / decrypt-on-load on any Beanie model field.
Import from `vibetuner.models.mixins`. Requires `FIELD_ENCRYPTION_KEY` env var
Expand Down
67 changes: 67 additions & 0 deletions vibetuner-py/src/vibetuner/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
computed_field,
model_validator,
)
from pydantic_extra_types.color import Color as _PydanticColor
from pydantic_extra_types.language_code import LanguageAlpha2
from pydantic_settings import BaseSettings, SettingsConfigDict

Expand All @@ -29,6 +30,25 @@
from .paths import PathSettings, config_vars as config_vars_path


class HexColor(_PydanticColor):
"""Subtype of :class:`pydantic_extra_types.color.Color` that always
renders as long-form ``#rrggbb`` hex.

Pydantic ``Color`` accepts named colors, ``rgb()``, ``hsl()``, hex
shorthand etc., which is great for input flexibility. But its default
``__str__`` returns the named form when one matches (e.g.
``str(Color("#ffffff")) == "white"``), which is awkward when the value
is interpolated into HTML attributes via Jinja's ``{{ color }}``.

``HexColor`` keeps all of ``Color``'s parsing (so ``"red"``,
``"rgb(255,0,0)"`` and ``"#f00"`` all work as input) but pins
``str(self)`` to ``#rrggbb`` so templates render predictably.
"""

def __str__(self) -> str: # type: ignore[override]
return self.as_hex(format="long")


def _resolve_env_files() -> tuple[str, ...]:
"""Resolve .env file paths relative to the project root.

Expand Down Expand Up @@ -140,6 +160,49 @@ class LocaleDetectionSettings(BaseSettings):
)


class BrandSettings(BaseSettings):
"""App-level brand colors for surfaces CSS variables can't reach.

Read from ``BRAND_*`` env vars. These cover three places where
DaisyUI/CSS-variable theming doesn't apply:

- ``<link rel="mask-icon" color>`` (Safari pinned tab) and
``<meta msapplication-TileColor>`` (Windows tile) — both driven by
:attr:`primary_color`.
- ``<meta theme-color>`` (browser chrome / Android status bar) and the
PWA webmanifest's ``theme_color`` / ``background_color`` — driven by
:attr:`browser_theme_color`.
- Inline button color in HTML emails (e.g. magic-link CTA) — driven by
:attr:`email_button_color`, which falls back to :attr:`primary_color`.

For per-tenant theming of in-page colors, use
:class:`vibetuner.models.TenantTheme` instead. ``BrandSettings`` is
deliberately app-level: favicon meta is set before CSS runs, and the
email-sending code path doesn't currently see per-request context.
"""

primary_color: HexColor = HexColor("#5b2333")
browser_theme_color: HexColor = HexColor("#ffffff")
email_button_color: HexColor | None = None # falls back to primary_color

@property
def email_button(self) -> HexColor:
"""The color used for HTML-email CTA buttons.

Falls back to :attr:`primary_color` when ``BRAND_EMAIL_BUTTON_COLOR``
is not set, so a single ``BRAND_PRIMARY_COLOR`` covers both surfaces
in the common case.
"""
return self.email_button_color or self.primary_color

model_config = SettingsConfigDict(
case_sensitive=False,
extra="ignore",
env_prefix="BRAND_",
env_file=_ENV_FILES,
)


class MailSettings(BaseSettings):
"""Mail provider configuration. Read from MAIL_* env vars."""

Expand Down Expand Up @@ -244,6 +307,10 @@ class CoreConfiguration(BaseSettings):
# Mail provider settings (MAIL_* env vars)
mail: MailSettings = Field(default_factory=MailSettings)

# App-level brand colors (BRAND_* env vars). Used in places CSS variables
# can't reach: favicon meta, webmanifest theme/background, email buttons.
brand: BrandSettings = Field(default_factory=BrandSettings)

r2_default_bucket_name: str | None = None
r2_bucket_endpoint_url: HttpUrl | None = None
r2_access_key: SecretStr | None = None
Expand Down
1 change: 1 addition & 0 deletions vibetuner-py/src/vibetuner/frontend/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ async def send_magic_link_email(
context={
"login_url": str(login_url),
"project_name": project_name,
"button_color": settings.brand.email_button,
},
)

Expand Down
15 changes: 15 additions & 0 deletions vibetuner-py/src/vibetuner/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,21 @@ def _csp_nonce_context(request: Request) -> dict[str, Any]:

register_context_provider(_csp_nonce_context)


def _brand_context() -> dict[str, Any]:
"""Expose app-level brand colors (``settings.brand``) to every template.

See :class:`vibetuner.config.BrandSettings` for the underlying env vars.
Templates can reference ``{{ brand.primary_color }}``,
``{{ brand.browser_theme_color }}``, and ``{{ brand.email_button }}``.
"""
from vibetuner.config import settings

return {"brand": settings.brand}


register_context_provider(_brand_context)

# Global Vars
jinja_env.globals.update({"DEBUG": data_ctx.DEBUG})

Expand Down
27 changes: 19 additions & 8 deletions vibetuner-py/src/vibetuner/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,29 @@ def _render_template_with_env(
lang: str | None,
context: dict[str, Any],
) -> str:
"""Render template using Jinja environment with language fallback."""
# Try language-specific folder first
"""Render template using Jinja environment with language fallback.

Lookup order, first match wins:

1. ``<lang>/<name>`` when *lang* is provided.
2. ``default/<name>`` (legacy convention with a per-language tree).
3. ``<name>`` directly (flat layout — what the framework ships today
for email templates).
"""
candidates: list[str] = []
if lang:
candidates.append(f"{lang}/{jinja_template_name}")
candidates.append(f"default/{jinja_template_name}")
candidates.append(jinja_template_name)

for candidate in candidates:
try:
template = env.get_template(f"{lang}/{jinja_template_name}")
return template.render(**context)
template = env.get_template(candidate)
except TemplateNotFound:
pass
continue
return template.render(**context)

# Fallback to default folder
template = env.get_template(f"default/{jinja_template_name}")
return template.render(**context)
raise TemplateNotFound(jinja_template_name)


def render_static_template(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
{# djlint:off #}
{# Localize by adding per-language copies (e.g. templates/email/es/magic_link.html.jinja);
the renderer prefers <lang>/<name>, falls back to default/<name>, then this flat file. -#}
<html>
<body>
<p>Click the link below to sign in to your account:</p>
<p>
<a href="{{ login_url }}"
style="background-color: #007bff;
style="background-color: {{ button_color }};
color: white;
padding: 12px 24px;
text-decoration: none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@
<link rel="manifest" href="{{ url_for('site_webmanifest').path }}" />
<link rel="mask-icon"
href="{{ url_for('favicons', path='safari-pinned-tab.svg').path }}"
color="#5b2333" />
<meta name="msapplication-TileColor" content="#5b2333" />
<meta name="theme-color" content="#ffffff" />
color="{{ brand.primary_color }}" />
<meta name="msapplication-TileColor" content="{{ brand.primary_color }}" />
<meta name="theme-color" content="{{ brand.browser_theme_color }}" />
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<tile>
<square150x150logo
src="{{ url_for('favicons', path='mstile-150x150.png').path }}" />
<TileColor>#5b2333</TileColor>
<TileColor>{{ brand.primary_color }}</TileColor>
</tile>
</msapplication>
</browserconfig>
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"theme_color": "{{ brand.browser_theme_color }}",
"background_color": "{{ brand.browser_theme_color }}",
"display": "standalone"
}
Loading
Loading