feat(themes): implement the Foundation 6 theme, CSS only (#23) - #30
Conversation
Adds all 14 components in both template sets and registers `foundation` in
`THEMES` — last, so a half-finished theme was never selectable.
The theme loads Foundation's stylesheet and nothing else. Reveal, Tabs,
Accordion and Dropdown Menu are jQuery plugins, and pulling jQuery in for one
theme would mean a `CF_UI_THEME` edit silently changed a consuming app's
dependency graph. Alpine keeps owning modal, tab and panel state exactly as it
does for bulma and daisy.
Foundation's state mechanisms are not uniform, and the differences were
verified against the compiled 6.7.5 stylesheet rather than the docs:
* `.tabs-panel.is-active` is a real CSS rule, so tabs bind normally. The active
tab's *styling* comes from `.tabs-title > a[aria-selected=true]`, which makes
the aria attribute load-bearing for appearance, not only for the reader.
* `.reveal` and `.reveal-overlay` are `display: none` with no counterpart rule
at all — the plugin opens them by writing an inline `display`. `x-show`
cannot substitute, because showing only *removes* the inline property and
falls straight back to the rule that hid it. So the modal binds `:style`.
* `.accordion-content` is `display: none` with nothing that ever un-hides it,
so a server-open accordion panel would be invisible with Alpine off. The
panel is therefore built from `card`/`card-section`, which the accessibility
contract's "an open panel is readable without JS" requires.
* Foundation 6.7.5 does not style a native `<progress>`; every colour rule is
`.progress.<variant> .progress-meter`, which needs a child the native control
cannot have. Progress renders the div-based meter with explicit ARIA.
The navbar binds `hide-for-small-only` rather than `hide`: `.hide` is
`display: none !important` at every width and would have collapsed the desktop
menu too.
Variant vocabulary is mapped inside the partials — Foundation uses bare
`alert`/`success`/`warning` with no `is-` prefix, and has no `info` — so the
public prop names are unchanged.
Also fixes a Django templating trap found by these tests: `{# #}` is
single-line only, so a multi-line comment renders verbatim into the page. All
cotton partial comments are `{% comment %}` blocks, pinned by an E2E test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adversarial self-reviewI went looking for places this diff is wrong, and verified every suspicion by execution rather than by reading. One finding is significant enough that I want it read before this merges, even though it is pre-existing rather than introduced here. Finding 1 (significant, pre-existing, faithfully replicated by this PR): a hostile
|
Bootstrap (#22) and Foundation (#23) were split out of the same parent issue and touch the same five registration points, so every conflict here is additive — both themes' entries belong in the merged file. Resolved as unions, in `THEMES` order: - `themes.py`, `test_theme_dispatch.py`, `test_accessibility.py` — both themes listed. `ACTIVE_TAB_CLASS` now records that Foundation reuses Bulma's `is-active` token on the `<li class="tabs-title">`, whereas DaisyUI and Bootstrap mark the `<a role="tab">` itself. - `jinja_app/main.py` — both CSS pins, and one shared comment for the two themes that ship prebuilt CSS whose JavaScript cf-ui declines to load. - `e2e/conftest.py` — all four server fixtures (8775/8776 Bootstrap, 8777/8778 Foundation) and all four page fixtures. Each cotton server fixture got its own `terminate()`/`wait()`; the conflict region ended before the shared teardown, so taking either side would have left one Django subprocess running for the rest of the session. Two things git could not see: - `test_resolve_theme_rejects_a_stub_theme_by_name` named `foundation` on master, because Foundation was still a stub when Bootstrap landed. It now names `fomantic`, and `resolve_theme("foundation")` gets its own positive test — otherwise the merged suite asserted the opposite of what this branch ships and no conflict marker would have said so. - The Foundation tabs templates still passed `'{{ tab.id }}'` into four Alpine expressions (#32). Fixed the same way as the other themes: `data-cf-tab` on the element carrying the binding, read back through `$el.dataset.cfTab`. Foundation puts `:class` on the `<li>`, so the `<li>` carries its own copy of the attribute. Landing the theme without this would put the bug in a fourth theme and go red against the tree-wide guard in #35. Also adds the CHANGELOG section the branch never got. Gates: 903 unit+integration, 178 E2E (26 skipped), 65 node, ruff clean, prek clean twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
Conflicts resolved — first green CI this PR has had
Bootstrap (#22) and Foundation (#23) were split out of the same parent issue and register at the same five points, so every conflict was additive. Resolved as unions, in
One conflict in Two things git could not see
The Foundation tabs templates still passed Landing the theme without this would put the bug in a fourth theme, and would go red against the tree-wide guard in #35 the moment that merges. AlsoAdded the CHANGELOG section this branch never had — every other theme has one. It records the three places Foundation's CSS assumes its JS is present ( Gates
|
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
Bootstrap (#22), Foundation (#23) and the Alpine injection fix (#32) all landed on master while this branch sat. Seven files conflicted; every one was an additive union at the same five registration points the last two themes went through, plus README and CHANGELOG. THEMES becomes all five. tests/e2e/conftest.py gains the fomantic pair on 8779/8780 — ordered last so its cotton fixture takes the trailing terminate/wait that sits outside the conflict region, with foundation's given back explicitly, since taking either side literally would have leaked a Django subprocess. Three things git could not flag: 1. test_appconfig_rejects_an_unimplemented_theme merged clean and wrong. Master's version sets CF_UI_THEME = "fomantic" and expects ImproperlyConfigured — correct when Fomantic was a stub, exactly backwards on the branch that implements it. No marker anywhere near it. Renamed to ..._rejects_an_unregistered_theme and pointed at "fomantic-ui", the upstream package name and a plausible typo. 2. test_resolve_theme_rejects_a_stub_theme_by_name has no subject left. Every earlier version named whichever theme was still a PLANNED.md stub; #24 removes the last one, so no theme name can play that role again. Replaced with test_resolve_theme_answers_from_the_registry_not_the_filesystem, which states the invariant the old test was really protecting: a name is selectable because THEMES lists it, never because a directory exists. test_resolve_theme_accepts_fomantic added alongside the bootstrap and foundation cases. 3. The theme still carried the #32 pattern — eight interpolated tab ids across the two tabs templates. All four bindings sit on the <a> that already has data-cf-tab, so this is the DaisyUI/Bootstrap shape and needed no wrapper plumbing. Verified the guard is not vacuous by reverting one binding: three tests fail, including the tree-wide scanner. The two test_tabs_keeps_the_alpine_contract assertions move from setActive('one') to setActive($el.dataset.cfTab) and additionally assert data-cf-tab="one", so they are tightened rather than loosened. README's theme table called Foundation planned, which #30 made false and did not correct. The merge forces a rewrite of those exact lines, so all five rows now read accurate and the install block lists the extras that have existed in pyproject.toml all along. Gates: 1293 unit+integration passed, 230 E2E passed / 33 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
Closes #23
Implements the Foundation theme — 14 components in both template sets — replacing the
PLANNED.mdstubs.foundationwas added toTHEMESlast, so a half-finished theme was never selectable.Checklist
templates/jinja/foundation/— 14 JinjaX components*.jinja, stub deletedtemplates/cotton/_themes/foundation/— 14 cotton partials*.html, no<c-vars>(it stays on the wrapper), stub atcotton/foundation/PLANNED.mddeleted"foundation"toTHEMES— lastassets.jinjamatches_CDN_CSSfoundation-sites@6.7.5/dist/css/foundation.min.css, and I fetched that URL (HTTP 200, 134,868 bytes)tests/unit/jinja/andtests/unit/cotton/test_foundation.pyin both, plusfoundationadded to the parametrizedtest_accessibility.pyandtest_theme_dispatch.pyThe public wrappers in
cotton/cf/are untouched, andpyproject.tomlalready carried the extras entry.Class mapping, verified against the compiled stylesheet
The issue asked for the mapping to be confirmed against Foundation 6.7 rather than memory. I downloaded
foundation-sites@6.7.5/dist/css/foundation.min.cssand read the rules directly, which caught four things the docs would not have:.revealand.reveal-overlayare bothdisplay: nonewith no counterpart rule; the jQuery plugin opens them by writing an inlinedisplay.x-showcannot substitute — showing only removes the inline property, which falls straight back to the rule that hid it. The modal binds:styleinstead, and nests.revealinside.reveal-overlay(the wrapper the plugin normally injects at runtime) because.revealisposition: relative; top: 100pxand is only centred by that fixed overlay..tabs-panel.is-active { display: block }exists, but there is no.accordion-content.is-activerule — the plugin opens it with an inlineslideDown(). A server-open accordion panel would therefore be invisible with Alpine off, which the accessibility contract forbids outright. The panel is built fromcard/card-divider/card-sectioninstead, and an E2E test asserts the open panel is visible injs_off.<progress>. The bare element gets onlyvertical-align: baseline; every colour rule is.progress.<variant> .progress-meter, which needs a child the native control cannot have. Progress renders the div-based meter with explicitrole="progressbar"andaria-value*..hideisdisplay: none !importantat every width, so binding it to!menuOpenwould have collapsed the desktop menu too. The navbar bindshide-for-small-only, which is what Foundation's own responsive toggle effectively does; with Alpine off no class is emitted and the menu is simply visible.Everything else maps as the issue sketched:
calloutwith barealert/success/warning(nois-prefix, and noinfo—primaryis the stand-in),top-bar,breadcrumbs,paginationwith.current/.disabled,table-scroll+<table class="hover">, andis-invalid-input/is-invalid-label/form-error.is-visiblefor form errors. The public prop vocabulary is unchanged; mapping happens inside the partials.CSS only — no jQuery
Verified three ways, all executed:
jquery,foundation.js, orfoundation.min.js.test_foundation_js_is_never_initialisedassertstypeof window.jQuery === "undefined"andtypeof window.Foundation === "undefined"in a live browser.data-reveal,data-tabs,data-accordion,data-toggler,data-closable,data-dropdown-menu,data-responsive-toggle, ordata-abide.The template source grep returns two hits, both prose inside explanatory comments (
Modal.jinja:9,_themes/foundation/modal.html:2) describing what the plugin would have done. No code path loads it.Accessibility parity
Foundation is now in the parametrized
THEMESlist intests/unit/test_accessibility.py, so every assertion from the accessibility phase runs against it unchanged — modalrole/aria-modal/aria-labelledbywith thelabelfallback, tabs roles +aria-selected+aria-controls+ rovingtabindex(including the "no active prop → first tab gets the 0" case), panelaria-expanded/aria-controlswith a real<button type="button">, andx-cloakonly when closed. No assertion was weakened.Request-controlled state still crosses into Alpine through
data-cf-active/data-cf-open, never an interpolated expression.One Foundation-specific note:
.tabs-title > a[aria-selected=true]is what actually restyles the selected tab —.is-activeon the<li>alone changes nothing visually — so here the ARIA attribute is load-bearing for appearance, not only for the reader. Both are server-rendered and both keep their Alpine binding.A bug the tests caught
Django's
{# #}is single-line only —tag_reis compiled withoutre.DOTALL, so a multi-line comment is emitted verbatim as page text. My first draft of the cotton partials used them and three tests failed on comment prose leaking into the rendered HTML. All cotton comments are now{% comment %}blocks, and an E2E test pins it.Gates
All green, run in this worktree:
ruff check src tests— passedruff format --check src tests— 62 files already formattedpytest tests/unit tests/integration -q— 701 passedpytest tests/e2e -q --browser chromium— 130 passed, 20 skipped (49 of those are the new Foundation suite)node --test tests/js/**/*.test.mjs— 65 passedprek run --all-files— run twice, second pass clean with no rewritesThe first full E2E run had 17
Page.gotofailures in the pre-existing Bulma suite; those fixtures bind the shared ports 8771–8774 and a sibling agent's suite was running concurrently. Bulma passed in isolation and the full suite passed clean on retry. The new fixtures use ports 8777/8778 as assigned.Follow-ups, deliberately not folded in
src/cf_ui/templates/cotton/_themes/daisy/navbar.htmlhas the same multi-line{# #}bug — its comment about thehidden/lg:flexlayering is currently rendered as visible text inside every daisy navbar. No existing test greps for those tokens, so it passes today. Worth its own ticket.tests/unit/test_tailwind_content.pyneeded no change: it hardcodesDAISY_TEMPLATESrather than parametrizing overTHEMES, so Foundation is correctly out of scope and daisy's assertions were not weakened.🤖 Generated with Claude Code
https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf