Skip to content

fix(notification): render body content, in every theme and both engines (#65) - #66

Merged
fsecada01 merged 4 commits into
masterfrom
fix/component-framework-ui-65-notification-slot
Aug 2, 2026
Merged

fix(notification): render body content, in every theme and both engines (#65)#66
fsecada01 merged 4 commits into
masterfrom
fix/component-framework-ui-65-notification-slot

Conversation

@fsecada01

@fsecada01 fsecada01 commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes #65.

Notification was the only container-shaped component in the set that ignored body content. Every theme, both engines, rendered the scalar message prop and nothing else — so <c-cf.notification type="danger">{{ error }}</c-cf.notification> produced a correctly styled, correctly coloured, empty box. In cotton this failed silently; in JinjaX it raised MissingRequiredArgument, because message was a required {#def} parameter with nowhere for a body to land.

What changed

Ten templates — five themes × two engines — plus the cotton wrapper.

Cotton partials now render the body when one is present and fall back to message when it is not, each wrapped in that theme's own element (bulma bare, bootstrap/daisy <span>, fomantic <div class="content">, foundation <p>). The whole conditional stays on one physical line: the Django template language has no whitespace-control syntax, and djLint's reformat pass must not be given a chance to introduce whitespace that changes rendered bytes.

The cotton wrapper gains message="" in its <c-vars>. Without a default, django-cotton resolved a body-form call's message to the empty string — which is precisely how this rendered an empty box instead of erroring.

Jinja templates declare content="" and demote message to optional, so a body-only call is now valid. The signature only loosens; no existing call form changes.

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. A naive {% if slot %} would therefore suppress message for any caller writing a conditional body, which is this same bug with a new trigger. Both engines treat a whitespace-only body as absent (slot.strip / content|trim).

Foundation's <p> wraps the message, not the body

Broadening the content channel made an existing wrapper wrong. Foundation put the scalar in <p>{{ message }}</p>, which was correct while only a string could land there. A body is arbitrary markup, and the HTML parser closes an open paragraph on block content — verified in Chromium, not assumed: <div class="callout"><p><ul>…</ul></p></div> parses to <div class="callout"><p></p><ul>…</ul><p></p></div>, reparenting the body onto .callout and leaving two empty paragraphs for Foundation's own .callout > :first-child / > :last-child margin rules to match.

The <p> now wraps message alone, so the scalar form renders byte-identically to before. The same probe cleared the other four: fomantic's <div class="content"> and bootstrap/daisy's <span> keep a <ul> nested where written, and bulma has no wrapper. Only <p> restructures.

Escaping

The two operands of {{ content if content else message }} want opposite treatment and get it. 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.

Tests

tests/unit/test_notification_body.py — 67 tests covering the issue's four suggested cases across every theme and both engines, plus a drift guard asserting every shipped notification template references its body channel.

tests/e2e/test_bulma_cotton.py — three call forms through the real compiler. This tier exists in the PR deliberately: the bug survived three months because no tier that runs the django-cotton compiler looked at this component's content channel. The unit tier injects slot as raw context, and the integration tier never installs django_cotton at all, so its <c-cf.*> tags reach the response as literal text and its assertions pass on Django variable interpolation alone. A fix for #65 should not be validated only by the tiers that missed #65.

Both halves were mutation-checked: reverting the bulma partial to bare {{ message }} reddens only the body test; weakening slot.strip to slot reddens only the whitespace-fallback test. Neither mutation disturbed the other assertions.

Gates

2383 passed, 20 skipped (unit + integration) · 236 passed, 33 skipped (E2E, chromium) · ruff check/ruff format --check src tests clean · prek run --all-files clean, both djLint trees included.

Note on scope

Docs (docs/components.md) gained the content prop row and the escaping note. No consumer migration is required — this is purely additive to the prop contract.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf

…es (#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 `<c-vars message ...>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
@fsecada01 fsecada01 added the bug Something isn't working label Aug 1, 2026
Two defects in the previous commit, both found by probing the real engines
rather than reasoning about them.

A naive `{% if slot %}` reintroduces #65 with a new trigger. django-cotton's
CottonComponentNode.render sets `"slot": self.nodelist.render(context)` —
verbatim, unstripped — so a paired tag whose body renders empty still hands
the partial `"\n  "`, which is truthy. The plausible call is a conditional
body:

    <c-cf.notification message="Nothing to report">
      {% if error %}{{ error }}{% endif %}
    </c-cf.notification>

On the false branch that dropped `message` and rendered a styled empty box,
silently. Confirmed against the real cotton compiler before fixing, since
`render_to_string` bypasses it. Cotton now tests `slot.strip`; JinjaX already
strips its slot, but the Jinja side uses `content|trim` so the two engines
cannot disagree about what counts as a body.

The drift guard was vacuous and two substring needles failed to fix it.
`"content" in src` matches fomantic's `<div class="content">` wrapper, and the
tightened `"content if content"` still matches the
`{% set content = content if content is defined %}` StrictUndefined guard every
theme carries — both passed on a fomantic template with the fallback
expression deleted. It now renders instead of grepping, and enumerates themes
off the filesystem rather than from the hand-written THEMES list, so a sixth
theme fails the list check instead of silently escaping every parametrized
test in the file.

Both new guards were mutation-tested: stripping `slot.strip`/`|trim` turns all
ten whitespace tests red, and deleting fomantic's fallback expression turns the
render guard red on that theme alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
@fsecada01 fsecada01 self-assigned this Aug 2, 2026
fsecada01 and others added 2 commits August 1, 2026 20:21
#65)

The unit suite proves the ten templates render a body, but it cannot prove
django-cotton builds `slot` the way the fix assumes: `render_to_string`
bypasses the compiler, so `slot` is whatever the test injected. The
integration tier is no better — its settings module never installs
`django_cotton`, so `<c-cf.*>` tags reach the response as literal text and
its existing assertions pass on Django variable interpolation alone.

That gap is why #65 went unnoticed for three months, so the fix does not get
to rely on the same tiers. Adds a `/notification/` gallery page exercising
all three cotton call forms and asserts them in E2E, where cotton itself
builds the slot:

- the body form renders its body
- the `message=` form still renders (no regression)
- a conditional body on its false branch falls back to `message` rather
  than emitting a styled empty box

Both halves of the fix were mutation-checked against these: reverting the
bulma partial to bare `{{ message }}` reddens only the body test, and
weakening `slot.strip` to `slot` reddens only the whitespace-fallback test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
)

Broadening notification's content channel made an existing wrapper wrong.
Foundation put the scalar `message` in a `<p>`, which was fine while the only
thing that could land there was a string. A body is arbitrary markup, and the
HTML parser does not tolerate block content inside an open paragraph — it
closes it. Verified in Chromium:

  <div class="callout"><p><ul><li>Item</li></ul></p></div>
    parses to
  <div class="callout"><p></p><ul><li>Item</li></ul><p></p></div>

The body is reparented onto `.callout` and two empty paragraphs remain, which
Foundation's own `.callout > :first-child` / `> :last-child` margin rules then
match instead of the content. No attribute or stylesheet can prevent it.

The `<p>` now wraps only `message`, so the scalar form renders byte-identically
to before and a body is emitted where the caller wrote it. Fomantic's
`<div class="content">` and bootstrap/daisy's `<span>` both keep a `<ul>`
nested where written (same probe), so they are unchanged.

The two new tests assert the emitted markup rather than the parsed DOM, so they
run at the unit tier in both engines; a third derives the wrapper list from the
shipped templates so a sixth theme choosing `<p>` cannot silently disable them.
Restoring the `<p>` around the body reddens exactly those two and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
@fsecada01

Copy link
Copy Markdown
Owner Author

Adversarial review — PR #66

Scope: the full PR diff (14 files, 5 themes × 2 engines + tests + docs + changelog).

One finding, confirmed and fixed in 8cb0994

Foundation's <p> could not hold the thing this PR just made it able to receive. Foundation wrapped the scalar in <p>{{ message }}</p>, and the first version of this fix widened that same <p> to hold the slot. A body is arbitrary markup, and the HTML parser closes an open paragraph on encountering block content. Verified in Chromium rather than asserted:

<div class="callout"><p><ul><li>Item</li></ul></p></div>
  parses to
<div class="callout"><p></p><ul><li>Item</li></ul><p></p></div>

The body is reparented onto .callout and two empty paragraphs are left behind — which Foundation's own .callout > :first-child / > :last-child margin rules then match instead of the content. No attribute or stylesheet prevents it; it is a parsing rule.

This is a defect the PR introduces, not a pre-existing one: while <p> could only ever hold a string, it was correct. Broadening the channel is what makes the wrapper wrong, which is exactly the class of thing worth catching before merge rather than in a consumer.

The same probe cleared the other four themes: fomantic's <div class="content"> and bootstrap/daisy's <span> both keep a <ul> nested where it was written, and bulma has no wrapper at all. Only <p> restructures.

Fixed by wrapping message alone — the scalar form renders byte-identically to before, and a body is emitted where the caller wrote it. Covered by test_{jinja,cotton}_body_is_not_wrapped_in_a_paragraph, plus a third test that derives the wrapper list from the shipped templates so a sixth theme choosing <p> cannot silently disable the first two. Restoring the <p> around the body reddens exactly those two tests and nothing else.

Checked and clear

The whitespace guard is load-bearing and correct. {% if slot.strip %} / {{ content if content|trim else message }}. django-cotton sets "slot": self.nodelist.render(context) verbatim, so a paired tag whose body renders to nothing still hands over "\n " — truthy. A naive {% if slot %} would drop message on the false branch of a conditional body, which is #65 again with a new trigger. Mutation-confirmed: weakening slot.strip to slot reddens only the whitespace-fallback tests.

The escaping asymmetry is right in both directions. {{ content if content|trim else message }} gives its two operands opposite treatment inside one {% autoescape true %} block — correct, because they are not alike: JinjaX wraps slot content in Markup so it passes through untouched, while message is caller-supplied text. Both halves asserted per theme, including that a message containing <script> still comes out escaped. Widening the body channel did not widen the text channel.

Backward compatibility holds on both engines. The JinjaX signature only loosens (message required → optional); the cotton wrapper only gains a default. No existing call form changes shape. The message=-form regression tests cover all five themes on all three render paths.

Django template-lexer constraints respected. The conditional stays on one physical line in every cotton partial — the DTL has no whitespace-control syntax, and djLint's reformat pass must not get a chance to insert whitespace that changes rendered bytes. Rationale comments use {% comment %}, not {# #}, which is single-line only. prek run --all-files is clean with both djLint trees included.

The content row in docs/components.md names a JinjaX prop, not a cotton one — in cotton the channel is the body, and content="x" would be silently discarded as an undeclared attribute. That is not a defect here: it matches the existing Cf:Modal row verbatim, it is the package-wide table convention for the slot channel, and the row's own text ("Body — the slot in template position") is the disambiguation. Noted rather than filed.

On the E2E tier in this PR

The three cotton call forms are asserted in E2E deliberately, and only for bulma. That is not thin coverage — the thing E2E adds here is whether django-cotton builds slot the way the fix assumes, which is theme-independent (the wrapper dispatches to a partial; the compiler does not know which). Per-theme typos in the conditional are what the 85 unit tests catch, on all five.

Worth stating plainly, because it is the actual lesson of #65: the bug survived three months in a real consumer because no tier that ran the compiler ever looked at this component's content channel. The unit tier injects slot as raw context, and the integration tier does not install django_cotton at all — its <c-cf.*> tags reach the response as literal text, and its existing assertions pass on Django variable interpolation alone. A fix for #65 should not be validated only by the tiers that missed #65.

Gates

2386 passed, 20 skipped (unit + integration) · 236 passed, 33 skipped (E2E, chromium, both js modes) · ruff check / ruff format --check src tests clean · prek run --all-files clean.

One note on the local toolchain, not on this diff: the ruff on PATH here is 0.6.4 and disagrees with the repo about formatting 10 files. The authoritative ones are prek's pinned v0.16.0 and .venv/Scripts/ruff.exe (0.16.1), which agree with each other and with CI. Same shape as the drift recorded in .pre-commit-config.yaml's comment, one layer out.

@fsecada01
fsecada01 merged commit b0b4a86 into master Aug 2, 2026
7 checks passed
@fsecada01
fsecada01 deleted the fix/component-framework-ui-65-notification-slot branch August 2, 2026 00:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Notification silently discards body content in every theme and both engines

1 participant