Skip to content

Theme: implement Bootstrap 5 (14 components, both template sets) - #29

Merged
fsecada01 merged 4 commits into
masterfrom
feat/component-framework-ui-phase-22-bootstrap-theme
Jul 30, 2026
Merged

Theme: implement Bootstrap 5 (14 components, both template sets)#29
fsecada01 merged 4 commits into
masterfrom
feat/component-framework-ui-phase-22-bootstrap-theme

Conversation

@fsecada01

Copy link
Copy Markdown
Owner

Implements the Bootstrap 5 theme: 14 components in both template sets, replacing the PLANNED.md stubs.

Closes #22

Scope checklist

  • templates/jinja/bootstrap/ — 14 JinjaX components (*.jinja) — Breadcrumb, Card, CheckboxGroup, FormField, Modal, Navbar, Notification, Pagination, Panel, Progress, Select, Table, Tabs, Textarea. All render under plain Jinja2 with StrictUndefined, so every optional prop carries its is defined guard.
  • templates/cotton/_themes/bootstrap/ — 14 cotton partials (*.html) — no <c-vars> anywhere; the prop contract stays on the untouched wrappers in cotton/cf/. Both PLANNED.md stubs are deleted, including the legacy cotton/bootstrap/ directory that predates the _themes/ dispatch from DaisyUI theme — implement component set #6.
  • Add "bootstrap" to THEMES in themes.py — last — it is the final source change in the branch; the theme was unreachable, and rejected loudly at startup, until every template existed.
  • Confirm assets.jinja has the Bootstrap CDN entry to match _CDN_CSS — verified, not assumed: both carry bootstrap at 5.3.3 and both point at bootstrap@{v}/dist/css/bootstrap.min.css. _DEFAULTS likewise. No change needed. Steps 4, 5 and 6 of CLAUDE.md's Adding a New Theme were already done (the bootstrap = [] extras entry exists too).
  • Add tests under tests/unit/jinja/ and tests/unit/cotton/test_bootstrap.py in each, mirroring the shape of the daisy suites.

Verification checklist

  • just check greenruff check src tests, ruff format --check src tests, and pytest tests/unit tests/integration (732 passed). node --test 65 passed. prek run --all-files run twice, clean both times with no rewrites.
  • pytest tests/unit/test_theme_dispatch.py -v covers bootstrapIMPLEMENTED_THEMES now has three entries, so every parametrized case picks it up automatically.
  • E2E renders every component under the new theme without errortests/e2e/test_bootstrap.py, 48 passed / 6 skipped, on new fixtures at ports 8775 (JinjaX) and 8776 (cotton). Full pytest tests/e2e also green: 129 passed, 19 skipped.
  • Modal focus management, tab active state, and ARIA behave identically to bulma/daisy — reusing the accessibility phase's assertions rather than writing weaker ones: tests/unit/test_accessibility.py now parametrizes over three themes instead of two, so every case in that module had to pass against Bootstrap markup unchanged.

The architectural constraint: CSS only, Alpine drives behaviour

No bootstrap.bundle.js, no data-bs-*. Bootstrap's own API is a second state owner for the modal, the tabs and the accordion, and cf_ui_alpine.js is already the first — loading both would make Alpine.store('cf').modal.open(id) mean something different under this theme than under every other one. This is asserted rather than left to prose: every component is checked for data-bs- in both the rendered output and the source, and the E2E suite reads back script[src] on the gallery page to prove no Bootstrap script is loaded at all.

Two places where "use Bootstrap's classes" was not sufficient on its own, both because the missing piece lived in the bundle we are not loading:

The modal reveal. .modal is display: none and Bootstrap's .show only sets opacity — its JS is what writes style.display = "block". A theme that toggled just .show would change the class list and never become visible. The reveal rides on d-block, a real Bootstrap utility, and the E2E test asserts to_be_visible() rather than a class so that failure mode cannot pass.

