From b4bf470abd4f0e66e5003891a558a0fe7e694efc Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Fri, 31 Jul 2026 23:57:45 -0400 Subject: [PATCH 1/4] fix(notification): render body content, in every theme and both engines (#65) Notification was the one container-shaped component in the set that did not accept a body. Every theme rendered the scalar `message` and nothing else, so the natural container form produced a correctly styled, correctly coloured, empty box. The two engines failed differently and the cotton one was the dangerous half. Cotton's `` carried no default, so `message` resolved to the empty string and the box rendered silently. JinjaX's `message` was a required `{#def}` parameter, so a body-only call raised MissingRequiredArgument instead. Both now render the body when present and fall back to `message` when it is not. Existing `message=` callers are untouched and the JinjaX signature only loosens, so this is backward compatible in both directions. The escaping contract is asserted rather than inherited: JinjaX wraps slot content in `Markup`, so a body passes through each template's `{% autoescape true %}` block untouched, while `message` stays caller-supplied text and is still escaped. Both halves are tested per theme. tests/unit/test_notification_body.py adds 67 tests across the five themes: body renders, `message` still renders, body wins when both are given (and the loser is asserted absent), the JinjaX slot is a real slot, and two drift guards so a new theme cannot ship a Notification that ignores its body. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf --- CHANGELOG.md | 24 ++ docs/components.md | 6 +- .../_themes/bootstrap/notification.html | 4 +- .../cotton/_themes/bulma/notification.html | 4 +- .../cotton/_themes/daisy/notification.html | 4 +- .../cotton/_themes/fomantic/notification.html | 4 +- .../_themes/foundation/notification.html | 4 +- .../templates/cotton/cf/notification.html | 5 +- .../jinja/bootstrap/Notification.jinja | 10 +- .../templates/jinja/bulma/Notification.jinja | 10 +- .../templates/jinja/daisy/Notification.jinja | 10 +- .../jinja/fomantic/Notification.jinja | 10 +- .../jinja/foundation/Notification.jinja | 10 +- tests/unit/test_notification_body.py | 252 ++++++++++++++++++ 14 files changed, 340 insertions(+), 17 deletions(-) create mode 100644 tests/unit/test_notification_body.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 834a6b4..00d155b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## [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. +- **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. + ## [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..e9c50d6 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 %}{{ 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 %}{{ 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..62028f7 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 %}{{ 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 %}{{ slot }}{% else %}{{ message }}{% endif %}
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/notification.html b/src/cf_ui/templates/cotton/_themes/foundation/notification.html index 37cc95a..e7818eb 100644 --- a/src/cf_ui/templates/cotton/_themes/foundation/notification.html +++ b/src/cf_ui/templates/cotton/_themes/foundation/notification.html @@ -10,7 +10,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 %}{{ slot }}{% else %}{{ message }}{% endif %}

{% if dismissible == "true" %} {% 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 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..7f1bfe4 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 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 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..421268f 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,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 else message }}

{% if dismissible %}