Skip to content

feat(themes): implement the Foundation 6 theme, CSS only (#23) - #30

Merged
fsecada01 merged 2 commits into
masterfrom
feat/component-framework-ui-phase-23-foundation-theme
Jul 30, 2026
Merged

feat(themes): implement the Foundation 6 theme, CSS only (#23)#30
fsecada01 merged 2 commits into
masterfrom
feat/component-framework-ui-phase-23-foundation-theme

Conversation

@fsecada01

Copy link
Copy Markdown
Owner

Closes #23

Implements the Foundation theme — 14 components in both template sets — replacing the PLANNED.md stubs. foundation was added to THEMES last, so a half-finished theme was never selectable.

Checklist

Item Done
templates/jinja/foundation/ — 14 JinjaX components 14 *.jinja, stub deleted
templates/cotton/_themes/foundation/ — 14 cotton partials 14 *.html, no <c-vars> (it stays on the wrapper), stub at cotton/foundation/PLANNED.md deleted
Add "foundation" to THEMESlast Yes, final edit before the first green full run
Confirm assets.jinja matches _CDN_CSS Verified, no edit needed — both already carry foundation-sites@6.7.5/dist/css/foundation.min.css, and I fetched that URL (HTTP 200, 134,868 bytes)
Tests under tests/unit/jinja/ and tests/unit/cotton/ test_foundation.py in both, plus foundation added to the parametrized test_accessibility.py and test_theme_dispatch.py

The public wrappers in cotton/cf/ are untouched, and pyproject.toml already 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.css and read the rules directly, which caught four things the docs would not have:

  • Reveal has no open-state class at all. .reveal and .reveal-overlay are both display: none with no counterpart rule; the jQuery plugin opens them by writing an inline display. x-show cannot substitute — showing only removes the inline property, which falls straight back to the rule that hid it. The modal binds :style instead, and nests .reveal inside .reveal-overlay (the wrapper the plugin normally injects at runtime) because .reveal is position: relative; top: 100px and is only centred by that fixed overlay.
  • Accordion is not symmetric with Tabs. .tabs-panel.is-active { display: block } exists, but there is no .accordion-content.is-active rule — the plugin opens it with an inline slideDown(). A server-open accordion panel would therefore be invisible with Alpine off, which the accessibility contract forbids outright. The panel is built from card / card-divider / card-section instead, and an E2E test asserts the open panel is visible in js_off.
  • Foundation 6.7.5 does not style a native <progress>. The bare element gets only vertical-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 explicit role="progressbar" and aria-value*.
  • .hide is display: none !important at every width, so binding it to !menuOpen would have collapsed the desktop menu too. The navbar binds hide-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: callout with bare alert/success/warning (no is- prefix, and no infoprimary is the stand-in), top-bar, breadcrumbs, pagination with .current/.disabled, table-scroll + <table class="hover">, and is-invalid-input / is-invalid-label / form-error.is-visible for form errors. The public prop vocabulary is unchanged; mapping happens inside the partials.

CSS only — no jQuery

Verified three ways, all executed:

  • Rendered output of all six JinjaX routes plus the cotton pages: 0 occurrences of jquery, foundation.js, or foundation.min.js.
  • test_foundation_js_is_never_initialised asserts typeof window.jQuery === "undefined" and typeof window.Foundation === "undefined" in a live browser.
  • A parametrized unit test asserts no component emits data-reveal, data-tabs, data-accordion, data-toggler, data-closable, data-dropdown-menu, data-responsive-toggle, or data-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 THEMES list in tests/unit/test_accessibility.py, so every assertion from the accessibility phase runs against it unchanged — modal role/aria-modal/aria-labelledby with the label fallback, tabs roles + aria-selected + aria-controls + roving tabindex (including the "no active prop → first tab gets the 0" case), panel aria-expanded/aria-controls with a real <button type="button">, and x-cloak only 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-active on 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_re is compiled without re.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 — passed
  • ruff format --check src tests — 62 files already formatted
  • pytest tests/unit tests/integration -q — 701 passed
  • pytest tests/e2e -q --browser chromium — 130 passed, 20 skipped (49 of those are the new Foundation suite)
  • node --test tests/js/**/*.test.mjs — 65 passed
  • prek run --all-files — run twice, second pass clean with no rewrites

The first full E2E run had 17 Page.goto failures 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.html has the same multi-line {# #} bug — its comment about the hidden/lg:flex layering 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.py needed no change: it hardcodes DAISY_TEMPLATES rather than parametrizing over THEMES, so Foundation is correctly out of scope and daisy's assertions were not weakened.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf

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>
@fsecada01 fsecada01 added the enhancement New feature or request label Jul 29, 2026
@fsecada01

Copy link
Copy Markdown
Owner Author

Adversarial self-review

I 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 tab.id executes arbitrary JS at page load

The brief for this ticket said: never interpolate a request-controlled value into an Alpine expression, pass it via a data- attribute and read it in x-init. I complied for active — it arrives via data-cf-active and is read in initTabs(). I did not comply for tab.id, because bulma and daisy do not either and I was matching the established contract. That was the wrong instinct, and here is the proof.

Rendering Cf:Tabs with a single tab whose id is ');window.PWNED=true;(' produces these bindings, identically under all three themes:

:tabindex="tabIndexFor('');window.PWNED=true;('')"
@click.prevent="setActive('');window.PWNED=true;('')"

tabIndexFor('');window.PWNED=true;('') is valid JavaScript — a call, then an assignment, then an expression — and Alpine evaluates :tabindex eagerly. Served to a real browser with the real cf_ui_alpine.js, window.PWNED === true is already true before any click, on page load. I confirmed the same payload fires under bulma and daisy too, so this PR replicates an existing framework-wide sink rather than creating one.

The :class and :aria-selected comparison bindings are not sinks — active === '');...;('' is a syntax error, which is exactly why the console shows Unexpected token ')'. The function-call bindings are the sinks.

The part worth emphasising: HTML-escaping does not mitigate this. I served a correctly escaped payload in which every quote is &#39;, and the HTML parser decodes the attribute back to f('');window.PWNED=true;('') before Alpine ever sees it — window.PWNED === true. Turning on autoescape, or adding an |escape filter, would look like a fix and would not be one. The only correct fix is the one the brief describes: read the id from the data-cf-tab attribute that every <a> already carries, inside the Alpine component, and stop interpolating it into expression text.

Related, and worth recording while it is in view: the JinjaX catalog renders with autoescape off, so <script>x</script> passed as message or tab.id emits raw and a"onload="y breaks out of its attribute. That may well be deliberate — the gallery passes real HTML into footer, so these props look intentionally HTML-bearing with escaping left to the consumer. Flagging it rather than asserting it is a bug.

I deliberately did not fix this here. It requires changing the shared cf_ui_alpine.js plus the Tabs template in every theme, which is outside "implement the Foundation theme", and two sibling PRs (#22 Bootstrap, #24 Fomantic) are in flight against those same files right now. It wants its own issue and its own branch.

Finding 2 (cosmetic, pre-existing pattern): the string :style binding silently drops the arrow's CSS transition

The Panel chevron carries style="display:inline-block;transition:transform 200ms" alongside a string :style binding. Alpine's string form replaces the entire style attribute, so once Alpine initialises the attribute reads exactly display:inline-block and the computed transition-duration is 0s. The rotation itself still works — I measured matrix(0, 1, -1, 0, 0, 0) on open under both foundation and bulma — it simply snaps instead of animating.

bulma and daisy share the construction and additionally drop display:inline-block from the binding; they survive only because their arrow sits in a flex container that blockifies it anyway. This version at least re-declares the display. Cosmetic in all three, not worth widening this PR.

Suspicions I raised and cleared by execution

  • Backdrop semantics: clicking inside .reveal leaves the modal open, clicking the overlay closes it. @click.self does what it claims.
  • Breakpoints: menu-icon computes display: none at 1280px and is visible at 600px. Choosing hide-for-small-only over .hide was load-bearing — .hide is display: none !important at every width and would have collapsed the desktop menu.
  • Progress arithmetic across engines: the Jinja expression and Django's {% widthratio %} agree on all 13 value/max pairs checked, including (0, 0), (1, 3), (1, 6), (5, 7), (7, 9) and (1, 8). No off-by-one, no ZeroDivisionError.
  • Theme dispatch: all 14 cotton stems render under foundation, none render empty, none are byte-identical to bulma, and none leak unrendered template syntax.
  • The tabs accessibility tests are genuinely collected for foundation rather than silently skipped: 39 passed, 81 deselected under -k foundation.
  • No jQuery and no Foundation JS: zero hits for jquery, foundation.js or foundation.min.js in the rendered output of every gallery route, plus typeof window.jQuery === "undefined" asserted in E2E.

Follow-ups named, not folded in

  • The Alpine expression injection above. This is the one I would prioritise.
  • src/cf_ui/templates/cotton/_themes/daisy/navbar.html carries the same multi-line {# #} bug I hit and fixed in my own partials. Django's tag_re is compiled without re.DOTALL, so a multi-line {# #} is not a comment and renders as visible page text inside every daisy navbar today. No existing test greps those tokens, which is why it is green.
  • tests/unit/test_tailwind_content.py needed no change: it hardcodes DAISY_TEMPLATES rather than parametrizing over THEMES, so Foundation is correctly outside its scope and daisy's assertions stayed untouched.

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

Copy link
Copy Markdown
Owner Author

Conflicts resolved — first green CI this PR has had

5d6c1d6 merges master in. mergeStateStatus was DIRTY, which is why no workflow had ever run against this branch: GitHub cannot build a merge commit for a conflicting PR, so pull_request never fires. All five checks pass now.

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 THEMES order:

File Resolution
themes.py, test_theme_dispatch.py, test_accessibility.py both themes listed
jinja_app/main.py both CSS pins; one shared comment for the two themes that ship prebuilt CSS whose JS cf-ui declines to load
e2e/conftest.py all four server fixtures (8775/8776 Bootstrap, 8777/8778 Foundation) and all four page fixtures

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. Matched as a whole class token, so Bootstrap's bare active still does not collide with is-active.

One conflict in conftest.py was worth care: the region ended before a shared trailing proc.terminate() / proc.wait(timeout=5), so taking either side literally would have left one Django subprocess running for the rest of the session. Both cotton fixtures got their own teardown.

Two things git could not see

test_resolve_theme_rejects_a_stub_theme_by_name named foundation on master — correctly, since Foundation was still a stub when Bootstrap landed. A textual union leaves the merged suite asserting the exact opposite of what this branch ships, and no conflict marker says so. It now names fomantic, and resolve_theme("foundation") gets its own positive test. This one actually failed after the mechanical resolve, which is how it surfaced.

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 class="tabs-title"> rather than the <a>, so the <li> carries its own copy of the attribute. The two test_tabs_keeps_the_alpine_contract tests were updated to assert the new form plus data-cf-tab="one", so they are not merely loosened.

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.

Also

Added 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 (.reveal has no open-state class; .accordion-content has no un-hiding rule; hide vs hide-for-small-only) and what each does instead, plus why aria-selected is load-bearing for appearance in this theme rather than only for the reader.

Gates

  • 903 unit + integration
  • 178 E2E, 26 skipped (js_off parameterizations)
  • 65 node --test
  • ruff check src tests + ruff format --check src tests clean
  • prek clean, run twice
  • CI: test (3.11/3.12/3.13), tailwind-build, GitGuardian — all pass

@fsecada01
fsecada01 merged commit 2c7bc51 into master Jul 30, 2026
5 checks passed
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 Foundation (14 components, both template sets)

1 participant