The modal backdrop. Bootstrap appends one to <body> at z-index 1050, below the modal's 1055. cf-ui has no JS to do that, so the backdrop has to live inside the modal — where the same 1050 would paint it over the dialog, since .modal opens a stacking context, and it would swallow every click. A negative z-index puts it back underneath. Because that is the least obvious decision in the diff, it has its own E2E test asserting both halves: a control inside the dialog is still clickable, and a backdrop click still closes.

The panel body carries no .collapse. That class is display: none without .show, which would hide a server-open panel permanently once Alpine is off — exactly the bug #21 fixed. x-show owns display, seeded from data-cf-open. The navbar does use .collapse, where the pure-CSS .collapse:not(.show) / .navbar-expand-lg .navbar-collapse pair is correct with or without JS; the E2E test asserts the computed display rather than the class list, following the lesson from the DaisyUI navbar fix.

Every class name was confirmed against the actual Bootstrap 5.3.3 stylesheet from the CDN — not from memory — including the .modal, .modal-backdrop, .collapse:not(.show), .navbar-expand-lg .navbar-collapse, .invalid-feedback and .is-invalid ~ .invalid-feedback rules that drove the decisions above.

Two smaller notes worth flagging

invalid-feedback d-block. Bootstrap's .invalid-feedback is display: none until an .is-invalid sibling precedes it. That works for the input in FormField/Select/Textarea, but a checkbox group has no such sibling, so the message would render invisibly. Every error slot in this theme carries d-block for consistency.

Progress is a div, not a <progress>. Bootstrap sizes the bar in percent, so the width has to be computed from value/max rather than emitted verbatim — {% widthratio %} on the Django side, a filter chain on the Jinja side. A bar hard-coded to value% would be wrong for any max != 100, so there is a test for max=4 and one for max=0.

One shared test I had to change, and why

test_switching_the_setting_switches_the_rendered_markup asserts that each theme renders differently. Bootstrap and DaisyUI genuinely spell a plain non-dismissible info alert the same way — alert alert-info — so the notification case collided. Rather than weaken the assertion to something pairwise-optional, the props bag now passes dismissible="true". That restates a <c-vars> default which render_to_string does not apply, so it makes the fixture more representative of a real render, and the themes diverge where they actually differ (btn-close versus DaisyUI's btn btn-sm btn-circle btn-ghost). The comment in the test records the reason.

Two tests in test_theme_dispatch.py used bootstrap as their example of a rejected stub theme; they now use foundation and fomantic, which are still stubs.

Merge conflict note

As the issue predicted, this touches exactly one shared source line — THEMES in themes.py. It also touches three shared test surfaces the Foundation and Fomantic phases will want: IMPLEMENTED_THEMES in test_theme_dispatch.py, THEMES + ACTIVE_TAB_CLASS in test_accessibility.py, and the _THEME_CSS / _THEME_EXTRA_HEAD maps plus E2E fixtures. All are one-line-per-theme additions.

tests/unit/test_tailwind_content.py needed no change: it is scoped to an explicit DAISY_TEMPLATES list rather than parametrized over THEMES, so Bootstrap — which ships prebuilt CSS and has no tree-shaking hazard — is correctly out of its scope, and daisy's assertions are untouched.

Follow-ups deliberately not folded in

  • No docs/bootstrap.md. DaisyUI needed one because of the Tailwind content-glob trap; Bootstrap has no build step, so the CSS-only constraint is documented in the README themes section and the CHANGELOG instead. A dedicated page can follow if the theme grows options.
  • The Navbar toggle carries aria-label and :aria-expanded but no aria-controls, matching the Bulma and DaisyUI navbars. Adding it properly needs an id prop on the wrapper's <c-vars>, which is a public prop change and out of scope here.

All 14 components in both template sets, replacing the PLANNED.md stubs.
`bootstrap` is now accepted by CF_UI_THEME and install_cf_ui(theme=...);
_CDN_CSS, _DEFAULTS and assets.jinja already carried it at 5.3.3.

The theme loads no Bootstrap JavaScript, and that is the whole design.
Bootstrap's data-bs-* API is a second state owner for the modal, the tabs
and the accordion, and cf_ui_alpine.js is already the first. Loading both
would make Alpine.store('cf').modal.open(id) behave differently under this
theme than under every other one -- the cross-theme guarantee the theme
work exists to protect. So the templates take Bootstrap's classes and
markup structure and wire state through the existing Alpine components.

Two places where "use Bootstrap's classes" is not enough on its own, both
because the missing piece was in the bundle we are not loading:

  * `.modal` is display:none and `.show` only sets opacity -- Bootstrap's
    JS is what writes style.display = "block". The reveal rides on
    `d-block`, a real utility Alpine can toggle, and the E2E tier asserts
    visibility rather than the class list so a reveal that changes classes
    and nothing else cannot pass. The backdrop is the same story: Bootstrap
    appends one to <body> at z-index 1050, under the modal's 1055, and we
    have no JS to do that. It lives inside the modal at a negative z-index
    instead, which keeps it under the dialog rather than eating every
    click; both halves are covered by a test.

  * The panel body deliberately carries no `.collapse`. That class is
    display:none without `.show`, which would hide a server-open panel
    permanently once Alpine is off -- exactly the bug #21 fixed. x-show
    owns display, seeded from data-cf-open. The navbar does use
    `.collapse`, where the pure-CSS `.collapse:not(.show)` /
    `.navbar-expand-lg .navbar-collapse` pair is correct with or without
    JS.

Accessibility parity is enforced rather than asserted in prose:
tests/unit/test_accessibility.py now parametrizes over three themes, so
dialog semantics, the label fallback, the tabs' server-rendered active
state with roving tabindex, and the panel's aria-expanded/aria-controls
toggle all have to hold here too.

"bootstrap" is added to THEMES last, so a half-finished theme was never
selectable at any commit in this branch.

Closes #22

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fsecada01 fsecada01 added the enhancement New feature or request label Jul 29, 2026
)

Issue #22 asks to "confirm assets.jinja has the Bootstrap CDN entry to
match _CDN_CSS". Confirming it by reading both files is exactly the kind
of check that rots: `_CDN_CSS` serves Django and the assets.jinja macro
serves Jinja2 apps, holding the same URLs in two places, and a theme
wired into one and not the other renders unstyled on half the supported
frameworks with no error at all.

So the agreement is now asserted, parametrized over all five theme names
including the two that are still stubs, plus a guard that every entry in
THEMES has both a CDN URL and a pinned version -- a theme selectable at
startup but unstyled at render is worse than one rejected at startup.

Verified non-vacuous: bumping the bootstrap version in assets.jinja alone
turns the new parity test red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fsecada01

Copy link
Copy Markdown
Owner Author

Adversarial self-review

I went looking for places this diff is wrong rather than places it is right, and tried to settle each suspicion by running something rather than by re-reading the template. Below is the one real gap I found (now closed in c972a2b), the things that turned out to be fine, and the evidence for each.

The three Bootstrap-specific decisions, and why I distrusted them

This theme is unusual in the repo because it deliberately omits the framework's own JavaScript. That makes three places where Bootstrap's CSS expects its JS to have written something load-bearing and effectively invisible to diff review. I verified all three against the actual bootstrap@5.3.3 stylesheet pulled from the CDN, not from memory.

Modal reveal uses d-block, not .show alone. .modal { display: none }, and .modal.show only affects the .modal-dialog transform — it never sets display. Bootstrap's JS is what assigns style.display = "block". Since cf-ui loads none, .show on its own yields a modal that is "open" by every class assertion and invisible on screen. d-block is a real Bootstrap utility (display: block !important), so nothing is invented. Removing 'd-block': open from the :class binding turns the E2E assertions red — which is why those tests assert visibility, not classes.

