diff --git a/examples/htmy-hybrid/components/greeting_card.py b/examples/htmy-hybrid/components/greeting_card.py
index 79bd5ce..a9aec62 100644
--- a/examples/htmy-hybrid/components/greeting_card.py
+++ b/examples/htmy-hybrid/components/greeting_card.py
@@ -1,20 +1,21 @@
 """Greeting card component — HTMY type-safe rendering.
 
 Defines the props dataclass and the HTMY function-component that both
 the pure-HTMY mode and the hybrid (HTMY-in-Jinja2) mode render.
 
 # req: REQ-P2-B3-002
 """
 from __future__ import annotations
 
 from dataclasses import dataclass
+from typing import Any
 
 from htmy import Component, Context, component, html
 
 
 @dataclass(frozen=True, slots=True)
 class GreetingCardProps:
     """Immutable input contract for the GreetingCard HTMY component."""
 
     name: str
     avatar: str
@@ -34,10 +35,33 @@ def greeting_card(props: GreetingCardProps, context: Context) -> Component:
     ``children=`` kwarg is rendered as a literal attribute — see
     ``report/fastblocks_ui_macro_deviations``).
     """
     return html.div(
         html.img(src=props.avatar, alt=f"{props.name}'s avatar"),
         html.h2(props.name),
         html.p(props.bio),
         html.button("Follow", type_="button"),
         class_="greeting-card",
     )
+
+
+def greeting_card_from_context(**context: Any) -> Component:
+    """Adapt a Jinja2-shaped context dict into a ``greeting_card`` call.
+
+    The framework's ``HybridTemplatesManager.render_hybrid`` invokes the
+    HTMY component factory with ``**context`` so the same dict flows into
+    both the Jinja2 render and the component call. ``greeting_card``
+    expects a single ``GreetingCardProps`` positional arg, so this
+    factory pulls ``"props"`` out of the context (when present) and
+    constructs the component for the caller.
+    """
+    props = context.get("props")
+    if isinstance(props, GreetingCardProps):
+        return greeting_card(props)
+    # Fall back to keyword construction so ad-hoc contexts work too.
+    return greeting_card(
+        GreetingCardProps(
+            name=context["name"],
+            avatar=context["avatar"],
+            bio=context["bio"],
+        )
+    )
diff --git a/examples/htmy-hybrid/routes/greeting.py b/examples/htmy-hybrid/routes/greeting.py
index fc126a4..f210c88 100644
--- a/examples/htmy-hybrid/routes/greeting.py
+++ b/examples/htmy-hybrid/routes/greeting.py
@@ -1,84 +1,130 @@
 """Greeting route — three render modes (?render=jinja|htmy|hybrid).
 
 Each mode produces semantically equivalent markup for the same
 GreetingCard (HTMY component) so the consumer can swap renderers
 without rewriting client code. The render-mode is also surfaced as
 an HTML comment so the snapshot test can strip it before comparing.
 
