feat(themes): implement Fomantic UI 2.9 theme, CSS only (#24) - #31
feat(themes): implement Fomantic UI 2.9 theme, CSS only (#24)#31fsecada01 wants to merge 3 commits into
Conversation
Adds the Fomantic UI theme across both template sets — 14 JinjaX
components and 14 django-cotton theme partials — and registers
"fomantic" in THEMES.
The constraint that shaped every template: Fomantic ships its
interactive behavior as jQuery plugins, and cf-ui already owns that
behavior in Alpine. Loading semantic.min.js would mean two systems
fighting over the same DOM, plus a jQuery dependency in a package that
has none. So this is CSS only, and the templates render from Alpine
state the classes Fomantic's JS would otherwise toggle:
- Modal: the root element *is* the dimmer, because .ui.dimmer and
.ui.modal are both display:none until .active — Fomantic normally
injects the dimmer at runtime.
- Panel: `active` is a bound class, not just x-show. The rule
`.ui.accordion .title~.content:not(.active){display:none}` wins over
Alpine clearing an inline display, so x-show alone leaves an expanded
panel invisible. Also `fluid`, since `ui styled accordion` is a fixed
600px.
- Tabs: the panel is a `ui bottom attached segment`, never `.ui.tab`
(display:none without the JS module).
- Select: a real styled <select class="ui fluid selection dropdown">
rather than a reimplementation of Fomantic's dropdown widget.
- Progress: `data-percent` is mandatory — the bar's background is
removed without it — and the width is server-rendered.
- Field errors: `ui basic red pointing prompt label`, because
`.ui.form .error.message` stays hidden until the *form* carries
`.error`.
Accessibility is at parity with bulma and daisy rather than
best-effort: dialog semantics and labelling on the modal, tablist /
tab / tabpanel with roving tabindex, aria-expanded on the panel toggle,
and a real <button> for every control. Request-controlled values reach
Alpine through data- attributes read in x-init, never interpolated into
an expression.
"fomantic" was added to THEMES last, so the theme was never selectable
in a half-finished state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by adversarially probing my own diff against bulma and daisy with
the same inputs, rather than by reading it.
Bulma and DaisyUI render a real <progress value max>. The browser clamps
that element for free, in both the painting and the accessibility tree,
so `value="150" max="100"` paints full and reports 100%. Fomantic has no
native element behind it — the bar is a div sized by a server-rendered
width — so the same props produced `width: 150%` (a bar wider than its
track) and `aria-valuenow="150"` against `aria-valuemin="0"`, which is
not valid ARIA. A negative value was symmetrically wrong.
Clamping restores the parity that the native element was providing
silently. The ARIA is now stated in percent against a fixed max of 100
so it agrees with the visible "40%" text instead of describing a
different scale.
Django has no min/max filter, so the cotton side clamps with three
`widthratio` branches; that `{% widthratio ... as %}` inside `{% if %}`
escapes the branch is verified by the tests, not assumed.
Also raises the navigation timeout on the one E2E test that asserts
computed visibility. It is the only test here that needs Fomantic's
1.6 MB stylesheet to have actually arrived, and it flaked at the default
5s under a contended connection. Raised locally so the rest of the file
keeps failing fast.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adversarial self-reviewI went looking for places this diff is wrong rather than places it is right, and I verified every suspicion by executing it — rendering the components with hostile and out-of-range inputs and diffing the result against what 0. First: my earlier gate results were void, and I re-ran everythingMid-review the coordinator flagged that a sibling agent's editable install had repointed the shared repo-level venv. I checked instead of assuming: That is the Bootstrap agent's worktree, not mine. Anything I had run as bare — so I re-ran the entire gate list through that absolute interpreter path. Every number quoted in the PR body and below is from the re-run. I did not modify the shared venv. 1. Real bug, now fixed: the progress bar did not clampThe probe that found it: render Bulma and Daisy render a real This is exactly the class of bug that "port the markup to the new framework" produces and that reading the diff does not catch: the old code's correctness lived in the element, not in the template. Fixed in a300ffa, TDD — four tests written first, confirmed failing with 2. Real flake, now fixed: one E2E test was timing out under loadThe full E2E run failed once on Cause: this is the only test in the file that needs the stylesheet to have actually arrived, because it asserts computed visibility rather than an attribute. Fomantic ships as a single 1.6 MB file from the CDN, and three agents were pulling from it concurrently. The default 5 s navigation budget is not reliably enough. I raised the timeout on that one call to 30 s rather than globally, so the other 53 tests keep failing fast. A test that is right but flaky is worse than useless — it trains you to re-run instead of read. I would rather flag this than bury it: the first full E2E run in this session was red. The re-run is green — 3. Not a bug here, but you should know: JinjaX renders with autoescape OFFPassing This is pre-existing and package-wide. This PR does not introduce it and does not make it worse — I confirmed identical behaviour on Worth separating two things that get conflated. The brief's constraint was never interpolate a request-controlled value into an Alpine expression, and that constraint holds — every such value in this theme reaches Alpine through a 4. Checked and cleared: tabs with no active tabI suspected But 5. Checked and cleared: the modal's class bindingsVerified by render, not by eye. 6. Checked and cleared: pagination boundaries
The jQuery grep, run again after the fixTemplates: 2 hits, both inside comments naming the module being replaced. Rendered jinja output across all six gallery routes: none. Browser, Gate status after the re-run, all through the worktree interpreter
What I would still push back on in this PRThe navbar burger. It sets |
…tic-theme # Conflicts: # CHANGELOG.md
Implements the Fomantic UI 2.9 theme across both template sets, replacing the
PLANNED.mdstubs. CSS only — no Fomantic JS, no jQuery.Scope checklist
templates/jinja/fomantic/— 14 JinjaX components (*.jinja) — Breadcrumb, Card, CheckboxGroup, FormField, Modal, Navbar, Notification, Pagination, Panel, Progress, Select, Table, Tabs, Textarea.PLANNED.mddeleted.templates/cotton/_themes/fomantic/— 14 cotton partials (*.html) — same set, no<c-vars>(those stay on the untouched wrappers incotton/cf/). The now-emptycotton/fomantic/directory and itsPLANNED.mdare gone, matching howdaisyis laid out."fomantic"toTHEMESinthemes.py— last — done as the final source change, after all 28 templates existed, so the theme was never selectable half-finished.assets.jinjahas the Fomantic CDN entry to match_CDN_CSS— verified by reading both, not assumed.assets.jinjaandtemplatetags/cf_ui.pyalready agreed onfomantic→2.9.3→dist/semantic.min.css. No edit needed. I also fetched the pinned URL before writing templates against it:https://cdn.jsdelivr.net/npm/fomantic-ui@2.9.3/dist/semantic.min.cssreturns HTTP 200, 1,647,205 bytes,text/css.tests/unit/jinja/andtests/unit/cotton/—tests/unit/jinja/test_fomantic.pyandtests/unit/cotton/test_fomantic.py, mirroring the daisy suites. Plustests/e2e/test_fomantic.pyand the two E2E server fixtures.Verification checklist
just checkgreen —ruff check src testsclean,ruff format --check src testsreports 62 files already formatted,pytest tests/unit tests/integration -q→ 734 passed.pytest tests/unit/test_theme_dispatch.py -vcovers fomantic —IMPLEMENTED_THEMESnow["bulma", "daisy", "fomantic"], so every parametrized dispatch test runs against it.pytest tests/e2e -q --browser chromium→ 128 passed, 20 skipped for the full suite; the fomantic file alone is 47 passed, 7 skipped. No port contention occurred; fomantic binds 8779 (jinja) and 8780 (cotton), leaving the shared 8771–8774 fixtures untouched.tests/unit/test_accessibility.pynow parametrizes over["bulma", "daisy", "fomantic"]with no assertion weakened. The only addition is anACTIVE_TAB_CLASSmap, because the three themes name the selected-tab class differently (is-active/tab-active/active) and put it on different elements; every assertion matches whole class tokens rather than substrings so Fomantic's bareactivecannot pass by accident.jquery— there should be no hits — see below; ran it, zero hits.The jQuery grep, actually run
Templates, raw source: two hits, both inside comments explaining which jQuery module the template is replacing (
Select.jinja:8and_themes/fomantic/select.html:2). Nothing executable.Rendered output, jinja: all six routes of the FastAPI gallery app under
theme="fomantic"fetched and scanned forjquery,semantic.min.js,semantic.js,fomantic.min.js,$(→ HITS: none.Rendered output, browser:
tests/e2e/test_fomantic.py -k jquery→ 4 passed. Those tests checkpage.content()for the same tokens and asserttypeof window.jQuery === "undefined"andtypeof window.$ === "undefined"on both the jinja and the cotton servers, so this covers what a script tag would have loaded, not just the served HTML.The unit-level guard scans all 28 templates with comments stripped first (
{# … #}and{% comment %}…{% endcomment %}), because a guard that forbade naming jQuery would push the reasoning for these decisions out of the code and into a PR nobody re-reads.What "CSS only" forced
I verified every class against the shipped
fomantic-ui@2.9.3/dist/semantic.min.cssrather than from memory. Six rules would have shipped a broken theme otherwise:.ui.dimmerand.ui.modalare bothdisplay:noneuntil.active, and Fomantic normally injects the dimmer from JS. So the modal's root element is the dimmer, and both it and the inner.ui.modalget:class="{ 'active': open }"..ui.accordion:not(details) .title~.content:not(.active){display:none}applies to styled accordions too.x-showalone is not enough — Alpine clears an inlinedisplay, and this rule then wins.activeis a bound class as well.ui styled accordionis a fixedwidth:600px. The panel usesui styled fluid accordion(.ui.fluid.accordion{width:100%})..ui.tab{display:none}without the JS Tab module, so the tabs panel is aui bottom attached segmentand never carriesui tab..ui.ui.ui.progress:not([data-percent]) .bar{background:0 0}—data-percentis mandatory or the bar is invisible. Both the attribute and the width are server-rendered..ui.form .error.message{display:none}until the form itself carries.error. Field errors therefore render asui basic red pointing prompt label, which needs no form-level state.The dismiss control is
<button type="button" class="close icon">×</button>rather than<i class="close icon">: Fomantic's glyph rules are element-scoped (i.icon.close::before), so a<button>keeps the.ui.message>.close.iconpositioning while rendering its own literal×— and is focusable and announced, which an<i>is not.Select is a real
<select class="ui fluid selection dropdown">, per the issue — the component's contract is a form control, not a combobox.Progress clamps its percentage to
0..100, which Bulma and Daisy get for free and I did not: they render a real<progress>, and the browser clamps that element in both the painting and the accessibility tree. A div does not, sovalue=150 max=100producedwidth: 150%andaria-valuenow="150"against a max of 100. Caught by the self-review below, fixed in a300ffa.Environment note
The shared repo-level venv at
C:\dev\python\modules\component-framework-ui\.venvcurrently hascf_uieditable-installed pointing at a sibling worktree (agent-a504e12e731fca22f, the Bootstrap phase), becauseVIRTUAL_ENVmakesuv pip install -e .escape a worktree. Every gate above was therefore re-run through this worktree's own interpreter by absolute path, withcf_ui.__file__confirmed to resolve inside this worktree first. I did not modify the shared venv — it will need repointing before anyone runs tests from the main checkout.Accessibility
Parity with bulma and daisy, not best-effort. Modal:
role="dialog",aria-modal="true",aria-labelledby="{id}-title"with alabel→aria-labelfallback,x-data="cfModal"+x-init="initModal()". Tabs: server-rendered active state,role="tablist"/role="tab"/role="tabpanel",aria-selected,aria-controls, rovingtabindexwith exactly one0,data-cf-active+initTabs()+@keydown="onKeydown($event)". Panel:data-cf-open+initPanel(),aria-expanded,aria-controls, a real<button type="button">toggle,x-cloakonly when closed.No request-controlled value is interpolated into an Alpine expression anywhere in the theme — they all arrive via
data-attributes read inx-init.cf_ui_alpine.jsis unchanged. A theme partial supplies classes and ARIA state and never implements behavior, so this theme inherits the focus trap, Escape handling, and roving tabindex for free.Not in this PR — follow-ups
cotton/_themes/daisy/navbar.htmlhas a latent multi-line{# … #}comment. Django'stag_rehas nore.DOTALL, so a{# … #}spanning lines is not a comment — the text leaks into the rendered HTML. I hit exactly this in my own fomantic partials (a comment containing the literal stringui error messagefailed an assertion by being rendered) and converted all nine affected files to{% comment %}…{% endcomment %}. The daisy file has the same bug. I deliberately did not fix it here — it is unrelated to #24 and belongs in its own change.The Fomantic navbar burger reports state but hides nothing. It toggles
aria-expandedand anactiveclass, but Fomantic's responsive menu collapse is the Sidebar/Dropdown JS module.ui stackable menuis the CSS-only substitute and stacks unconditionally at mobile widths, so there is nothing to reveal. Documented in the template and the changelog. Closing that gap properly means a cf-ui-owned CSS rule, which is a separate decision about whether the package ships its own CSS at all.Also
README.md(install extra, themes table) andCHANGELOG.mdupdated.tests/integration/jinja_app/main.pygained the fomantic CDN entry for the gallery app, with an explicit empty_THEME_EXTRA_HEADentry and a comment recording that loading no JS is deliberate, not an oversight.tests/unit/test_tailwind_content.pywas checked and not modified: it hardcodesDAISY_TEMPLATESand does not parametrize overTHEMES, so adding fomantic does not reach it and no daisy assertion was weakened.Closes #24