The backdrop lives inside the modal at z-index: -1. Bootstrap puts .modal-backdrop on <body> at z-index: 1050, below .modal at 1055. Without its JS there is nothing to relocate it, and a backdrop nested inside .modal at 1050 paints over the dialog, because .modal opens its own stacking context. This is the change I trusted least, because a click test passes either way: elementFromPoint finds the backdrop outside the dialog whether or not the dialog is buried underneath it. So I checked that it actually paints. With the modal open, elementFromPoint(100, 700) returns DIV.modal-backdrop show and that pixel is (127, 127, 127); the dialog centre returns DIV.modal-body at (255, 255, 255). Dialog on top, backdrop visible. Full-page screenshots also confirmed the navbar, alert, accordion chevrons and tabs render as Bootstrap intends.

The panel body carries no .collapse. .collapse:not(.show) { display: none } is pure CSS, so a .collapse body on a server-open panel would be hidden permanently with JS off — x-show never runs to correct it. x-show owns display here, seeded from data-cf-open, and x-cloak is emitted only when the panel is closed.

Mutation testing: nine deliberate breakages, nine reds

A green suite proves nothing unless removing the behaviour turns it red. Each mutation was reverted immediately after.

  • Drop the server-rendered active tab class — red (jinja and cotton, separately).
  • Set every tab tabindex to -1 — red. This is the one that catches a roving-tabindex regression where the widget is still keyboard-reachable but lands on the wrong tab.
  • Reveal the modal with .show only, no d-block — red.
  • Drop aria-modal="true" — red.
  • Emit x-cloak unconditionally on the panel body — red (jinja and cotton), i.e. the JS-off open-panel case is genuinely covered.
  • Replace the accordion toggle <button type="button"> with a <div> — red.
  • Hard-code style="width: {{ value }}%" instead of computing value / max — red. Bootstrap's bar is a percent-sized div, not a native <progress>, so max=4 must resolve to 100% and max=0 must not raise.

A gap the unit suite structurally cannot see

Unit tests render these templates through plain Jinja2, where {#def} is a comment — so JinjaX's own prop parsing is never exercised — and the E2E gallery mounts only 6 of the 14 components. A malformed {#def} header would therefore ship green. I rendered all 14 Bootstrap components through a real jinjax.Catalog with required props only. All 14 rendered clean. (My first attempt reported KeyError('') on every one, which was my error rather than the templates': JinjaX resolves these as Cf:Modal, with a colon, under add_folder(prefix="Cf").) No bug found, but recording that this failure class has no standing coverage in any tier.

The one thing that was actually wrong

The issue asked me to "confirm assets.jinja has the Bootstrap CDN entry to match _CDN_CSS". I had confirmed it — by reading both files. That is exactly the check that rots: _CDN_CSS serves Django and the assets.jinja macro serves Jinja2 apps, holding the same URLs in two places, and a theme wired into one but not the other renders unstyled on half the supported frameworks with no error raised anywhere. Commit c972a2b replaces the eyeball with three tests: a parity assertion over all five theme names, a guard that every entry in THEMES has both a CDN URL and a pinned version, and a Bootstrap-specific check that the head links the stylesheet and never bootstrap.bundle. Verified non-vacuous — bumping the version in assets.jinja alone turns the parity test red (1 failed, 12 passed).

Things I checked and deliberately left alone

  • tests/unit/test_tailwind_content.py uses an explicit DAISY_TEMPLATES list rather than parametrizing over THEMES, so Bootstrap is already out of scope. No weakening of daisy's assertions was needed, and none was made.
  • test_switching_the_setting_switches_the_rendered_markup[notification] failed legitimately: DaisyUI and Bootstrap emit byte-identical markup for a plain non-dismissible info alert (alert alert-info in both). I fixed the fixture, adding dismissible: "true" so the themes diverge on their real close-button markup, rather than relaxing the assertion. The assertion was right; the fixture was unrepresentative.
  • test_form_field_not_required_by_default initially asserted "required" not in html, which can never hold: render_to_string bypasses the django-cotton compiler, so the literal <c-vars ... required="false"> line survives into the output. It now asserts on required> as an emitted attribute, with a docstring explaining why. A standing reminder that the cotton unit tier tests template text, not compiled components.
  • No data-bs-* attribute appears in any output or any source file, and the E2E suite reads script[src] on the live pages to confirm no Bootstrap bundle is ever loaded. Alpine remains the single state owner, so Alpine.store('cf').modal.open(id) behaves identically across bulma, daisy and bootstrap.

Environment caveat, unrelated to this diff

Early in this run VIRTUAL_ENV pointed at the shared repo-level .venv, and my uv pip install -e ".[dev]" replaced a sibling worktree's editable cf-ui install with mine before I noticed. Every subsequent install and test run was pinned to this worktree's own interpreter. I deliberately did not try to "restore" the shared venv, since a sibling agent may be mid-run and a second rewrite would be the more damaging move. Nothing in this branch depends on that venv — flagging it only so that any concurrently running Foundation or Fomantic branch is re-verified in a clean environment before it merges.

)

