diff --git a/CHANGELOG.md b/CHANGELOG.md
index 834a6b4..5eb9ad5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,54 @@
## [Unreleased]
+### Fixed — `Notification` silently discarded its body content (#65)
+
+- **Every theme, both engines, rendered the scalar `message` prop and nothing
+ else.** A caller writing the natural container form —
+ `{{ error }}` — got a
+ correctly styled, correctly coloured, **empty** box. In cotton this failed
+ silently: `` carried no default, so `message` resolved to
+ the empty string and the box rendered anyway. In JinjaX it failed loudly but
+ wrongly — `message` was a required `{#def}` parameter, so a body-only call
+ raised `MissingRequiredArgument`. Both paths now render the body when one is
+ present and fall back to `message` when it is not. `message=` callers are
+ untouched; the JinjaX signature only loosens.
+- `Notification` was the single outlier among the container-shaped components.
+ `card`, `modal`, `panel`, `prose` and `box` have always accepted a body
+ alongside their scalar props; the bodiless components (`breadcrumb`,
+ `pagination`, `table`, `progress`) are all data-driven, where children would
+ be meaningless. `Notification` is a container that happened to expose only a
+ string.
+- **A body that renders to nothing is not a body.** django-cotton hands the
+ partial `nodelist.render(context)` verbatim, so a paired tag whose body
+ renders empty still supplies `"\n "` — truthy. Without a guard, a `message=`
+ caller writing a conditional body would get an empty box on the false branch,
+ which is this same bug with a new trigger. Both engines now treat a
+ whitespace-only body as absent and fall back to `message`.
+- **The two operands want opposite escaping and now get it, under test.** JinjaX
+ wraps slot content in `Markup`, so a body passes through the template's
+ `{% autoescape true %}` block untouched; `message` is caller-supplied text and
+ is still escaped. Both halves are asserted per theme rather than left to
+ autoescape semantics.
+- **Foundation's `
` now wraps only `message`, not the body.** A body is
+ arbitrary markup, and the HTML parser closes an open `
` on encountering
+ block content: `
…
` parses to `
…
`, which
+ reparents the body onto `.callout` and leaves two empty paragraphs for
+ Foundation's own `.callout > :first-child` / `> :last-child` margin rules to
+ match. Broadening the content channel is what made that reachable — before
+ this, the `
` only ever held a string. `message=` callers render
+ byte-identically to before. Fomantic's `
` and
+ bootstrap/daisy's `` hold block content without being restructured, and
+ are unchanged.
+- **Covered at the tier that could have caught it.** The bug survived three
+ months because no tier that ran the django-cotton compiler looked at this
+ component's content channel: the unit tier injects `slot` as raw context
+ (`render_to_string` bypasses the compiler), and the integration tier does not
+ install `django_cotton` at all, so its `` tags reach the response as
+ literal text. The three cotton call forms — body, `message=`, and a
+ conditional body on its false branch — are now asserted in the E2E tier,
+ where `slot` is built by cotton itself.
+
## [0.3.0] — 2026-07-31
The primitives layer. 0.2.0 shipped 14 *structural* components — card, modal,
diff --git a/docs/components.md b/docs/components.md
index 29fbf49..e9e3bdf 100644
--- a/docs/components.md
+++ b/docs/components.md
@@ -146,11 +146,15 @@ Focus trapping, focus restoration, and `Escape`-to-close live in
| Prop | Default | Notes |
|---|---|---|
-| `message` | *required* | |
+| `content` | `""` | Body — the slot in template position; wins over `message` |
+| `message` | `""` | Scalar alternative to the body, for a plain string |
| `type` | `"info"` | `info`, `success`, `warning`, `danger` |
| `dismissible` | `true` | Renders a close button |
| `extra_class` | `""` | |
+Supply one or the other. A body passed as slot content is already-rendered
+markup and is not escaped again; `message` is caller-supplied text and is.
+
### `Cf:Progress` / ``
| Prop | Default | Notes |
diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html b/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html
index dc367dd..005551f 100644
--- a/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html
+++ b/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html
@@ -4,7 +4,9 @@
x-data="{ visible: true }"
x-show="visible"
>
- {{ message }}
+ {% comment %} Body content wins over the scalar `message` — a caller who wrote children
+ meant them. `message` stays for the existing call form (#65). {% endcomment %}
+ {% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %}
{% if dismissible == "true" %}
{% endif %}
- {{ message }}
+ {% comment %} Body content wins over the scalar `message` — a caller who wrote children
+ meant them. `message` stays for the existing call form (#65). {% endcomment %}
+ {% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %}
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/notification.html b/src/cf_ui/templates/cotton/_themes/daisy/notification.html
index 6646223..e7036f1 100644
--- a/src/cf_ui/templates/cotton/_themes/daisy/notification.html
+++ b/src/cf_ui/templates/cotton/_themes/daisy/notification.html
@@ -4,7 +4,9 @@
x-data="{ visible: true }"
x-show="visible"
>
- {{ message }}
+ {% comment %} Body content wins over the scalar `message` — a caller who wrote children
+ meant them. `message` stays for the existing call form (#65). {% endcomment %}
+ {% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %}
{% if dismissible == "true" %}
{% endif %}
-
{{ message }}
+ {% comment %} Body content wins over the scalar `message` — a caller who wrote children
+ meant them. `message` stays for the existing call form (#65). {% endcomment %}
+
+ {% comment %} Body content wins over the scalar `message` — a caller who wrote children
+ meant them. `message` stays for the existing call form (#65).
+
+ The `
+
+` wraps only `message`. A body is arbitrary markup, and the HTML
+ parser closes an open `
+
+` on encountering block content: `
+
+
+…
+
+
+`
+ parses to `
+
+
+
+…
+
+
+
+`, reparenting the body onto `.callout` and
+ leaving two empty paragraphs behind for `.callout > :last-child` to match.
+ `message` is text, so its wrapper renders byte-identically to before. {% endcomment %}
+ {% if slot.strip %}{{ slot }}{% else %}
{{ message }}
{% endif %}
{% if dismissible == "true" %}
+{% comment %} `message` now carries a default. Without one, django-cotton resolved a
+ body-form call's `message` to the empty string and rendered a correctly
+styled, correctly coloured, empty box — silently (#65). {% endcomment %}
+
{% load cf_ui %}
{% cf_ui_theme_path "notification" as cf_ui_partial %}
{% include cf_ui_partial %}
diff --git a/src/cf_ui/templates/jinja/bootstrap/Notification.jinja b/src/cf_ui/templates/jinja/bootstrap/Notification.jinja
index b2f7cfd..edcb45f 100644
--- a/src/cf_ui/templates/jinja/bootstrap/Notification.jinja
+++ b/src/cf_ui/templates/jinja/bootstrap/Notification.jinja
@@ -1,5 +1,7 @@
-{#def message, type="info", dismissible=true, extra_class="" #}
+{#def content="", message="", type="info", dismissible=true, extra_class="" #}
{% autoescape true %}
+ {% set content = content if content is defined else "" %}
+ {% set message = message if message is defined else "" %}
{% set type = type if type is defined else "info" %}
{% set dismissible = dismissible if dismissible is defined else true %}
{% set extra_class = extra_class if extra_class is defined else "" %}
@@ -9,7 +11,11 @@
x-data="{ visible: true }"
x-show="visible"
>
- {{ message }}
+ {# Body content wins over the scalar `message` — a caller who wrote children
+ meant them. The two operands want opposite escaping and get it: JinjaX
+ wraps slot content in `Markup`, so it passes through this block
+ untouched, while `message` is caller-supplied text and is escaped. #}
+ {{ content if content|trim else message }}
{% if dismissible %}
{% endif %}
- {{ message }}
+ {# Body content wins over the scalar `message` — a caller who wrote children
+ meant them. The two operands want opposite escaping and get it: JinjaX
+ wraps slot content in `Markup`, so it passes through this block
+ untouched, while `message` is caller-supplied text and is escaped. #}
+ {{ content if content|trim else message }}
{% endautoescape %}
diff --git a/src/cf_ui/templates/jinja/daisy/Notification.jinja b/src/cf_ui/templates/jinja/daisy/Notification.jinja
index 925e135..c6c04dd 100644
--- a/src/cf_ui/templates/jinja/daisy/Notification.jinja
+++ b/src/cf_ui/templates/jinja/daisy/Notification.jinja
@@ -1,5 +1,7 @@
-{#def message, type="info", dismissible=true, extra_class="" #}
+{#def content="", message="", type="info", dismissible=true, extra_class="" #}
{% autoescape true %}
+ {% set content = content if content is defined else "" %}
+ {% set message = message if message is defined else "" %}
{% set type = type if type is defined else "info" %}
{% set dismissible = dismissible if dismissible is defined else true %}
{% set extra_class = extra_class if extra_class is defined else "" %}
@@ -9,7 +11,11 @@
x-data="{ visible: true }"
x-show="visible"
>
- {{ message }}
+ {# Body content wins over the scalar `message` — a caller who wrote children
+ meant them. The two operands want opposite escaping and get it: JinjaX
+ wraps slot content in `Markup`, so it passes through this block
+ untouched, while `message` is caller-supplied text and is escaped. #}
+ {{ content if content|trim else message }}
{% if dismissible %}
{% endif %}
-
{{ message }}
+ {# Body content wins over the scalar `message` — a caller who wrote children
+ meant them. The two operands want opposite escaping and get it: JinjaX
+ wraps slot content in `Markup`, so it passes through this block
+ untouched, while `message` is caller-supplied text and is escaped. #}
+
{{ content if content|trim else message }}
{% endautoescape %}
diff --git a/src/cf_ui/templates/jinja/foundation/Notification.jinja b/src/cf_ui/templates/jinja/foundation/Notification.jinja
index 935101b..1ba11ef 100644
--- a/src/cf_ui/templates/jinja/foundation/Notification.jinja
+++ b/src/cf_ui/templates/jinja/foundation/Notification.jinja
@@ -1,5 +1,7 @@
-{#def message, type="info", dismissible=true, extra_class="" #}
+{#def content="", message="", type="info", dismissible=true, extra_class="" #}
{% autoescape true %}
+ {% set content = content if content is defined else "" %}
+ {% set message = message if message is defined else "" %}
{% set type = type if type is defined else "info" %}
{% set dismissible = dismissible if dismissible is defined else true %}
{% set extra_class = extra_class if extra_class is defined else "" %}
@@ -15,7 +17,18 @@
x-data="{ visible: true }"
x-show="visible"
>
-
{{ message }}
+ {# Body content wins over the scalar `message` — a caller who wrote children
+ meant them. The two operands want opposite escaping and get it: JinjaX
+ wraps slot content in `Markup`, so it passes through this block
+ untouched, while `message` is caller-supplied text and is escaped.
+
+ The `
` wraps only `message`. A body is arbitrary markup, and the HTML
+ parser closes an open `
` on encountering block content:
+ `
…
` parses to `
…
`, reparenting the
+ body onto `.callout` and leaving two empty paragraphs behind for
+ `.callout > :last-child` to match. `message` is text, so its wrapper
+ renders byte-identically to before. #}
+ {% if content|trim %}{{ content }}{% else %}