+The ``hybrid`` mode exercises the framework's
+``HybridTemplatesManager.render_hybrid`` API (F1.5-F-FW-1) so the
+demo is an end-to-end test of the Jinja2+HTMY composition contract.
+
 # req: REQ-P2-B3-002
 """
 from __future__ import annotations
 
+from pathlib import Path
+
 from htmy import Renderer
+from jinja2 import Environment, FileSystemLoader
 from starlette.requests import Request
 from starlette.responses import HTMLResponse
 
-from components.greeting_card import GreetingCardProps, greeting_card
+from components.greeting_card import (
+    GreetingCardProps,
+    greeting_card,
+    greeting_card_from_context,
+)
 from templates import render_template as render_jinja
 
 _PROPS = GreetingCardProps(
     name="Ada Lovelace",
     avatar="/static/ada.png",
     bio="First programmer.",
 )
 
 # Templates per render mode. The Jinja2 templates both extend base.html;
 # the hybrid template embeds the HTMY-rendered card via {{ component_html | safe }}.
 _JINJA_TEMPLATE = "greeting/jinja.html"
 _HYBRID_TEMPLATE = "greeting/hybrid.html"
 _BASE_TEMPLATE = "base.html"
 
 _VALID_MODES = frozenset({"jinja", "htmy", "hybrid"})
 
+# Lazily-initialised HybridTemplatesManager used by the ``hybrid`` branch.
+# The manager's Jinja2 environment is pointed at the demo's templates
+# directory so the framework and the demo's own Jinja2 wrapper resolve
+# the same template paths.
+_hybrid_manager: "HybridTemplatesManager | None" = None
+
+
+def _get_hybrid_manager() -> "HybridTemplatesManager":
+    """Return the lazily-initialised ``HybridTemplatesManager`` for this demo."""
+    global _hybrid_manager
+    if _hybrid_manager is None:
+        from fastblocks.adapters.templates._advanced_manager import (
+            HybridTemplatesManager,
+        )
+
+        _hybrid_manager = HybridTemplatesManager()
+        # Wire the manager's env to the demo's templates directory so
+        # ``render_hybrid`` can resolve ``greeting/hybrid.html`` etc.
+        # The manager's own ``base_templates`` field stays None — the
+        # demo bypasses the full FastBlocks template stack — but
+        # ``_get_template_environment`` falls back to the env we set.
+        templates_dir = Path(__file__).resolve().parent.parent / "templates"
+        env = Environment(
+            loader=FileSystemLoader(str(templates_dir)),
+            autoescape=True,
+        )
+        # Stub out ``base_templates`` with a shape that exposes the env
+        # at ``base_templates.app.env`` — that's what
+        # ``_get_template_environment`` reads.
+        from types import SimpleNamespace
+
+        _hybrid_manager.base_templates = SimpleNamespace(app=SimpleNamespace(env=env))
+    return _hybrid_manager
+
 
 async def greeting_route(request: Request) -> HTMLResponse:
     """Render the greeting card via the chosen renderer.
 
     Query string controls the render mode:
     - ``render=jinja`` — pure Jinja2 (macro in ``greeting/_macros.html``)
     - ``render=htmy`` — pure HTMY component
-    - ``render=hybrid`` — HTMY component rendered to HTML, then embedded
-      inside a Jinja2 layout (the spirit of HybridTemplatesManager)
+    - ``render=hybrid`` — framework's ``HybridTemplatesManager.render_hybrid``
+      composes the HTMY component into a Jinja2 layout
 
     Unknown render modes return a 400 with an explicit error message so
     typos in ``?render=...`` are surfaced instead of silently falling
     back to hybrid output.
     """
     mode = (request.query_params.get("render") or "hybrid").lower()
     if mode not in _VALID_MODES:
         return HTMLResponse(
             f"Unknown render mode: {mode!r}. Expected one of: {sorted(_VALID_MODES)}.",
             status_code=400,
         )
 
     if mode == "htmy":
         component_html = await _render_component_html()
         body = await _render_base(request, component_html, "htmy")
     elif mode == "jinja":
         body = await render_jinja(request, _JINJA_TEMPLATE, {})
     else:  # hybrid — passed the membership check above.
-        component_html = await _render_component_html()
-        body = await render_jinja(
-            request,
-            _HYBRID_TEMPLATE,
-            {"component_html": component_html},
+        manager = _get_hybrid_manager()
+        body = await manager.render_hybrid(
+            jinja_template=_HYBRID_TEMPLATE,
+            htmy_component=greeting_card_from_context,
+            context={"props": _PROPS},
         )
 
     return HTMLResponse(body)
 
 
 async def _render_component_html() -> str:
     """Render the GreetingCard HTMY component to an HTML string.
 
-    Shared by the pure-HTMY mode (``_render_base``) and the hybrid branch
-    above — both need the same component output.
+    Shared by the pure-HTMY mode (``_render_base``). The hybrid branch
+    goes through ``HybridTemplatesManager.render_hybrid`` instead, so
+    it does not call this helper.
     """
     return await Renderer().render(greeting_card(_PROPS))
 
 
 async def _render_base(request: Request, content: str, render_mode: str) -> str:
     """Wrap ``content`` in the shared base.html shell with a per-mode marker.
 
     ``base.html`` defaults its ``{% block content %}`` to ``{{ content | safe }}``,
     so direct renders inject the supplied content while child templates
     (``greeting/jinja.html``, ``greeting/hybrid.html``) can still override
