Skip to content

feat(themes): implement Fomantic UI 2.9 theme, CSS only (#24) - #31

Open
fsecada01 wants to merge 3 commits into
masterfrom
feat/component-framework-ui-phase-24-fomantic-theme
Open

feat(themes): implement Fomantic UI 2.9 theme, CSS only (#24)#31
fsecada01 wants to merge 3 commits into
masterfrom
feat/component-framework-ui-phase-24-fomantic-theme

Conversation

@fsecada01

@fsecada01 fsecada01 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Implements the Fomantic UI 2.9 theme across both template sets, replacing the PLANNED.md stubs. 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.md deleted.
  • templates/cotton/_themes/fomantic/ — 14 cotton partials (*.html) — same set, no <c-vars> (those stay on the untouched wrappers in cotton/cf/). The now-empty cotton/fomantic/ directory and its PLANNED.md are gone, matching how daisy is laid out.
  • Add "fomantic" to THEMES in themes.py — last — done as the final source change, after all 28 templates existed, so the theme was never selectable half-finished.
  • Confirm assets.jinja has the Fomantic CDN entry to match _CDN_CSS — verified by reading both, not assumed. assets.jinja and templatetags/cf_ui.py already agreed on fomantic2.9.3dist/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.css returns HTTP 200, 1,647,205 bytes, text/css.
  • Add tests under tests/unit/jinja/ and tests/unit/cotton/tests/unit/jinja/test_fomantic.py and tests/unit/cotton/test_fomantic.py, mirroring the daisy suites. Plus tests/e2e/test_fomantic.py and the two E2E server fixtures.

Verification checklist

  • just check greenruff check src tests clean, ruff format --check src tests reports 62 files already formatted, pytest tests/unit tests/integration -q734 passed.
  • pytest tests/unit/test_theme_dispatch.py -v covers fomanticIMPLEMENTED_THEMES now ["bulma", "daisy", "fomantic"], so every parametrized dispatch test runs against it.
  • E2E renders every component under the new theme without errorpytest tests/e2e -q --browser chromium128 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.
  • Modal focus management, tab active state, and ARIA behave identically to bulma/daisytests/unit/test_accessibility.py now parametrizes over ["bulma", "daisy", "fomantic"] with no assertion weakened. The only addition is an ACTIVE_TAB_CLASS map, 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 bare active cannot pass by accident.
  • Grep the rendered E2E output and the templates for 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:8 and _themes/fomantic/select.html:2). Nothing executable.

Rendered output, jinja: all six routes of the FastAPI gallery app under theme="fomantic" fetched and scanned for jquery, semantic.min.js, semantic.js, fomantic.min.js, $(HITS: none.

Rendered output, browser: tests/e2e/test_fomantic.py -k jquery4 passed. Those tests check page.content() for the same tokens and assert typeof window.jQuery === "undefined" and typeof 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.css rather than from memory. Six rules would have shipped a broken theme otherwise:

  • .ui.dimmer and .ui.modal are both display:none until .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.modal get :class="{ 'active': open }".
  • .ui.accordion:not(details) .title~.content:not(.active){display:none} applies to styled accordions too. x-show alone is not enough — Alpine clears an inline display, and this rule then wins. active is a bound class as well.
  • ui styled accordion is a fixed width:600px. The panel uses ui styled fluid accordion (.ui.fluid.accordion{width:100%}).
  • .ui.tab{display:none} without the JS Tab module, so the tabs panel is a ui bottom attached segment and never carries ui tab.
  • .ui.ui.ui.progress:not([data-percent]) .bar{background:0 0}data-percent is 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 as ui basic red pointing prompt label, which needs no form-level state.

The dismiss control is <button type="button" class="close icon">&times;</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.icon positioning 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, so value=150 max=100 produced width: 150% and aria-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\.venv currently has cf_ui editable-installed pointing at a sibling worktree (agent-a504e12e731fca22f, the Bootstrap phase), because VIRTUAL_ENV makes uv pip install -e . escape a worktree. Every gate above was therefore re-run through this worktree's own interpreter by absolute path, with cf_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 a labelaria-label fallback, x-data="cfModal" + x-init="initModal()". Tabs: server-rendered active state, role="tablist" / role="tab" / role="tabpanel", aria-selected, aria-controls, roving tabindex with exactly one 0, data-cf-active + initTabs() + @keydown="onKeydown($event)". Panel: data-cf-open + initPanel(), aria-expanded, aria-controls, a real <button type="button"> toggle, x-cloak only when closed.

No request-controlled value is interpolated into an Alpine expression anywhere in the theme — they all arrive via data- attributes read in x-init.

cf_ui_alpine.js is 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.html has a latent multi-line {# … #} comment. Django's tag_re has no re.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 string ui error message failed 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-expanded and an active class, but Fomantic's responsive menu collapse is the Sidebar/Dropdown JS module. ui stackable menu is 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) and CHANGELOG.md updated. tests/integration/jinja_app/main.py gained the fomantic CDN entry for the gallery app, with an explicit empty _THEME_EXTRA_HEAD entry and a comment recording that loading no JS is deliberate, not an oversight.

tests/unit/test_tailwind_content.py was checked and not modified: it hardcodes DAISY_TEMPLATES and does not parametrize over THEMES, so adding fomantic does not reach it and no daisy assertion was weakened.

Closes #24

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>
@fsecada01 fsecada01 added the enhancement New feature or request label Jul 29, 2026
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>
@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 I verified every suspicion by executing it — rendering the components with hostile and out-of-range inputs and diffing the result against what bulma and daisy produce from the same props. Reading the templates would have missed all four findings below.

0. First: my earlier gate results were void, and I re-ran everything

Mid-review the coordinator flagged that a sibling agent's editable install had repointed the shared repo-level venv. I checked instead of assuming:

bare-python exe   : C:\dev\python\modules\component-framework-ui\.venv\Scripts\python.exe
bare-python cf_ui : ...\.claude\worktrees\agent-a504e12e731fca22f\src\cf_ui\__init__.py

That is the Bootstrap agent's worktree, not mine. Anything I had run as bare python -m pytest was testing someone else's source. My worktree-local venv was itself clean —

exe    : ...\worktrees\agent-a62437a446b06b9e9\.venv\Scripts\python.exe
cf_ui  : ...\worktrees\agent-a62437a446b06b9e9\src\cf_ui\__init__.py

— 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 clamp

The probe that found it: render CfProgress with value=150, max=100 and value=-10, max=100 under all three themes and compare the output.

BULMA     <progress class="progress is-info" value="150" max="100">150%</progress>
DAISY     <progress class="progress progress-info w-full" value="150" max="100">150%</progress>
FOMANTIC  <div class="ui teal progress" data-percent="150" role="progressbar"
               aria-valuenow="150" aria-valuemin="0" aria-valuemax="100">
            <div class="bar" style="width: 150%">

Bulma and Daisy render a real <progress>, and the browser clamps that element for free — in the painting and in the accessibility tree. I replaced it with a div whose width I compute myself, and in doing so silently dropped a guarantee I had not noticed I was relying on. width: 150% is a bar wider than its own track, and aria-valuenow="150" against aria-valuemin="0" aria-valuemax="100" is invalid ARIA. -10 was symmetrically wrong.

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 data-percent="-10" and width: 150% actually in the output, then the clamp. The ARIA is now expressed in percent against a fixed max of 100 so it agrees with the visible "40%" text rather than 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 now proven by a passing test rather than by my belief about Django's IfNode.

2. Real flake, now fixed: one E2E test was timing out under load

The full E2E run failed once on test_jinja_modal_is_actually_visible_when_open[js_on]Page.goto: Timeout 5000ms exceeded on /gallery. It passed in isolation immediately afterwards, which is the signature of contention, not logic.

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 — 128 passed, 20 skipped — and the reason is stated above rather than waved off as "flaky".

3. Not a bug here, but you should know: JinjaX renders with autoescape OFF

BULMA     autoescape = False   hostile label unescaped in modal : True
DAISY     autoescape = False   hostile label unescaped in modal : True
FOMANTIC  autoescape = False   hostile label unescaped in modal : True

Passing label='x" onload="alert(1)' to CfModal puts that string into the attribute verbatim, on every theme. The catalog's jinja_env.autoescape is False, and the unit-test Environment uses select_autoescape(["html"]) — which returns False for a .jinja filename, so the test tier would not catch it either.

This is pre-existing and package-wide. This PR does not introduce it and does not make it worse — I confirmed identical behaviour on bulma, which predates all of this. I am explicitly not fixing it here: flipping autoescape changes every consumer's rendered output and needs its own PR, plus a decision about |safe escape hatches.

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 data- attribute read in x-init, so nothing user-supplied is ever parsed as JS. The autoescape gap is plain HTML-attribute injection on a different axis. It deserves its own issue.

4. Checked and cleared: tabs with no active tab

I suspected active="" would leave a tablist with no aria-selected="true", which would be an a11y defect. It does:

tabs active='' -> aria-selected=true count : 0
tabs active='' -> tabindex=0 count         : 1

But bulma and daisy produce identical counts. This is the established server-render contract — Alpine's initTabs() resolves the selected tab client-side, and exactly one tab carries tabindex="0" so keyboard entry works regardless. Fomantic matches it. Not a divergence I introduced, and changing it unilaterally in one theme would break the parity the accessibility phase established.

5. Checked and cleared: the modal's class bindings

Verified by render, not by eye. "'active': open" appears exactly twice (dimmer + modal, both required, since both are display:none without it). Alpine's object-form :class does not clobber the static list — class="ui page dimmer mine" survives with extra_class set. aria-labelledby="m-title" is emitted when a header exists, and aria-label is correctly absent in that case rather than both being present.

6. Checked and cleared: pagination boundaries

page=1 total=1 → 1 active item, 2 disabled item (both edges correctly dead). page=1 total=0 → 0 active, 2 disabled, no crash on the empty range. page=3 total=5 → 1 active, 0 disabled.

The jQuery grep, run again after the fix

Templates: 2 hits, both inside comments naming the module being replaced. Rendered jinja output across all six gallery routes: none. Browser, -k jquery: 4 passed, checking page.content() and asserting window.jQuery and window.$ are both undefined on the jinja and cotton servers.

Gate status after the re-run, all through the worktree interpreter

ruff check src tests — All checks passed. ruff format --check src tests — 62 files already formatted. pytest tests/unit tests/integration -q734 passed. pytest tests/e2e -q --browser chromium128 passed, 20 skipped. node --test65 pass, 0 fail. prek run --all-files twice — clean both times, no rewrites.

What I would still push back on in this PR

The navbar burger. It sets aria-expanded and an active class and hides nothing, because ui stackable menu stacks unconditionally and Fomantic's real collapse is a JS module. I documented it, but a control that announces a state it does not have is arguably worse than no control — it lies to a screen reader. The honest options are to drop the burger entirely under this theme, or to ship a small cf-ui-owned CSS rule. I left it because choosing between those is a decision about whether this package ships its own CSS at all, which is bigger than #24. I would not treat it as closed just because the theme merges.

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 Fomantic UI (14 components, both template sets)

1 participant