diff --git a/vibetuner-docs/docs/development-guide.md b/vibetuner-docs/docs/development-guide.md
index 74401357..afb090d5 100644
--- a/vibetuner-docs/docs/development-guide.md
+++ b/vibetuner-docs/docs/development-guide.md
@@ -965,23 +965,37 @@ The following language-related variables are available in templates:
| `default_language` | `str` | Default language code (e.g., "en") |
| `supported_languages` | `set[str]` | Set of supported language codes |
| `locale_names` | `dict[str, str]` | Language codes to native display names |
+| `language` | `str` | The active language for the current request |
-#### Using `locale_names` for Language Selectors
+A `language_picker()` Jinja global is also available — see below.
-The `locale_names` dict maps language codes to their native display names, sorted alphabetically:
+#### Using `language_picker()` for Locale-Aware Switchers
+
+`language_picker()` is a Jinja global (also importable as
+`vibetuner.i18n.language_picker`) that returns a sorted list of
+`{code, name}` entries with names rendered in the **current request's
+locale**. Browsing in Spanish gives "inglés / español / catalán";
+browsing in Catalan gives "anglès / espanyol / català".
```html
```
-Example output: `{"ca": "Català", "en": "English", "es": "Español"}`
+Pass an explicit `display_locale` to render names in a specific language
+regardless of the request locale: `{% for e in language_picker("es") %}`.
+
+#### Using `locale_names` for native names
+
+`locale_names` is locale-independent — each language is shown in its own native name
+(e.g. `{"ca": "Català", "en": "English", "es": "Español"}`). Use this when you want a
+consistent display regardless of the user's current language.
### SEO-Friendly Language URLs
@@ -1005,12 +1019,64 @@ The `LangPrefixMiddleware` handles path-prefix language routing:
Languages are detected in this order (first match wins):
-1. Query parameter (`?l=es`)
-2. URL path prefix (`/ca/...`)
-3. User preference (from session, for authenticated users)
-4. Cookie (`language` cookie)
-5. Accept-Language header (browser preference)
-6. Default language
+1. **Custom resolvers** registered via
+ [`register_locale_resolver`](#custom-locale-resolvers-register_locale_resolver)
+2. Query parameter (`?l=es`)
+3. URL path prefix (`/ca/...`)
+4. User preference (from session, for authenticated users)
+5. Cookie (`language` cookie)
+6. Accept-Language header (browser preference)
+7. Default language
+
+#### Custom Locale Resolvers (`register_locale_resolver`)
+
+For per-tenant or domain-specific locale rules, register a custom resolver at
+startup. Resolvers run **before** all built-in selectors and the first one to
+return a non-`None` value wins. Within the registered group, resolvers are
+ordered by `priority` ascending (lower runs first).
+
+```python
+from vibetuner.i18n import register_locale_resolver
+
+def tenant_locale(conn):
+ tenant = getattr(conn.scope.get("state", {}), "tenant", None)
+ return tenant.language if tenant else None
+
+register_locale_resolver(tenant_locale)
+```
+
+Resolvers must be synchronous (do any I/O upstream in middleware). If a
+resolver raises, the exception is logged and the chain falls through to the
+next resolver — a bad lookup never produces a 500.
+
+#### Forcing a Language Mid-Request (`set_request_language`)
+
+To change the active language partway through a request (e.g. right after a
+session login), use `set_request_language`. It updates both the Babel context
+(drives `{% trans %}`) and `request.state.language` (drives `` and
+the `Content-Language` header) in one call so they stay in sync.
+
+```python
+from vibetuner.i18n import set_request_language
+
+set_request_language(request, user.preferred_language)
+```
+
+The code is normalized to lowercase and validated; an invalid code raises
+`ValueError`.
+
+#### Programmatic Language Picker (`language_picker`)
+
+When you need the picker output outside a template (e.g. JSON endpoint, email
+rendering), call `language_picker` directly. By default the names are
+rendered in the current request's locale.
+
+```python
+from vibetuner.i18n import language_picker
+
+choices = language_picker() # display in current locale
+es_choices = language_picker(display_locale="es") # always in Spanish
+```
#### Redirect Behavior
diff --git a/vibetuner-docs/docs/llms-full.txt b/vibetuner-docs/docs/llms-full.txt
index 4c773831..9b4878bb 100644
--- a/vibetuner-docs/docs/llms-full.txt
+++ b/vibetuner-docs/docs/llms-full.txt
@@ -1389,6 +1389,98 @@ rules to load the binary before any `--font-display` variable can switch to
it, which is a different mechanism. Tracked in
[vibetuner#1705](https://github.com/alltuner/vibetuner/issues/1705).
+### i18n Primitives
+
+`vibetuner.i18n` exposes three primitives for apps that need to override
+locale detection or render language switchers without hardcoding language
+lists.
+
+#### `register_locale_resolver(getter, *, priority=0)`
+
+Inject a custom selector at the **front** of `LocaleMiddleware`'s chain.
+Resolvers receive the `HTTPConnection` and return either a locale code or
+`None` to defer to the next resolver. All registered resolvers run before
+the built-in chain (query param → URL prefix → user session → cookie →
+`Accept-Language`). Within the registered group, lower `priority` runs
+first; ties fall back to insertion order.
+
+```python
+from vibetuner.i18n import register_locale_resolver
+
+
+def tenant_locale(conn):
+ tenant = conn.scope.get("state", {}).get("tenant")
+ return tenant.language if tenant else None
+
+
+register_locale_resolver(tenant_locale)
+```
+
+The combined selector is fail-soft: a resolver that raises is logged and
+the chain falls through. Resolvers must be synchronous — do DB lookups in
+upstream middleware that already attached the tenant to `request.state`.
+
+#### `set_request_language(request, code)`
+
+Force the active language for the rest of the request. Updates **both**
+the Babel context (drives `{% trans %}`) and `request.state.language`
+(drives `` and `Content-Language`) so all three stay in sync.
+Use this for the late-bound case — e.g. switching to the user's preferred
+language right after a session login.
+
+```python
+from vibetuner.i18n import set_request_language
+
+
+@router.post("/login")
+async def login(request: Request, ...):
+ user = await authenticate(...)
+ if user.preferred_language:
+ set_request_language(request, user.preferred_language)
+ ...
+```
+
+The code is normalized to lowercase and validated with
+`babel.Locale.parse`; an invalid code raises `ValueError`.
+
+#### `language_picker(display_locale=None, *, supported_languages=None)`
+
+Returns a sorted `[{"code", "name"}, ...]` list. Names are rendered in
+`display_locale` so the dropdown shows itself in the user's current
+language. Browsing in Spanish gives "inglés / español / catalán";
+browsing in Catalan gives "anglès / espanyol / català". When
+`display_locale` is omitted, the current Babel context locale is used.
+
+```python
+from vibetuner.i18n import language_picker
+
+picker = language_picker() # display in current request's locale
+es = language_picker(display_locale="es") # always in Spanish
+```
+
+The function is also registered as a Jinja global so templates can call
+it directly — no new context variable, no override of the existing
+`supported_languages` template var (which stays a `set[str]` of codes):
+
+```jinja
+
+```
+
+Pass an explicit `display_locale` to override the default of "current
+request locale": `{% for e in language_picker("es") %}` always renders
+in Spanish.
+
+The existing `locale_names` template variable (locale-independent native
+names — `{"ca": "Català", "en": "English"}`) and the `supported_languages`
+template variable (set of codes) are unchanged.
+
### Service Dependency Injection
Vibetuner provides FastAPI `Depends()` wrappers for built-in services:
diff --git a/vibetuner-docs/docs/llms.txt b/vibetuner-docs/docs/llms.txt
index 45400782..beab3cf2 100644
--- a/vibetuner-docs/docs/llms.txt
+++ b/vibetuner-docs/docs/llms.txt
@@ -80,6 +80,15 @@ Important notes:
render, fail-soft. Vibetuner's `base/skeleton.html.jinja` already includes
`base/theme.html.jinja`, which emits a CSP-noncified ``
block after `bundle.css`. `bundle.css` stays tenant-agnostic and cached
+- **i18n Primitives**: `vibetuner.i18n` ships
+ `register_locale_resolver(getter, *, priority=0)` to inject custom selectors
+ at the front of `LocaleMiddleware`'s chain (per-tenant locale becomes a
+ one-liner), `set_request_language(request, code)` to update both the Babel
+ context and `request.state.language` in one call, and `language_picker(display_locale=None)`
+ returning `[{code, name}]` with names rendered in the current request's locale
+ (browsing in Spanish gives "inglés / español / catalán"; never English-only).
+ `language_picker` is also registered as a Jinja global so templates can call
+ it directly without overriding any existing template variable
- **Service DI**: `get_email_service()`, `get_blob_service()`, `get_runtime_config()`
FastAPI dependency wrappers
- **Email Providers**: Pluggable transactional email via Resend
diff --git a/vibetuner-py/src/vibetuner/frontend/middleware.py b/vibetuner-py/src/vibetuner/frontend/middleware.py
index 7b26e7a7..65237cb3 100644
--- a/vibetuner-py/src/vibetuner/frontend/middleware.py
+++ b/vibetuner-py/src/vibetuner/frontend/middleware.py
@@ -384,13 +384,16 @@ def _build_locale_selectors() -> list:
Selectors are evaluated in order. The first one that returns
a valid locale wins. Order is fixed by design:
+ 0. user-registered resolvers (vibetuner.i18n.register_locale_resolver)
1. query_param - ?l=ca query parameter
2. url_prefix - /ca/... path prefix
3. user_session - authenticated user's stored preference
4. cookie - language cookie
5. accept_language - browser Accept-Language header
"""
- selectors: list = []
+ from vibetuner.i18n import combined_locale_selector
+
+ selectors: list = [combined_locale_selector]
config = settings.locale_detection
if config.query_param:
diff --git a/vibetuner-py/src/vibetuner/i18n.py b/vibetuner-py/src/vibetuner/i18n.py
new file mode 100644
index 00000000..b7d25081
--- /dev/null
+++ b/vibetuner-py/src/vibetuner/i18n.py
@@ -0,0 +1,229 @@
+# ABOUTME: Public i18n primitives — locale resolver registry, request-language helper, language picker
+# ABOUTME: Used by apps that need to override the locale chain or expose a language switcher
+"""Public i18n primitives for vibetuner apps.
+
+Three pieces of glue that close the gap between vibetuner's
+:class:`~starlette_babel.LocaleMiddleware` setup and per-tenant /
+user-driven language flows:
+
+* :func:`register_locale_resolver` — inject a custom selector into the
+ locale-detection chain (e.g. "this tenant always renders in its own
+ language regardless of ``Accept-Language``").
+* :func:`set_request_language` — update both the Babel context and
+ ``request.state.language`` in one call so ``{% trans %}``, the
+ ```` attribute and the ``Content-Language`` header all
+ agree.
+* :func:`language_picker` — produce ``[{code, name}]`` for a language
+ switcher, with names rendered in the *current* display locale (not
+ hard-coded to English).
+
+``language_picker`` is also exposed as a Jinja global so templates can
+call it directly — no new context variable, no override of the existing
+``supported_languages`` template var (which stays a ``set[str]`` of
+codes).
+"""
+
+from collections.abc import Callable
+
+from babel import Locale, UnknownLocaleError
+from fastapi.requests import HTTPConnection
+from starlette.requests import Request
+from starlette_babel import get_locale, set_locale
+
+from vibetuner.context import ctx
+from vibetuner.logging import logger
+
+
+__all__ = [
+ "LocaleResolver",
+ "combined_locale_selector",
+ "get_locale_resolvers",
+ "language_picker",
+ "register_locale_resolver",
+ "set_request_language",
+]
+
+
+LocaleResolver = Callable[[HTTPConnection], str | None]
+
+
+_resolvers: list[tuple[int, int, LocaleResolver]] = []
+_insertion_counter = 0
+
+
+def register_locale_resolver(
+ resolver: LocaleResolver,
+ *,
+ priority: int = 0,
+) -> LocaleResolver:
+ """Register a custom locale resolver at the front of the selector chain.
+
+ Resolvers receive the current
+ :class:`~fastapi.requests.HTTPConnection` and return either a
+ locale code (e.g. ``"ca"``) or ``None`` to defer to the next
+ resolver. The first resolver to return a non-``None`` value wins.
+
+ All registered resolvers run *before* vibetuner's built-in
+ selectors (query param, URL prefix, user session, cookie,
+ ``Accept-Language``). Within the registered group, resolvers are
+ ordered by ``priority`` ascending — lower numbers run first. Ties
+ fall back to insertion order.
+
+ The combined selector is fail-soft: if a resolver raises, the
+ exception is logged and the next resolver runs. This avoids
+ turning a misbehaving per-tenant lookup into a 500.
+
+ Args:
+ resolver: Callable that takes an ``HTTPConnection`` and returns
+ ``str | None``. Must be synchronous; do any I/O upstream.
+ priority: Sort key within the registered group. Lower runs
+ first. Defaults to ``0``.
+
+ Returns:
+ The resolver, unchanged — handy for use as a decorator.
+
+ Example::
+
+ from vibetuner.i18n import register_locale_resolver
+
+ def tenant_locale(conn):
+ tenant = conn.scope.get("state", {}).get("tenant")
+ return tenant.language if tenant else None
+
+ register_locale_resolver(tenant_locale)
+ """
+ if not callable(resolver):
+ raise TypeError(
+ f"register_locale_resolver: expected callable, got {type(resolver).__name__}"
+ )
+ global _insertion_counter
+ _resolvers.append((priority, _insertion_counter, resolver))
+ _insertion_counter += 1
+ _resolvers.sort(key=lambda item: (item[0], item[1]))
+ return resolver
+
+
+def get_locale_resolvers() -> list[LocaleResolver]:
+ """Return registered locale resolvers in chain order."""
+ return [resolver for _, _, resolver in _resolvers]
+
+
+def combined_locale_selector(conn: HTTPConnection) -> str | None:
+ """Run registered resolvers in priority order; return the first hit.
+
+ This is the single selector vibetuner inserts into
+ :class:`~starlette_babel.LocaleMiddleware` to delegate to
+ user-registered resolvers. Apps usually do not call it directly.
+ """
+ for resolver in get_locale_resolvers():
+ try:
+ result = resolver(conn)
+ except Exception as exc:
+ logger.error(
+ f"locale resolver {getattr(resolver, '__name__', resolver)!r} raised {exc!r}"
+ )
+ continue
+ if result:
+ return result
+ return None
+
+
+def _reset_locale_resolvers() -> None:
+ """Test hook: clear the resolver registry."""
+ global _insertion_counter
+ _resolvers.clear()
+ _insertion_counter = 0
+
+
+def set_request_language(request: Request, code: str) -> None:
+ """Force the active language for the rest of the request.
+
+ Updates both the Babel context (drives ``{% trans %}``) and
+ ``request.state.language`` (drives ```` and the
+ ``Content-Language`` response header) so all three stay in sync.
+
+ The code is normalized to lowercase and validated with
+ :func:`babel.Locale.parse`; an invalid code raises
+ :class:`ValueError` rather than letting Babel surface a less
+ helpful error later in the request.
+
+ Use this for the late-bound case — e.g. switching to the user's
+ preferred language right after a session login. For the
+ "every request for tenant X uses language Y" case, prefer
+ :func:`register_locale_resolver`.
+
+ Args:
+ request: The active Starlette/FastAPI request.
+ code: Two-letter language code (e.g. ``"ca"``).
+
+ Raises:
+ TypeError: If ``code`` is not a string.
+ ValueError: If ``code`` is not a parseable locale.
+ """
+ if not isinstance(code, str):
+ raise TypeError(
+ f"set_request_language: code must be str, got {type(code).__name__}"
+ )
+ normalized = code.lower()
+ try:
+ locale = Locale.parse(normalized)
+ except (UnknownLocaleError, ValueError) as exc:
+ raise ValueError(
+ f"set_request_language: {code!r} is not a valid locale code"
+ ) from exc
+ set_locale(locale)
+ request.state.language = normalized
+
+
+def language_picker(
+ display_locale: str | Locale | None = None,
+ *,
+ supported_languages: set[str] | None = None,
+) -> list[dict[str, str]]:
+ """Return a sorted ``[{code, name}, ...]`` list for a language switcher.
+
+ Names are rendered in ``display_locale`` so the dropdown shows
+ itself in the user's current language. Browsing in Spanish gives
+ "inglés / español / catalán"; browsing in Catalan gives
+ "anglès / espanyol / català". When ``display_locale`` is omitted,
+ the current Babel context locale is used (falls back to the
+ project's default language).
+
+ Args:
+ display_locale: Locale used to render display names. Accepts a
+ string code or a :class:`babel.Locale`. Defaults to the
+ current request's locale.
+ supported_languages: Override the set of language codes to
+ include. Defaults to ``settings.project.languages``.
+
+ Returns:
+ A list of ``{"code": "", "name": ""}``
+ dicts, sorted by ``name``.
+ """
+ languages = (
+ supported_languages
+ if supported_languages is not None
+ else ctx.supported_languages
+ )
+
+ if display_locale is None:
+ try:
+ display = get_locale()
+ except Exception:
+ display = Locale.parse(ctx.default_language)
+ elif isinstance(display_locale, Locale):
+ display = display_locale
+ else:
+ display = Locale.parse(display_locale)
+
+ entries: list[dict[str, str]] = []
+ for code in languages:
+ try:
+ locale = Locale.parse(code)
+ name = locale.get_display_name(display) or code
+ except Exception:
+ name = code
+ entries.append({"code": code, "name": name})
+
+ entries.sort(key=lambda entry: entry["name"].casefold())
+ return entries
diff --git a/vibetuner-py/src/vibetuner/rendering.py b/vibetuner-py/src/vibetuner/rendering.py
index b150e375..cc95817c 100644
--- a/vibetuner-py/src/vibetuner/rendering.py
+++ b/vibetuner-py/src/vibetuner/rendering.py
@@ -755,6 +755,12 @@ def _csp_nonce_context(request: Request) -> dict[str, Any]:
jinja_env.globals.update({"url_for_language": url_for_language})
jinja_env.globals.update({"hreflang_tags": hreflang_tags})
+# Language picker (lazy import to avoid circular dependency on vibetuner.i18n)
+from vibetuner.i18n import language_picker as _language_picker # noqa: E402
+
+
+jinja_env.globals.update({"language_picker": _language_picker})
+
# Date Filters
jinja_env.filters["timeago"] = timeago
jinja_env.filters["format_date"] = format_date
diff --git a/vibetuner-py/tests/unit/test_i18n.py b/vibetuner-py/tests/unit/test_i18n.py
new file mode 100644
index 00000000..590519a2
--- /dev/null
+++ b/vibetuner-py/tests/unit/test_i18n.py
@@ -0,0 +1,178 @@
+# ABOUTME: Unit tests for vibetuner.i18n public primitives
+# ABOUTME: Covers register_locale_resolver, set_request_language, language_picker
+# ruff: noqa: S101
+
+"""Tests for the public i18n primitives.
+
+The module under test is :mod:`vibetuner.i18n`. These tests exercise the
+public surface only — internal registries are reset between tests via the
+``reset_locale_resolvers`` fixture so registrations from one test do not
+bleed into the next.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+from babel import Locale
+from starlette_babel import set_locale
+from vibetuner import i18n
+
+
+@pytest.fixture(autouse=True)
+def reset_locale_resolvers():
+ """Clear the resolver registry between tests."""
+ i18n._reset_locale_resolvers()
+ yield
+ i18n._reset_locale_resolvers()
+
+
+def _fake_request(language: str | None = None) -> MagicMock:
+ """Build a minimal Request stand-in that supports request.state.language."""
+ request = MagicMock()
+ request.state = SimpleNamespace()
+ if language is not None:
+ request.state.language = language
+ return request
+
+
+def _fake_connection(state: dict | None = None) -> MagicMock:
+ """Build a minimal HTTPConnection stand-in for selector calls."""
+ conn = MagicMock()
+ conn.scope = {"state": state or {}}
+ return conn
+
+
+class TestRegisterLocaleResolver:
+ def test_resolver_appears_in_chain(self):
+ def resolver(conn):
+ return "ca"
+
+ i18n.register_locale_resolver(resolver)
+ chain = i18n.get_locale_resolvers()
+
+ assert resolver in chain
+
+ def test_resolvers_ordered_by_priority_low_first(self):
+ first = MagicMock(return_value=None)
+ second = MagicMock(return_value=None)
+ third = MagicMock(return_value=None)
+
+ i18n.register_locale_resolver(second, priority=10)
+ i18n.register_locale_resolver(third, priority=20)
+ i18n.register_locale_resolver(first, priority=0)
+
+ chain = i18n.get_locale_resolvers()
+ assert chain == [first, second, third]
+
+ def test_combined_selector_returns_first_non_none(self):
+ i18n.register_locale_resolver(lambda conn: None, priority=0)
+ i18n.register_locale_resolver(lambda conn: "es", priority=10)
+ i18n.register_locale_resolver(lambda conn: "ca", priority=20)
+
+ combined = i18n.combined_locale_selector
+ assert combined(_fake_connection()) == "es"
+
+ def test_combined_selector_returns_none_when_all_none(self):
+ i18n.register_locale_resolver(lambda conn: None)
+ i18n.register_locale_resolver(lambda conn: None)
+
+ assert i18n.combined_locale_selector(_fake_connection()) is None
+
+ def test_combined_selector_with_no_resolvers_returns_none(self):
+ assert i18n.combined_locale_selector(_fake_connection()) is None
+
+ def test_combined_selector_swallows_resolver_exceptions(self, log_sink):
+ def boom(conn):
+ raise RuntimeError("kaboom")
+
+ i18n.register_locale_resolver(boom, priority=0)
+ i18n.register_locale_resolver(lambda conn: "ca", priority=10)
+
+ assert i18n.combined_locale_selector(_fake_connection()) == "ca"
+ assert any("kaboom" in m for m in log_sink)
+
+ def test_register_rejects_non_callable(self):
+ with pytest.raises(TypeError):
+ i18n.register_locale_resolver("not a function") # type: ignore[arg-type]
+
+
+class TestSetRequestLanguage:
+ def test_updates_request_state_and_babel_contextvar(self):
+ request = _fake_request(language="en")
+ # seed Babel context with a different language
+ set_locale(Locale.parse("en"))
+
+ i18n.set_request_language(request, "ca")
+
+ assert request.state.language == "ca"
+ from starlette_babel import get_locale
+
+ assert str(get_locale()) == "ca"
+
+ def test_rejects_non_string_code(self):
+ request = _fake_request()
+ with pytest.raises(TypeError):
+ i18n.set_request_language(request, 42) # type: ignore[arg-type]
+
+ def test_rejects_invalid_locale_code(self):
+ request = _fake_request()
+ with pytest.raises(ValueError):
+ i18n.set_request_language(request, "not-a-locale")
+
+ def test_normalizes_to_lowercase(self):
+ request = _fake_request()
+ i18n.set_request_language(request, "CA")
+ assert request.state.language == "ca"
+
+
+class TestLanguagePicker:
+ def test_returns_code_and_name_for_each_supported_language(self):
+ result = i18n.language_picker(
+ display_locale="en", supported_languages={"en", "ca", "es"}
+ )
+
+ codes = {entry["code"] for entry in result}
+ assert codes == {"en", "ca", "es"}
+ for entry in result:
+ assert "code" in entry
+ assert "name" in entry
+ assert entry["name"]
+
+ def test_names_render_in_requested_display_locale(self):
+ en = i18n.language_picker(display_locale="en", supported_languages={"en", "es"})
+ es = i18n.language_picker(display_locale="es", supported_languages={"en", "es"})
+
+ en_map = {e["code"]: e["name"] for e in en}
+ es_map = {e["code"]: e["name"] for e in es}
+
+ assert en_map["es"].lower().startswith("spanish")
+ assert es_map["es"].lower().startswith("español")
+ assert es_map["en"].lower().startswith("inglés")
+
+ def test_default_display_locale_uses_babel_contextvar(self):
+ set_locale(Locale.parse("ca"))
+
+ result = i18n.language_picker(supported_languages={"en", "ca", "es"})
+ names = {e["code"]: e["name"] for e in result}
+
+ assert names["es"].lower().startswith("espanyol")
+ assert names["en"].lower().startswith("anglès")
+
+ def test_result_is_sorted_by_name(self):
+ result = i18n.language_picker(
+ display_locale="en", supported_languages={"en", "ca", "es"}
+ )
+ names = [e["name"] for e in result]
+ assert names == sorted(names)
+
+ def test_supported_languages_defaults_to_settings(self):
+ # No explicit list — should fall back to ctx.supported_languages.
+ result = i18n.language_picker(display_locale="en")
+ assert isinstance(result, list)
+ assert all("code" in e and "name" in e for e in result)
+
+ def test_exposed_as_jinja_global(self):
+ from vibetuner.rendering import jinja_env
+
+ assert jinja_env.globals.get("language_picker") is i18n.language_picker