Two things #29 needed before merge.

#33 — the CSS-only, Alpine-driven stance was decided during #22 and
recorded nowhere a consumer or maintainer would look. `docs/bootstrap.md`
states it, gives the three reasons in the order they mattered, and
answers the question that was actually unanswered: what to do about a
Bootstrap component cf-ui does not ship.

Bootstrap 5.3.3 ships 12 JS-driven components (`base-component.js` is the
shared base, not a component — the earlier "13" in #33's body counted it).
cf-ui replaces four of those plugins across five of its own components, so
eight are uncovered. The page names them, gives three ways forward, and
states the one hard rule: never put a `data-bs-*` attribute on a cf-ui
component, because Bootstrap's JS and `cf_ui_alpine.js` would then own the
same state and the failure is load-order dependent and intermittent.

The behaviour-driver abstraction is recorded as considered-and-deferred
rather than left unmentioned, so it is not relitigated from scratch.

`tests/unit/test_bootstrap_version_pin.py` turns "monitor for Bootstrap 6"
into something mechanical. `_DEFAULTS["bootstrap"]` is the single place
the major version is stated; the test fails the moment it leaves the `5.x`
line and its failure message is the checklist of what to re-evaluate. Same
pattern #17 established for Tailwind. Verified non-vacuous: bumping the
pin to 6.0.0 fails with that checklist, and the pin was restored.

What it points at, read off `v6-dev` directly rather than from release
notes — several secondary sources claim v6 already adopted native
`<dialog>` citing twbs#41751, which is closed and was never merged:
`_modal.scss` is replaced by `_dialog.scss` with no `.modal*` selector
left, `modal.js` by `dialog.ts` on `HTMLDialogElement.showModal()`, and
the JS surface is growing. cf-ui's bootstrap modal templates carry eight
`modal-*` references each, so the markup breaks at v6 on the CSS alone —
which is why deferring the JS question costs nothing.

#32 — the four Alpine bindings on each tab now read `$el.dataset.cfTab`
instead of an interpolated `'{{ tab.id }}'`. Applied here rather than
waiting, so this theme does not land with a known injection and get
patched twice; the tree-wide guard arrives with #32 itself and will hold
whichever of the two merges second.

743 unit + integration pass, 129 E2E pass, 65 node pass, ruff and prek
clean.

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

Copy link
Copy Markdown
Owner Author

Patched to get this over the line — 4b4f616. Two additions, both from the review of this PR rather than from its checklist.

#33docs/bootstrap.md, the decision record. The CSS-only, Alpine-driven stance was decided here and written down nowhere a consumer or maintainer would look. The page states it, gives the three reasons in the order they mattered, and answers the question that was genuinely unanswered: what to do about a Bootstrap component cf-ui does not ship. Bootstrap 5.3.3 ships 12 JS-driven components — base-component.js is the shared base class, not a component, so the "13" in #33's body counted it — and cf-ui replaces four of those plugins across five of its own components. The eight uncovered ones are named, with three ways forward and the one hard rule: never put a data-bs-* attribute on a cf-ui component, because Bootstrap's JS and cf_ui_alpine.js would then own the same state and the failure is load-order dependent and intermittent. The behaviour-driver abstraction is recorded as considered-and-deferred so it does not get relitigated from scratch.

#33tests/unit/test_bootstrap_version_pin.py, the tripwire. "Monitor for Bootstrap 6" is not a commitment that survives a context window; a red test is. _DEFAULTS["bootstrap"] is the single place the major version is stated, and the test fails the moment it leaves the 5.x line with a failure message that is the checklist of what to re-evaluate. Same pattern #17 established for Tailwind. Verified non-vacuous — bumping the pin to 6.0.0 fails with that checklist, and the pin was restored.

What it points at, read off v6-dev directly rather than from release notes (several secondary sources claim v6 already adopted native <dialog> citing twbs#41751, which is closed and was never merged): _modal.scss is replaced by _dialog.scss with no .modal* selector surviving, modal.js by dialog.ts on HTMLDialogElement.showModal(), and the JS surface is growingcombobox, datepicker, otp-input, chips, strength, drawer, menu, range. This theme's modal templates carry eight modal-* references each, so the markup breaks at v6 on the CSS alone, whatever is decided about JS. That is what makes deferring the JS question free rather than lazy.

#32 — the Alpine injection fix, applied here too. The four bindings on each tab read $el.dataset.cfTab instead of an interpolated '{{ tab.id }}'. This theme copied the pattern from Bulma, where a quote-bearing tab.id executes on page load; it is fixed here rather than after merge so the theme does not land with a known injection and get patched twice. The tree-wide guard that prevents a sixth theme reintroducing it arrives with #35, and will hold whichever of the two PRs merges second.

Merge order does not matter. #35 fixes bulma + daisy and adds the guard; this PR fixes bootstrap. Whichever lands second merges master cleanly and the guard finds nothing.

Gate Result
pytest tests/unit tests/integration 743 passed
pytest tests/e2e --browser chromium 129 passed, 19 skipped
node --test tests/js/*.test.mjs 65 passed
ruff check src tests / ruff format --check src tests clean
prek clean

@fsecada01
fsecada01 merged commit e071929 into master Jul 30, 2026
5 checks passed
fsecada01 added a commit that referenced this pull request Jul 30, 2026
…on-injection

CHANGELOG only. Kept both sides, and folded master's bootstrap-only #32
note into this branch's full #32 section rather than leaving two entries
for the same fix — #29 carried the fix in on its own branch, so the
section now names all the themes it covers.

The tree-wide guard from this branch now also scans master's bootstrap
templates. They pass: #29 landed with the fixed pattern, so the guard
finds nothing, which is the merge order working as intended.

872 unit + integration pass, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
fsecada01 added a commit that referenced this pull request Jul 30, 2026
Foundation (#23) landed on master, which moved the tree under this branch.
Only CHANGELOG.md conflicted, and only as an additive union: both sides
appended a new section to the top of [Unreleased]. Both kept, newest-first
per the file's existing order — Security (#32) above Foundation (#23).

The branch's deletion of the one-bullet "Fixed — tab ids no longer reach
Alpine as expression source (#32)" section that Bootstrap (#29) carried in
merged cleanly and stands: the full Security section supersedes it.
Foundation's equivalent note stays a bullet inside its own theme section,
where it describes what that theme shipped rather than duplicating a
heading.

One line went stale in the merge and was corrected: the Security section
said "Foundation and Fomantic follow the same way" about themes carrying
the fix in on their own branches. Foundation has now landed with it, so
the count and tense move — eight sites each in bootstrap and foundation,
branches #29 and #30, with only Fomantic still to come.

No source or test file needed an edit. Both guards derive their theme
list from cf_ui.themes.THEMES, so tests/unit/test_alpine_expression_safety.py
and tests/e2e/test_alpine_injection.py picked Foundation up on the merge
and pass against it — which is the whole point of writing them tree-wide
rather than per-theme.

Gates: 1066 unit+integration passed (903 on master before the merge),
182 E2E passed / 26 skipped, 65 node --test, ruff check and ruff format
clean over src and tests, prek clean on two consecutive runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Theme: implement Bootstrap 5 (14 components, both template sets)

1 participant