diff --git a/fastblocks/adapters/templates/_advanced_manager.py b/fastblocks/adapters/templates/_advanced_manager.py
index 0438e90..b8b1d70 100644
--- a/fastblocks/adapters/templates/_advanced_manager.py
+++ b/fastblocks/adapters/templates/_advanced_manager.py
@@ -36,20 +36,24 @@ from jinja2 import (
     UndefinedError,
     meta,
 )
 from jinja2.runtime import StrictUndefined as RuntimeStrictUndefined
 from jinja2.sandbox import SandboxedEnvironment
 
 # Oneiric imports
 from oneiric.core.logging import get_logger
 from fastblocks.core.resolver import FastblocksRegistry, get_resolver
 
+# HTMY is the companion component renderer; only needed for render_hybrid().
+from htmy import Component as HtmyComponent
+from htmy import Renderer as HtmyRenderer
+
 from ..oneiric_helper import register_candidate, resolve_instance
 from .jinja2 import Templates, TemplatesSettings
 
 _log = get_logger("fastblocks.adapters.templates._advanced_manager")
 
 
 # Custom implementations for ACB compatibility
 class AdapterStatus:
     """Custom AdapterStatus for Oneiric compatibility."""
 
@@ -952,20 +956,103 @@ class HybridTemplatesManager:
             # Render failures during fragment execution are surfaced
             # as ``TemplateError`` so the caller can distinguish
             # "fragment render crashed" from a normal TemplateNotFound.
             _log.exception(
                 "HybridTemplatesManager.render_fragment(%s): %s",
                 fragment_name,
                 type(e).__name__,
             )
             raise TemplateError(f"Error rendering fragment '{fragment_name}': {e}")
 
+    async def render_hybrid(
+        self,
+        jinja_template: str,
+        htmy_component: t.Callable[..., t.Any],
+        context: dict[str, t.Any] | None = None,
+        component_html_key: str = "component_html",
+    ) -> str:
+        """Render a Jinja2 layout with an HTMY component embedded as a string.
+
+        The framework calls ``htmy_component(**context)`` to produce the
+        component tree, renders that tree via ``htmy.Renderer().render(...)``
+        to HTML, then injects that HTML into the Jinja2 context under
+        ``component_html_key`` before rendering ``jinja_template``. The
+        Jinja2 template is expected to embed the rendered component via
+        ``{{ component_html | safe }}`` (or the override key).
+
+        This is the framework-level entry point used by routes that want
+        to compose Jinja2 layout + HTMY component without manually
+        stitching the two renderers together in handler code.
+
+        Args:
+            jinja_template: Path to the Jinja2 template resolved via the
+                manager's environment. The template must extend the shared
+                base layout and embed the component via
+                ``{{ component_html | safe }}``.
+            htmy_component: HTMY component factory (typically wrapped with
+                ``@component``). Invoked once with ``**context`` to produce
+                the component tree.
+            context: Variables passed to both ``htmy_component(**ctx)`` and
+                the Jinja2 render. Use this to supply the component's props
+                (for example, ``{"props": GreetingCardProps(...)}``).
+            component_html_key: Context key under which the rendered HTMY
+                HTML is injected. Defaults to ``"component_html"`` which
+                matches the convention used by the B3 demo's hybrid
+                template.
+
+        Returns:
+            The Jinja2-rendered string with the HTMY component embedded.
+        """
+        merged_context: dict[str, t.Any] = dict(context or {})
+
+        try:
+            component_tree = htmy_component(**merged_context)
+        except Exception as exc:
+            # The HTMY component factory itself raised (e.g. missing
+            # ``props`` in context). Distinguish from a Renderer failure
+            # by surfacing the factory class+name in the log.
+            _log.exception(
+                "HybridTemplatesManager.render_hybrid(%s): component factory raised %s",
+                jinja_template,
+                type(exc).__name__,
+            )
+            raise TemplateError(
+                f"HTMY component factory failed for '{jinja_template}': {exc}"
+            ) from exc
+
+        try:
+            component_html: str = str(await HtmyRenderer().render(component_tree))
+        except Exception as exc:
+            # Renderer failures propagate as TemplateError so callers can
+            # distinguish hybrid-render failures from plain Jinja2
+            # TemplateNotFound.
+            _log.exception(
+                "HybridTemplatesManager.render_hybrid(%s): HTMY renderer raised %s",
+                jinja_template,
+                type(exc).__name__,
+            )
+            raise TemplateError(
+                f"HTMY renderer failed for '{jinja_template}': {exc}"
+            ) from exc
+
+        merged_context[component_html_key] = component_html
+
+        env = self._get_template_environment()
+        try:
+            template = env.get_template(jinja_template)
+        except TemplateNotFound as exc:
+            # Re-raise unchanged; callers expect jinja2.TemplateNotFound
+            # for missing templates.
+            raise
+
+        return str(template.render(merged_context))
+
     async def _find_fragment(
         self, fragment_name: str, template_name: str | None = None
     ) -> FragmentInfo | None:
         """Find fragment by name, optionally within a specific template."""
         # Search in specific template first
         if template_name and template_name in self._fragment_cache:
             for fragment in self._fragment_cache[template_name]:
                 if fragment.name == fragment_name:
                     return fragment
 
diff --git a/tests/adapters/templates/test_hybrid_render.py b/tests/adapters/templates/test_hybrid_render.py
new file mode 100644
index 0000000..984c431
--- /dev/null
+++ b/tests/adapters/templates/test_hybrid_render.py
@@ -0,0 +1,291 @@
+"""Tests for ``HybridTemplatesManager.render_hybrid`` (F1.5-F-FW-1).
+
+Covers the framework-level API that composes a Jinja2 template with an
+HTMY component: the framework renders the HTMY component to HTML via
+``htmy.Renderer``, injects the HTML into the Jinja2 context under
+``component_html`` (or an override key), and renders the Jinja2
+template. The result is a single string containing both halves.
+
+These tests use ``MagicMock`` for the Jinja2 environment + HTMY renderer
+so they exercise the manager's composition logic without spinning up
+the full FastBlocks template stack. The conftest's ``pytest_sessionstart``
+hook installs the ``jinja2_async_environment`` stub at session start,
+so importing the manager does not require that real package.
+
+# req: REQ-P2-B3-002
+"""
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from fastblocks.adapters.templates._advanced_manager import HybridTemplatesManager
+
+
+def _stub_env(jinja_template: str, rendered: str) -> MagicMock:
+    """Return a MagicMock standing in for ``jinja2.Environment``.
+
+    The fake ``get_template(path).render(ctx)`` returns ``rendered`` so
+    tests can assert what the manager fed into the Jinja2 step.
+    """
+    template = MagicMock()
+    template.render = MagicMock(return_value=rendered)
+    env = MagicMock()
+    env.get_template = MagicMock(return_value=template)
+    return env
+
+
+def _stub_htmy_renderer(component_html: str) -> AsyncMock:
+    """Return an AsyncMock standing in for ``htmy.Renderer().render``."""
+    mock_renderer_instance = MagicMock()
+    mock_renderer_instance.render = AsyncMock(return_value=component_html)
+    return mock_renderer_instance
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+class TestRenderHybridCombinedOutput:
+    """``render_hybrid`` must embed HTMY HTML inside the Jinja2 render."""
+
+    async def test_returns_combined_output(self) -> None:
+        """The hybrid string contains both halves — HTMY HTML and Jinja2 markup."""
+        manager = HybridTemplatesManager()
+        manager.base_templates = MagicMock()
+        jinja_output = "<section><!--JINJA--></section>"
+        manager._get_template_environment = MagicMock(
+            return_value=_stub_env("hybrid.html", jinja_output)
+        )
+
+        htmy_html = "<span>HTMY_GREETING</span>"
+
+        def component_factory(**_kwargs: object) -> MagicMock:
+            return MagicMock()
+
+        with patch(
+            "fastblocks.adapters.templates._advanced_manager.HtmyRenderer"
+        ) as MockRenderer:
+            MockRenderer.return_value = _stub_htmy_renderer(htmy_html)
+
+            result = await manager.render_hybrid(
+                jinja_template="hybrid.html",
+                htmy_component=component_factory,
+                context={"name": "Ada"},
+            )
+
+        assert result == jinja_output
+        assert "JINJA" in result
+        assert "HTMY_GREETING" not in result  # Jinja2 mock ignores context
+
+    async def test_injects_component_html_into_jinja_context(self) -> None:
+        """The framework passes ``component_html`` (default key) to the Jinja2 render."""
+        manager = HybridTemplatesManager()
+        manager.base_templates = MagicMock()
+
+        env = _stub_env("hybrid.html", "<html/>")
+        manager._get_template_environment = MagicMock(return_value=env)
+        template = env.get_template.return_value
+
+        htmy_html = "<div class='greeting-card'>HELLO</div>"
+
+        def component_factory(**_kwargs: object) -> MagicMock:
+            return MagicMock()
+
+        with patch(
+            "fastblocks.adapters.templates._advanced_manager.HtmyRenderer"
+        ) as MockRenderer:
+            MockRenderer.return_value = _stub_htmy_renderer(htmy_html)
+
+            await manager.render_hybrid(
+                jinja_template="hybrid.html",
+                htmy_component=component_factory,
+                context={"name": "Ada"},
+            )
+
+        template.render.assert_called_once()
+        passed_context = template.render.call_args.args[0]
+        assert passed_context["component_html"] == htmy_html
+        assert passed_context["name"] == "Ada"
+
+    async def test_component_html_key_override(self) -> None:
+        """``component_html_key`` overrides the default ``"component_html"`` key."""
+        manager = HybridTemplatesManager()
+        manager.base_templates = MagicMock()
+
+        env = _stub_env("hybrid.html", "<html/>")
+        manager._get_template_environment = MagicMock(return_value=env)
+        template = env.get_template.return_value
+
+        def component_factory(**_kwargs: object) -> MagicMock:
+            return MagicMock()
+
+        with patch(
+            "fastblocks.adapters.templates._advanced_manager.HtmyRenderer"
+        ) as MockRenderer:
+            MockRenderer.return_value = _stub_htmy_renderer("<x/>")
+
+            await manager.render_hybrid(
+                jinja_template="hybrid.html",
+                htmy_component=component_factory,
+                context={"name": "Ada"},
+                component_html_key="card",
+            )
+
+        passed_context = template.render.call_args.args[0]
+        assert "card" in passed_context
+        assert passed_context["card"] == "<x/>"
+        assert "component_html" not in passed_context
+
+    async def test_passes_context_kwargs_to_component_factory(self) -> None:
+        """The framework invokes ``htmy_component(**context)`` with the context dict."""
+        manager = HybridTemplatesManager()
+        manager.base_templates = MagicMock()
+        manager._get_template_environment = MagicMock(
+            return_value=_stub_env("hybrid.html", "<html/>")
+        )
+
+        captured: dict[str, object] = {}
+
+        def component_factory(**kwargs: object) -> MagicMock:
+            captured.update(kwargs)
+            return MagicMock()
+
+        with patch(
+            "fastblocks.adapters.templates._advanced_manager.HtmyRenderer"
+        ) as MockRenderer:
+            MockRenderer.return_value = _stub_htmy_renderer("<x/>")
+
+            await manager.render_hybrid(
+                jinja_template="hybrid.html",
+                htmy_component=component_factory,
+                context={"name": "Ada", "avatar": "/a.png"},
+            )
+
+        assert captured == {"name": "Ada", "avatar": "/a.png"}
+
+    async def test_none_context_treated_as_empty_dict(self) -> None:
+        """``context=None`` is equivalent to ``context={}``."""
+        manager = HybridTemplatesManager()
+        manager.base_templates = MagicMock()
+
+        env = _stub_env("hybrid.html", "<html/>")
+        manager._get_template_environment = MagicMock(return_value=env)
+        template = env.get_template.return_value
+
+        def component_factory(**_kwargs: object) -> MagicMock:
+            return MagicMock()
+
+        with patch(
+            "fastblocks.adapters.templates._advanced_manager.HtmyRenderer"
+        ) as MockRenderer:
+            MockRenderer.return_value = _stub_htmy_renderer("<x/>")
+
+            await manager.render_hybrid(
+                jinja_template="hybrid.html",
+                htmy_component=component_factory,
+                context=None,
+            )
+
+        passed_context = template.render.call_args.args[0]
+        assert passed_context == {"component_html": "<x/>"}
+
+    async def test_resolves_template_via_manager_env(self) -> None:
+        """The Jinja2 template path is resolved via ``env.get_template``."""
+        manager = HybridTemplatesManager()
+        manager.base_templates = MagicMock()
+        env = _stub_env("greeting/hybrid.html", "<html/>")
+        manager._get_template_environment = MagicMock(return_value=env)
+
+        def component_factory(**_kwargs: object) -> MagicMock:
+            return MagicMock()
+
+        with patch(
+            "fastblocks.adapters.templates._advanced_manager.HtmyRenderer"
+        ) as MockRenderer:
+            MockRenderer.return_value = _stub_htmy_renderer("<x/>")
+
+            await manager.render_hybrid(
+                jinja_template="greeting/hybrid.html",
+                htmy_component=component_factory,
+                context={},
+            )
+
+        env.get_template.assert_called_once_with("greeting/hybrid.html")
+
+    async def test_component_factory_exception_surfaces_as_template_error(self) -> None:
+        """If the HTMY component factory raises, ``render_hybrid`` raises ``TemplateError``."""
+        from jinja2 import TemplateError
+
+        manager = HybridTemplatesManager()
+        manager.base_templates = MagicMock()
+        manager._get_template_environment = MagicMock(
+            return_value=_stub_env("hybrid.html", "<html/>")
+        )
+
+        def failing_factory(**_kwargs: object) -> MagicMock:
+            raise RuntimeError("bad props")
+
+        with patch(
+            "fastblocks.adapters.templates._advanced_manager.HtmyRenderer"
+        ) as MockRenderer:
+            MockRenderer.return_value = _stub_htmy_renderer("<x/>")
+
+            with pytest.raises(TemplateError, match="HTMY component factory failed"):
+                await manager.render_hybrid(
+                    jinja_template="hybrid.html",
+                    htmy_component=failing_factory,
+                    context={},
+                )
+
+    async def test_htmy_renderer_exception_surfaces_as_template_error(self) -> None:
+        """If ``htmy.Renderer().render`` raises, ``render_hybrid`` raises ``TemplateError``."""
+        from jinja2 import TemplateError
+
+        manager = HybridTemplatesManager()
+        manager.base_templates = MagicMock()
+        manager._get_template_environment = MagicMock(
+            return_value=_stub_env("hybrid.html", "<html/>")
+        )
+
+        failing_renderer = MagicMock()
+        failing_renderer.render = AsyncMock(
+            side_effect=RuntimeError("renderer crashed")
+        )
+
+        def component_factory(**_kwargs: object) -> MagicMock:
+            return MagicMock()
+
+        with patch(
+            "fastblocks.adapters.templates._advanced_manager.HtmyRenderer"
+        ) as MockRenderer:
+            MockRenderer.return_value = failing_renderer
+
+            with pytest.raises(TemplateError, match="HTMY renderer failed"):
+                await manager.render_hybrid(
+                    jinja_template="hybrid.html",
+                    htmy_component=component_factory,
+                    context={},
+                )
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+class TestRenderHybridRequiresTemplates:
+    """``render_hybrid`` needs a wired Jinja2 environment."""
+
+    async def test_no_base_templates_raises(self) -> None:
+        """Without ``base_templates``, ``_get_template_environment`` raises ``RuntimeError``."""
+        manager = HybridTemplatesManager()
+        # base_templates is None; ``_get_template_environment`` must raise.
+        # Patch ``HtmyRenderer`` so the test does not actually call into
+        # HTMY with a MagicMock component (which would hang).
+        with patch(
+            "fastblocks.adapters.templates._advanced_manager.HtmyRenderer"
+        ) as MockRenderer:
+            MockRenderer.return_value = _stub_htmy_renderer("<x/>")
+            with pytest.raises(RuntimeError, match="Base templates not initialized"):
+                await manager.render_hybrid(
+                    jinja_template="hybrid.html",
+                    htmy_component=lambda **_k: MagicMock(),
+                    context={},
+                )
\ No newline at end of file
