diff --git a/examples/landing/routes/demo.py b/examples/landing/routes/demo.py
index 207720b..caf5680 100644
--- a/examples/landing/routes/demo.py
+++ b/examples/landing/routes/demo.py
@@ -1,43 +1,53 @@
 """Demo route — search-as-you-type HTMX pattern.
 
 Mirrors the B1 starter's ``routes/demo.py`` (REQ-P2-B1-005). The route
 serves a full page on a normal GET and an HTMX fragment when the
 ``HX-Request`` header is present, demonstrating the dual-response pattern
 that fastblocks-ui apps use to keep server logic simple.
 
+On the HTMX swap branch, the route also emits an ``HX-Trigger`` response
+header carrying a JSON payload keyed by ``demo-search-completed`` so
+client-side listeners can observe the swap with the original query
+(F1.5-D4-T1).
+
 # req: REQ-P2-B2-001
 """
 from __future__ import annotations
 
+import json
+
 from fastblocks_ui import field, text_input
 from starlette.requests import Request
 from starlette.responses import HTMLResponse
-
 from templates import render_template
 
 _PARTIAL_TEMPLATE = "partials/results.html"
 _PAGE_TEMPLATE = "demo.html"
 
 
 async def demo_route(request: Request) -> HTMLResponse:
     """Search-as-you-type HTMX demo."""
     q = request.query_params.get("q", "")
     results = _fake_search(q) if q else []
     context = {
         "field": field(label="Search", input=text_input(name="q", value=q)),
         "results": results,
     }
     if request.headers.get("HX-Request"):
-        return HTMLResponse(
+        response = HTMLResponse(
             await render_template(request, _PARTIAL_TEMPLATE, context)
         )
+        response.headers["HX-Trigger"] = json.dumps(
+            {"demo-search-completed": {"query": q}}
+        )
+        return response
     return HTMLResponse(await render_template(request, _PAGE_TEMPLATE, context))
 
 
 def _fake_search(q: str) -> list[str]:
     """Placeholder search — replace with real query in consumer app."""
     corpus = [
         "alpha",
         "beta",
         "gamma",
         "delta",
diff --git a/tests/htmx/test_hx_trigger_emission.py b/tests/htmx/test_hx_trigger_emission.py
new file mode 100644
index 0000000..629153a
--- /dev/null
+++ b/tests/htmx/test_hx_trigger_emission.py
@@ -0,0 +1,99 @@
+"""F1.5-D4-T1: assert ``/demo`` emits ``HX-Trigger`` header on HTMX swap.
+
+The landing ``/demo`` endpoint serves an HTMX fragment when the request
+carries ``HX-Request: true``. Beyond the body swap, the route should
+emit an ``HX-Trigger`` response header so client-side listeners can
+fire on ``demo-search-completed`` with the query payload. This test
+pins that contract end-to-end through a real Starlette ``TestClient``.
+
+The app is bootstrapped from ``examples/landing/main.py`` so the test
+exercises the actual route handler, not a synthetic stand-in. To keep
+the test independent of pytest's working directory, we ``chdir`` into
+the example's root before importing the app — Oneiric's settings
+loader resolves ``settings/app.yaml`` from CWD.
+
+# req: REQ-P2-B2-001
+"""
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+from starlette.testclient import TestClient
+
+_EXAMPLES_LANDING = (
+    Path(__file__).resolve().parents[2] / "examples" / "landing"
+)
+
+
+@pytest.fixture
+def landing_app(monkeypatch: pytest.MonkeyPatch):
+    """Import the real landing FastBlocks app with Oneiric settings."""
+    monkeypatch.chdir(_EXAMPLES_LANDING)
+    if str(_EXAMPLES_LANDING) not in sys.path:
+        sys.path.insert(0, str(_EXAMPLES_LANDING))
+    # Reload the module fresh so any prior import (e.g. from a sibling
+    # test) does not leak cached adapter state into this test.
+    for name in list(sys.modules):
+        if name == "main" or name.startswith("main."):
+            monkeypatch.delitem(sys.modules, name)
+    from main import app
+
+    return app
+
+
+@pytest.fixture
+def client(landing_app):
+    """Starlette TestClient wrapping the landing demo app."""
+    return TestClient(landing_app)
+
+
+def test_demo_swap_emits_hx_trigger_header(client) -> None:
+    """``/demo`` HTMX swap emits ``HX-Trigger`` with the query payload."""
+    response = client.get("/demo?q=test", headers={"HX-Request": "true"})
+
+    assert response.status_code == 200, (
+        f"F1.5-D4-T1: /demo returned {response.status_code}, expected 200"
+    )
+    assert "HX-Trigger" in response.headers, (
+        "F1.5-D4-T1: HX-Trigger header missing from /demo swap response. "
+        "Per the integration contract, HTMX fragments must emit "
+        "demo-search-completed via HX-Trigger so client-side listeners can "
+        f"observe the swap. Response headers: {dict(response.headers)!r}"
+    )
+
+
+def test_demo_swap_hx_trigger_payload_is_json(client) -> None:
+    """``HX-Trigger`` body is a JSON object keyed by event name."""
+    response = client.get("/demo?q=test", headers={"HX-Request": "true"})
+
+    raw = response.headers.get("HX-Trigger")
+    assert raw is not None, "F1.5-D4-T1: HX-Trigger missing (see other test)"
+    payload = json.loads(raw)
+    assert "demo-search-completed" in payload, (
+        "F1.5-D4-T1: HX-Trigger payload missing 'demo-search-completed' "
+        f"event key (got keys={list(payload)!r})"
+    )
+    assert payload["demo-search-completed"].get("query") == "test", (
+        "F1.5-D4-T1: HX-Trigger payload should carry the echoed query "
+        f"(got query={payload['demo-search-completed'].get('query')!r})"
+    )
+
+
+def test_demo_full_page_does_not_emit_hx_trigger(client) -> None:
+    """Non-HTMX GET to ``/demo`` does NOT emit ``HX-Trigger``.
+
+    The trigger event is meaningful only on the HTMX fragment response.
+    On a full-page load there is no swap listener to fire it, so emitting
+    the header would be noise.
+    """
+    response = client.get("/demo?q=test")
+
+    assert response.status_code == 200
+    assert "HX-Trigger" not in response.headers, (
+        "F1.5-D4-T1: HX-Trigger leaked into the full-page response. "
+        "Only the HTMX swap (HX-Request: true) should emit it. "
+        f"Headers: {dict(response.headers)!r}"
+    )
\ No newline at end of file
