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