diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ee7a7d..57bf969 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,51 @@
## [Unreleased]
+### Added — Foundation 6 theme (#23)
+
+All 14 components in both template sets, replacing the `PLANNED.md` stub at
+`templates/jinja/foundation/` and creating `templates/cotton/_themes/foundation/`.
+`foundation` is now accepted by `CF_UI_THEME` and by `install_cf_ui(theme=…)`;
+`_CDN_CSS` and `_DEFAULTS` already carried it at `6.7.5`.
+
+- **CSS only, and no jQuery.** Foundation's interactive components — Reveal,
+ Tabs, Accordion, Dropdown Menu — are jQuery plugins. Loading them would make a
+ theme choice change a consuming app's dependency graph, and would put a second
+ owner on state `cf_ui_alpine.js` already holds. The templates use Foundation's
+ classes and markup structure; every piece of state is wired through the
+ existing Alpine components. Asserted per component rather than left to prose.
+
+- **Three places where Foundation's CSS assumes its JS is present**, and what
+ each does instead:
+ - `.reveal` has no open-state class — Foundation's Reveal writes
+ `style.display` directly — so the modal toggles inline display rather than a
+ class, and the E2E tier asserts visibility instead of a class list.
+ - `.accordion-content` is `display: none` with no un-hiding rule in the
+ stylesheet, so the panel stays off the accordion entirely and `x-show` owns
+ display, seeded from `data-cf-open`. Same shape as the bug #21 fixed.
+ - The navbar collapse uses `hide-for-small-only`, not `hide`: `hide` would
+ collapse the desktop menu too, and with Alpine off no class is emitted at
+ all, so the menu is simply visible.
+
+- **`aria-selected` on a tab is load-bearing for appearance here.**
+ `.tabs-title > a[aria-selected=true]` is the rule that restyles the selected
+ tab; `.is-active` on the `
` alone changes nothing visually. Both are
+ rendered server-side and both keep their Alpine binding. The tab panel is
+ `.tabs-content` rather than `.tabs-panel`, because this widget has one
+ always-shown HTMX-swapped panel, and `.tabs-panel` is hidden until
+ `.is-active` picks one of several siblings.
+
+- **Variant vocabulary maps inside the partial**, as designed: Foundation's
+ callout uses bare `alert` / `success` / `warning` with no `is-` prefix, and has
+ no `info` variant — `type="info"` maps to `primary`. Public prop values are
+ unchanged. `progress` needs a `.progress-meter` child because 6.7.5 does not
+ style a native ``; a zero `max` yields 0% rather than a
+ `ZeroDivisionError` on an empty result set.
+
+- Tab ids reach Alpine through `$el.dataset.cfTab`, never as interpolated
+ expression source (#32) — applied here so the theme does not land with the bug
+ and need patching twice.
+
### Added — Bootstrap JS decision record and a version tripwire (#33)
- **`docs/bootstrap.md`.** The CSS-only, Alpine-driven stance was a decision
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/breadcrumb.html b/src/cf_ui/templates/cotton/_themes/foundation/breadcrumb.html
new file mode 100644
index 0000000..693ebf0
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/breadcrumb.html
@@ -0,0 +1,15 @@
+{% comment %} Foundation draws the "/" separator with `.breadcrumbs li:not(:last-child)::after`,
+ so the markup must not carry one of its own. {% endcomment %}
+
+
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/card.html b/src/cf_ui/templates/cotton/_themes/foundation/card.html
new file mode 100644
index 0000000..9b08588
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/card.html
@@ -0,0 +1,13 @@
+{% comment %} Foundation has no `.card-footer`; a trailing `.card-divider` is the
+ documented footer convention. {% endcomment %}
+
+ {% if header %}
+
+
{{ header }}
+
+ {% endif %}
+
{{ slot }}
+ {% if footer %}
+
{{ footer }}
+ {% endif %}
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/checkbox-group.html b/src/cf_ui/templates/cotton/_themes/foundation/checkbox-group.html
new file mode 100644
index 0000000..3cf4467
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/checkbox-group.html
@@ -0,0 +1,17 @@
+{% comment %} Foundation groups checkboxes in a fieldset/legend and pairs a *sibling*
+ `label[for]` with each input rather than wrapping it, so every box needs an
+ id of its own — a shared one would point every label at the first input. {% endcomment %}
+
+ {{ label }}
+
+ {% for choice in choices %}
+
+ {{ choice.label }}
+ {% endfor %}
+
+ {% if error %}{{ error }} {% endif %}
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/form-field.html b/src/cf_ui/templates/cotton/_themes/foundation/form-field.html
new file mode 100644
index 0000000..86b492d
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/form-field.html
@@ -0,0 +1,17 @@
+{% comment %} `.form-error` is `display: none` until `.is-visible` joins it — emitting the
+ text without it renders an error nobody can see. These three classes are the
+ whole of what Foundation's Abide plugin does at runtime, so rendering them
+ server-side is a complete substitute for loading it. {% endcomment %}
+
+ {{ label }}
+
+ {% if error %}
+ {{ error }}
+ {% endif %}
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/modal.html b/src/cf_ui/templates/cotton/_themes/foundation/modal.html
new file mode 100644
index 0000000..5c53501
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/modal.html
@@ -0,0 +1,32 @@
+{% comment %} Foundation ships no open-state class for Reveal. Both `.reveal` and
+ `.reveal-overlay` are `display: none` in the stylesheet, and the jQuery
+ plugin opens them by writing an inline `display` — there is no `.is-active`
+ to bind. `x-show` cannot stand in either: it *removes* the inline property
+ to show, which falls straight back to the rule that hid it. So the state
+ binding is `:style`, and the inner `.reveal` carries the static
+ `display: block` the plugin would otherwise have left on it.
+
+ The overlay is the root because `.reveal` is `position: relative; top: 100px`
+ — it is only centred and scrollable because the fixed, full-screen overlay
+ is its container. The plugin injects that wrapper at runtime; here it is
+ just markup. {% endcomment %}
+
+
+
+ ×
+
+ {% if header %}
{{ header }} {% endif %}
+
{{ slot }}
+ {% if footer %}
+
{{ footer }}
+ {% endif %}
+
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/navbar.html b/src/cf_ui/templates/cotton/_themes/foundation/navbar.html
new file mode 100644
index 0000000..fafe372
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/navbar.html
@@ -0,0 +1,27 @@
+{% comment %} `hide-for-small-only`, not `hide`. `.hide` is `display: none !important` at
+ every width, so binding it to `!menuOpen` would collapse the desktop menu
+ too; the breakpoint-scoped class reproduces what Foundation's own responsive
+ toggle does. With Alpine off no class is emitted at all, so the menu is
+ simply visible — which is the right no-JS answer.
+
+ Foundation's `data-responsive-toggle` / `data-toggle` are inert without
+ `foundation.responsiveToggle.js`, so the burger is wired to cfNavbar. {% endcomment %}
+
+
+ {% if brand %}
+
+ {% endif %}
+
+
+
+
+
+
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/notification.html b/src/cf_ui/templates/cotton/_themes/foundation/notification.html
new file mode 100644
index 0000000..86b0921
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/notification.html
@@ -0,0 +1,17 @@
+{% comment %} Foundation's callout variants carry no `is-` prefix, and there is no `info`
+ — `primary` is the stand-in. The public prop vocabulary does not change.
+
+ `.close-button` is CSS-only styling; `data-closable` needs the Toggler
+ plugin, so the dismiss is Alpine's. `.callout` is `position: relative`
+ precisely so the absolutely-positioned close button anchors to it. {% endcomment %}
+
+
{{ message }}
+ {% if dismissible == "true" %}
+
+ ×
+
+ {% endif %}
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/pagination.html b/src/cf_ui/templates/cotton/_themes/foundation/pagination.html
new file mode 100644
index 0000000..66fb5d1
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/pagination.html
@@ -0,0 +1,39 @@
+{% load cf_ui %}
+{% comment %} Foundation generates the « / » arrows from `.pagination-previous a::before`
+ and `.pagination-next a::after`, and disabled edges are bare text rather
+ than links (`.pagination-previous.disabled::before` covers that case). {% endcomment %}
+
+
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/panel.html b/src/cf_ui/templates/cotton/_themes/foundation/panel.html
new file mode 100644
index 0000000..c1a90e9
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/panel.html
@@ -0,0 +1,30 @@
+{% comment %} Deliberately not Foundation's accordion. `.accordion-content` is
+ `display: none` and — unlike `.tabs-panel` — the stylesheet carries *no*
+ rule that ever un-hides it; the plugin opens it with an inline
+ `slideDown()`. A server-open accordion panel would therefore be invisible
+ with Alpine off, which is exactly what the accessibility contract forbids.
+ `.card-section` has no such rule, so `x-show` + `x-cloak` behave here the
+ way they do in every other theme. {% endcomment %}
+
+
+ {{ title }}
+ ▶
+
+
+ {{ slot }}
+
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/progress.html b/src/cf_ui/templates/cotton/_themes/foundation/progress.html
new file mode 100644
index 0000000..618553c
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/progress.html
@@ -0,0 +1,21 @@
+{% comment %} Not a native ``. Foundation 6.7.5's compiled CSS gives the bare
+ element only `vertical-align: baseline`; every colour rule is
+ `.progress. .progress-meter`, which needs a child the native
+ control cannot have. So the ARIA the native element would have supplied is
+ written out by hand, and the meter width is an inline style because
+ Foundation ships no width utility for it. `widthratio` returns "0" on a
+ zero max rather than raising. `tabindex` is deliberately omitted — a
+ progress bar is not an interactive stop. {% endcomment %}
+
+ {% if label %}
{{ label }}
{% endif %}
+
+
+ {% widthratio value max 100 %}%
+
+
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/select.html b/src/cf_ui/templates/cotton/_themes/foundation/select.html
new file mode 100644
index 0000000..e7a90ae
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/select.html
@@ -0,0 +1,16 @@
+
+ {{ label }}
+
+ {% for opt in options %}
+
+ {{ opt.label }}
+
+ {% endfor %}
+
+ {% if error %}
+ {{ error }}
+ {% endif %}
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/table.html b/src/cf_ui/templates/cotton/_themes/foundation/table.html
new file mode 100644
index 0000000..6c482c0
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/table.html
@@ -0,0 +1,23 @@
+{% load cf_ui %}
+{% comment %} Foundation styles the `` element itself — there is no `.table` class.
+ `.table-scroll` is the 6.2+ wrapper; `table.scroll` is the legacy form. {% endcomment %}
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/tabs.html b/src/cf_ui/templates/cotton/_themes/foundation/tabs.html
new file mode 100644
index 0000000..5df31d9
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/tabs.html
@@ -0,0 +1,32 @@
+{% comment %} `.tabs-title > a[aria-selected=true]` is what actually restyles the selected
+ tab — `.is-active` on the `` alone changes nothing visually — so the
+ aria attribute is load-bearing for appearance here, not only for the reader.
+ Both are rendered server-side and both keep their Alpine binding.
+
+ The panel is `.tabs-content` rather than `.tabs-panel`: this widget has one
+ panel that is always shown and HTMX-swapped, whereas `.tabs-panel` is
+ `display: none` until `.is-active` picks one of several siblings. {% endcomment %}
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/textarea.html b/src/cf_ui/templates/cotton/_themes/foundation/textarea.html
new file mode 100644
index 0000000..1004efd
--- /dev/null
+++ b/src/cf_ui/templates/cotton/_themes/foundation/textarea.html
@@ -0,0 +1,11 @@
+
+ {{ label }}
+
+ {% if error %}
+ {{ error }}
+ {% endif %}
+
diff --git a/src/cf_ui/templates/cotton/foundation/PLANNED.md b/src/cf_ui/templates/cotton/foundation/PLANNED.md
deleted file mode 100644
index 2c70743..0000000
--- a/src/cf_ui/templates/cotton/foundation/PLANNED.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Theme: Foundation — Planned
-
-Components for this theme are tracked in GitHub Issues.
diff --git a/src/cf_ui/templates/jinja/foundation/Breadcrumb.jinja b/src/cf_ui/templates/jinja/foundation/Breadcrumb.jinja
new file mode 100644
index 0000000..a0b2092
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Breadcrumb.jinja
@@ -0,0 +1,18 @@
+{#def items=[], extra_class="" #}
+{% set items = items if items is defined else [] %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{# Foundation draws the "/" separator with `.breadcrumbs li:not(:last-child)::after`,
+ so the markup must not carry one of its own. #}
+
+
+
diff --git a/src/cf_ui/templates/jinja/foundation/Card.jinja b/src/cf_ui/templates/jinja/foundation/Card.jinja
new file mode 100644
index 0000000..2909b90
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Card.jinja
@@ -0,0 +1,18 @@
+{#def content="", header="", footer="", extra_class="" #}
+{% set header = header if header is defined else "" %}
+{% set content = content if content is defined else "" %}
+{% set footer = footer if footer is defined else "" %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{# Foundation has no `.card-footer`; a trailing `.card-divider` is the
+ documented footer convention. #}
+
diff --git a/src/cf_ui/templates/jinja/foundation/CheckboxGroup.jinja b/src/cf_ui/templates/jinja/foundation/CheckboxGroup.jinja
new file mode 100644
index 0000000..e32817b
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/CheckboxGroup.jinja
@@ -0,0 +1,23 @@
+{#def name, label, choices=[], selected=[], error="", extra_class="", control_class="" #}
+{% set choices = choices if choices is defined else [] %}
+{% set selected = selected if selected is defined else [] %}
+{% set error = error if error is defined else "" %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{% set control_class = control_class if control_class is defined else "" %}
+{# Foundation groups checkboxes in a fieldset/legend and pairs a *sibling*
+ `label[for]` with each input rather than wrapping it, so every box needs an
+ id of its own — a shared one would point every label at the first input. #}
+
diff --git a/src/cf_ui/templates/jinja/foundation/FormField.jinja b/src/cf_ui/templates/jinja/foundation/FormField.jinja
new file mode 100644
index 0000000..df8bb35
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/FormField.jinja
@@ -0,0 +1,24 @@
+{#def name, label, value="", error="", type="text", required=false, extra_class="", input_class="" #}
+{% set value = value if value is defined else "" %}
+{% set error = error if error is defined else "" %}
+{% set type = type if type is defined else "text" %}
+{% set required = required if required is defined else false %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{% set input_class = input_class if input_class is defined else "" %}
+{# `.form-error` is `display: none` until `.is-visible` joins it — emitting the
+ text without it renders an error nobody can see. These three classes are the
+ whole of what Foundation's Abide plugin does at runtime, so rendering them
+ server-side is a complete substitute for loading it. #}
+
diff --git a/src/cf_ui/templates/jinja/foundation/Modal.jinja b/src/cf_ui/templates/jinja/foundation/Modal.jinja
new file mode 100644
index 0000000..00256d0
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Modal.jinja
@@ -0,0 +1,39 @@
+{#def id="modal", header="", content="", footer="", label="Dialog", extra_class="" #}
+{% set id = id if id is defined else "modal" %}
+{% set header = header if header is defined else "" %}
+{% set content = content if content is defined else "" %}
+{% set footer = footer if footer is defined else "" %}
+{% set label = label if label is defined and label else "Dialog" %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{# Foundation ships no open-state class for Reveal. Both `.reveal` and
+ `.reveal-overlay` are `display: none` in the stylesheet, and the jQuery
+ plugin opens them by writing an inline `display` — there is no `.is-active`
+ to bind. `x-show` cannot stand in either: it *removes* the inline property
+ to show, which falls straight back to the rule that hid it. So the state
+ binding is `:style`, and the inner `.reveal` carries the static
+ `display: block` the plugin would otherwise have left on it.
+
+ The overlay is the root because `.reveal` is `position: relative; top: 100px`
+ — it is only centred and scrollable because the fixed, full-screen overlay
+ is its container. The plugin injects that wrapper at runtime; here it is
+ just markup. #}
+
diff --git a/src/cf_ui/templates/jinja/foundation/Navbar.jinja b/src/cf_ui/templates/jinja/foundation/Navbar.jinja
new file mode 100644
index 0000000..49246b9
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Navbar.jinja
@@ -0,0 +1,32 @@
+{#def brand="", start="", end="", extra_class="" #}
+{% set brand = brand if brand is defined else "" %}
+{% set start = start if start is defined else "" %}
+{% set end = end if end is defined else "" %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{# `hide-for-small-only`, not `hide`. `.hide` is `display: none !important` at
+ every width, so binding it to `!menuOpen` would collapse the desktop menu
+ too; the breakpoint-scoped class reproduces what Foundation's own responsive
+ toggle does. With Alpine off no class is emitted at all, so the menu is
+ simply visible — which is the right no-JS answer.
+
+ Foundation's `data-responsive-toggle` / `data-toggle` are inert without
+ `foundation.responsiveToggle.js`, so the burger is wired to cfNavbar. #}
+
diff --git a/src/cf_ui/templates/jinja/foundation/Notification.jinja b/src/cf_ui/templates/jinja/foundation/Notification.jinja
new file mode 100644
index 0000000..7c66844
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Notification.jinja
@@ -0,0 +1,21 @@
+{#def message, type="info", dismissible=true, extra_class="" #}
+{% set type = type if type is defined else "info" %}
+{% set dismissible = dismissible if dismissible is defined else true %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{# Foundation's callout variants carry no `is-` prefix, and there is no `info`
+ — `primary` is the stand-in. The public prop vocabulary does not change.
+
+ `.close-button` is CSS-only styling; `data-closable` needs the Toggler
+ plugin, so the dismiss is Alpine's. `.callout` is `position: relative`
+ precisely so the absolutely-positioned close button anchors to it. #}
+
diff --git a/src/cf_ui/templates/jinja/foundation/PLANNED.md b/src/cf_ui/templates/jinja/foundation/PLANNED.md
deleted file mode 100644
index 2c70743..0000000
--- a/src/cf_ui/templates/jinja/foundation/PLANNED.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Theme: Foundation — Planned
-
-Components for this theme are tracked in GitHub Issues.
diff --git a/src/cf_ui/templates/jinja/foundation/Pagination.jinja b/src/cf_ui/templates/jinja/foundation/Pagination.jinja
new file mode 100644
index 0000000..7400dfa
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Pagination.jinja
@@ -0,0 +1,44 @@
+{#def page=1, total_pages=1, hx_target="", hx_url="", extra_class="" #}
+{% set page = page if page is defined else 1 %}
+{% set total_pages = total_pages if total_pages is defined else 1 %}
+{% set hx_target = hx_target if hx_target is defined else "" %}
+{% set hx_url = hx_url if hx_url is defined else "" %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{# Foundation generates the « / » arrows from `.pagination-previous a::before`
+ and `.pagination-next a::after`, and disabled edges are bare text rather
+ than links (`.pagination-previous.disabled::before` covers that case). #}
+
+
+
diff --git a/src/cf_ui/templates/jinja/foundation/Panel.jinja b/src/cf_ui/templates/jinja/foundation/Panel.jinja
new file mode 100644
index 0000000..411864c
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Panel.jinja
@@ -0,0 +1,35 @@
+{#def title, id="panel", content="", open=false, extra_class="" #}
+{% set content = content if content is defined else "" %}
+{% set id = id if id is defined else "panel" %}
+{% set open = open if open is defined else false %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{# Deliberately not Foundation's accordion. `.accordion-content` is
+ `display: none` and — unlike `.tabs-panel` — the stylesheet carries *no*
+ rule that ever un-hides it; the plugin opens it with an inline
+ `slideDown()`. A server-open accordion panel would therefore be invisible
+ with Alpine off, which is exactly what the accessibility contract forbids.
+ `.card-section` has no such rule, so `x-show` + `x-cloak` behave here the
+ way they do in every other theme. #}
+
diff --git a/src/cf_ui/templates/jinja/foundation/Progress.jinja b/src/cf_ui/templates/jinja/foundation/Progress.jinja
new file mode 100644
index 0000000..e69b73a
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Progress.jinja
@@ -0,0 +1,27 @@
+{#def value=0, max=100, type="primary", label="", extra_class="" #}
+{% set value = value if value is defined else 0 %}
+{% set max = max if max is defined else 100 %}
+{% set type = type if type is defined else "primary" %}
+{% set label = label if label is defined else "" %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{% set pct = ((value | float) * 100 / (max | float)) | round | int if (max | float) else 0 %}
+{# Not a native ``. Foundation 6.7.5's compiled CSS gives the bare
+ element only `vertical-align: baseline`; every colour rule is
+ `.progress. .progress-meter`, which needs a child the native
+ control cannot have. So the ARIA the native element would have supplied is
+ written out by hand, and the meter width is an inline style because
+ Foundation ships no width utility for it. `tabindex` is deliberately
+ omitted — a progress bar is not an interactive stop. #}
+
diff --git a/src/cf_ui/templates/jinja/foundation/Select.jinja b/src/cf_ui/templates/jinja/foundation/Select.jinja
new file mode 100644
index 0000000..a0e01b2
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Select.jinja
@@ -0,0 +1,22 @@
+{#def name, label, value="", error="", options=[], extra_class="", input_class="" #}
+{% set value = value if value is defined else "" %}
+{% set error = error if error is defined else "" %}
+{% set options = options if options is defined else [] %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{% set input_class = input_class if input_class is defined else "" %}
+
diff --git a/src/cf_ui/templates/jinja/foundation/Table.jinja b/src/cf_ui/templates/jinja/foundation/Table.jinja
new file mode 100644
index 0000000..f7c0ad6
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Table.jinja
@@ -0,0 +1,26 @@
+{#def columns=[], rows=[], hx_target="", hx_url="", extra_class="" #}
+{% set columns = columns if columns is defined else [] %}
+{% set rows = rows if rows is defined else [] %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{# Foundation styles the `` element itself — there is no `.table` class.
+ `.table-scroll` is the 6.2+ wrapper; `table.scroll` is the legacy form. #}
+
diff --git a/src/cf_ui/templates/jinja/foundation/Tabs.jinja b/src/cf_ui/templates/jinja/foundation/Tabs.jinja
new file mode 100644
index 0000000..8e09a7d
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Tabs.jinja
@@ -0,0 +1,38 @@
+{#def tabs=[], hx_target="tab-content", active="", content="", extra_class="" #}
+{% set tabs = tabs if tabs is defined else [] %}
+{% set content = content if content is defined else "" %}
+{% set active = active if active is defined else "" %}
+{% set hx_target = hx_target if hx_target is defined else "tab-content" %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{# `.tabs-title > a[aria-selected=true]` is what actually restyles the selected
+ tab — `.is-active` on the `` alone changes nothing visually — so the
+ aria attribute is load-bearing for appearance here, not only for the reader.
+ Both are rendered server-side and both keep their Alpine binding.
+
+ The panel is `.tabs-content` rather than `.tabs-panel`: this widget has one
+ panel that is always shown and HTMX-swapped, whereas `.tabs-panel` is
+ `display: none` until `.is-active` picks one of several siblings. #}
+
diff --git a/src/cf_ui/templates/jinja/foundation/Textarea.jinja b/src/cf_ui/templates/jinja/foundation/Textarea.jinja
new file mode 100644
index 0000000..456a558
--- /dev/null
+++ b/src/cf_ui/templates/jinja/foundation/Textarea.jinja
@@ -0,0 +1,17 @@
+{#def name, label, value="", error="", rows=4, extra_class="", input_class="" #}
+{% set value = value if value is defined else "" %}
+{% set error = error if error is defined else "" %}
+{% set rows = rows if rows is defined else 4 %}
+{% set extra_class = extra_class if extra_class is defined else "" %}
+{% set input_class = input_class if input_class is defined else "" %}
+
diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py
index 42cdde9..209e6d3 100644
--- a/src/cf_ui/themes.py
+++ b/src/cf_ui/themes.py
@@ -26,7 +26,7 @@
#: Themes with a real component set. The other directories under
#: ``templates/`` hold a ``PLANNED.md`` stub and are not selectable.
-THEMES = ("bulma", "daisy", "bootstrap")
+THEMES = ("bulma", "daisy", "bootstrap", "foundation")
DEFAULT_THEME = "bulma"
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index 449a84b..add13e3 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -95,6 +95,25 @@ def bootstrap_cotton_server_url() -> Generator[str, None, None]:
proc.wait(timeout=5)
+@pytest.fixture(scope="session")
+def foundation_jinja_server_url() -> Generator[str, None, None]:
+ thread = threading.Thread(target=_run_fastapi_server, args=("foundation", "127.0.0.1", 8777))
+ thread.daemon = True
+ thread.start()
+ time.sleep(2.0)
+ yield "http://127.0.0.1:8777"
+
+
+@pytest.fixture(scope="session")
+def foundation_cotton_server_url() -> Generator[str, None, None]:
+ """The same consumer templates again, CF_UI_THEME="foundation"."""
+ proc = _start_cotton_server(8778, "foundation")
+ time.sleep(2.0)
+ yield "http://127.0.0.1:8778"
+ proc.terminate()
+ proc.wait(timeout=5)
+
+
@pytest.fixture(params=["js_on", "js_off"])
def jinja_page(request, browser, jinja_server_url):
ctx = browser.new_context(java_script_enabled=(request.param == "js_on"))
@@ -147,3 +166,21 @@ def bootstrap_cotton_page(request, browser, bootstrap_cotton_server_url):
page.set_default_timeout(5000)
yield page, request.param
ctx.close()
+
+
+@pytest.fixture(params=["js_on", "js_off"])
+def foundation_jinja_page(request, browser, foundation_jinja_server_url):
+ ctx = browser.new_context(java_script_enabled=(request.param == "js_on"))
+ page = ctx.new_page()
+ page.set_default_timeout(5000)
+ yield page, request.param
+ ctx.close()
+
+
+@pytest.fixture(params=["js_on", "js_off"])
+def foundation_cotton_page(request, browser, foundation_cotton_server_url):
+ ctx = browser.new_context(java_script_enabled=(request.param == "js_on"))
+ page = ctx.new_page()
+ page.set_default_timeout(5000)
+ yield page, request.param
+ ctx.close()
diff --git a/tests/e2e/test_foundation.py b/tests/e2e/test_foundation.py
new file mode 100644
index 0000000..be4beba
--- /dev/null
+++ b/tests/e2e/test_foundation.py
@@ -0,0 +1,409 @@
+"""Foundation E2E coverage (issue #23), parameterized over js_on / js_off.
+
+Two things make this suite different from the Bulma and DaisyUI ones, and both
+come from the same constraint — the theme loads Foundation's **CSS only**:
+
+1. Reveal has no open-state class, so "is the modal open" cannot be asserted
+ with `to_have_class`. Every visibility claim here reads the **computed
+ display**, which is the property that actually matters and the only one that
+ would catch a binding that changes markup without changing the page.
+2. `test_no_page_loads_jquery` is the executable form of the issue's "grep the
+ rendered output for jquery" step. Adding jQuery for one theme would make a
+ `CF_UI_THEME` edit change a consuming app's dependency graph.
+"""
+
+import pytest
+from playwright.sync_api import expect
+
+DISPLAY = "el => getComputedStyle(el).display"
+
+
+def _wait_for_alpine(page) -> None:
+ page.wait_for_function(
+ "() => window.Alpine !== undefined && document.querySelectorAll('[x-cloak]').length === 0",
+ timeout=8000,
+ )
+
+
+def _wait_for_modal_focus(page) -> None:
+ """Wait for the dialog to actually hold focus — see the Bulma suite.
+
+ `_focusFirst` retries across animation frames, so a one-shot read of
+ `document.activeElement` right after the reveal is a race that only a slow
+ machine loses.
+ """
+ page.wait_for_function(
+ "() => document.getElementById('e2e-modal').contains(document.activeElement)",
+ timeout=5000,
+ )
+
+
+def _wait_for_modal_shown(page, shown: bool) -> None:
+ op = "!==" if shown else "==="
+ page.wait_for_function(
+ f"() => getComputedStyle(document.getElementById('e2e-modal')).display {op} 'none'",
+ timeout=5000,
+ )
+
+
+# --- The CSS-only constraint, executed -------------------------------------
+
+
+def test_no_page_loads_jquery(foundation_jinja_page, foundation_jinja_server_url):
+ """Issue #23's verification step, as a test rather than a one-off grep."""
+ page, _ = foundation_jinja_page
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ assert "jquery" not in page.content().lower()
+
+
+def test_no_cotton_page_loads_jquery(foundation_cotton_page, foundation_cotton_server_url):
+ page, _ = foundation_cotton_page
+ page.goto(f"{foundation_cotton_server_url}/modal/")
+ assert "jquery" not in page.content().lower()
+
+
+def test_foundation_js_is_never_initialised(foundation_jinja_page, foundation_jinja_server_url):
+ """`window.Foundation` existing would mean the plugins got loaded."""
+ page, js_mode = foundation_jinja_page
+ if js_mode != "js_on":
+ pytest.skip("nothing to evaluate without JS")
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ _wait_for_alpine(page)
+ assert page.evaluate("() => typeof window.jQuery") == "undefined"
+ assert page.evaluate("() => typeof window.Foundation") == "undefined"
+
+
+# --- JinjaX: same Alpine contract, Foundation classes ----------------------
+
+
+def test_jinja_modal_opens_and_closes(foundation_jinja_page, foundation_jinja_server_url):
+ """Reveal ships no state class, so this reads computed display.
+
+ `.reveal-overlay` is `display: none` in Foundation's stylesheet with no
+ counterpart rule; the template drives an inline `display` from Alpine
+ exactly as the jQuery plugin would have.
+ """
+ page, js_mode = foundation_jinja_page
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+
+ modal = page.locator("#e2e-modal")
+ if js_mode == "js_on":
+ _wait_for_alpine(page)
+ assert modal.evaluate(DISPLAY) == "none"
+ page.evaluate("Alpine.store('cf').modal.open('e2e-modal')")
+ _wait_for_modal_shown(page, shown=True)
+ modal.locator("button[aria-label='close']").click()
+ _wait_for_modal_shown(page, shown=False)
+ else:
+ # Without JS the stylesheet alone keeps it closed — which is the point
+ # of not needing a state class to hide it.
+ expect(modal).to_be_attached()
+ assert modal.evaluate(DISPLAY) == "none"
+
+
+def test_jinja_notification_dismisses(foundation_jinja_page, foundation_jinja_server_url):
+ page, js_mode = foundation_jinja_page
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ notification = page.locator(".callout")
+
+ if js_mode == "js_on":
+ _wait_for_alpine(page)
+ expect(notification).to_be_visible()
+ notification.locator("button[aria-label='dismiss']").click()
+ expect(notification).to_be_hidden()
+ else:
+ expect(notification).to_be_attached()
+
+
+def test_jinja_panel_expands(foundation_jinja_page, foundation_jinja_server_url):
+ page, js_mode = foundation_jinja_page
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ panel_body = page.locator("#e2e-panel-body")
+
+ if js_mode == "js_on":
+ _wait_for_alpine(page)
+ expect(panel_body).to_be_hidden()
+ page.locator('button[aria-controls="e2e-panel-body"]').click()
+ expect(panel_body).to_be_visible()
+ else:
+ expect(panel_body).to_be_attached()
+
+
+def test_jinja_navbar_burger_toggles_menu(foundation_jinja_page, foundation_jinja_server_url):
+ """`hide-for-small-only` is breakpoint-scoped on purpose.
+
+ Binding plain `.hide` would have collapsed the desktop menu too. Asserting
+ the computed display at a small viewport is what distinguishes the two.
+ """
+ page, js_mode = foundation_jinja_page
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+
+ if js_mode == "js_on":
+ _wait_for_alpine(page)
+ page.set_viewport_size({"width": 600, "height": 800})
+ menu = page.locator(".top-bar-right")
+ assert menu.evaluate(DISPLAY) == "none"
+ page.locator("button[aria-label='menu']").click()
+ assert menu.evaluate(DISPLAY) != "none"
+ else:
+ expect(page.locator(".top-bar")).to_be_attached()
+
+
+def test_jinja_navbar_menu_survives_at_desktop_width(
+ foundation_jinja_page, foundation_jinja_server_url
+):
+ """The regression `hide-for-small-only` exists to prevent."""
+ page, js_mode = foundation_jinja_page
+ if js_mode != "js_on":
+ pytest.skip("the collapse only happens under Alpine")
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ _wait_for_alpine(page)
+ page.set_viewport_size({"width": 1280, "height": 800})
+ assert page.locator(".top-bar-right").evaluate(DISPLAY) != "none"
+
+
+def test_jinja_tabs_activate(foundation_jinja_page, foundation_jinja_server_url):
+ page, js_mode = foundation_jinja_page
+ if js_mode != "js_on":
+ pytest.skip("covered without JS by test_jinja_exactly_one_tab_is_active")
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ _wait_for_alpine(page)
+
+ tabs = page.locator("[role='tab']")
+ expect(page.locator("li.tabs-title.is-active")).to_have_text("tab1")
+ tabs.nth(1).click()
+ expect(page.locator("li.tabs-title.is-active")).to_have_text("tab2")
+ expect(page.locator("li.tabs-title.is-active")).to_have_count(1)
+
+
+def test_jinja_exactly_one_tab_is_active(foundation_jinja_page, foundation_jinja_server_url):
+ page, js_mode = foundation_jinja_page
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ if js_mode == "js_on":
+ _wait_for_alpine(page)
+ expect(page.locator("li.tabs-title.is-active")).to_have_count(1)
+ expect(page.locator("li.tabs-title.is-active")).to_have_text("tab1")
+ expect(page.locator("[role='tab'][aria-selected='true']")).to_have_count(1)
+ expect(page.locator("[role='tab'][tabindex='0']")).to_have_count(1)
+
+
+def test_jinja_arrow_keys_rove_focus_across_the_tablist(
+ foundation_jinja_page, foundation_jinja_server_url
+):
+ page, js_mode = foundation_jinja_page
+ if js_mode != "js_on":
+ pytest.skip("roving tabindex requires JS")
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ _wait_for_alpine(page)
+
+ page.locator("[role='tab']").first.focus()
+ page.keyboard.press("ArrowRight")
+ assert page.evaluate("() => document.activeElement.dataset.cfTab") == "tab2"
+ page.keyboard.press("Home")
+ assert page.evaluate("() => document.activeElement.dataset.cfTab") == "tab1"
+
+
+# --- Dialog semantics and focus management (#21) ---------------------------
+
+
+def test_jinja_modal_declares_dialog_semantics(foundation_jinja_page, foundation_jinja_server_url):
+ page, _ = foundation_jinja_page
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ modal = page.locator("#e2e-modal")
+ expect(modal).to_have_attribute("role", "dialog")
+ expect(modal).to_have_attribute("aria-modal", "true")
+ expect(modal).to_have_attribute("aria-labelledby", "e2e-modal-title")
+
+
+def test_jinja_modal_manages_focus(foundation_jinja_page, foundation_jinja_server_url):
+ """Same Alpine contract as Bulma — the theme changes classes, not behavior."""
+ page, js_mode = foundation_jinja_page
+ if js_mode != "js_on":
+ pytest.skip("focus management requires JS")
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ _wait_for_alpine(page)
+
+ page.locator("#open-modal").click()
+ _wait_for_modal_focus(page)
+ page.keyboard.press("Escape")
+ _wait_for_modal_shown(page, shown=False)
+ assert page.evaluate("() => document.activeElement.id") == "open-modal"
+
+
+def test_jinja_tab_does_not_escape_the_open_modal(
+ foundation_jinja_page, foundation_jinja_server_url
+):
+ page, js_mode = foundation_jinja_page
+ if js_mode != "js_on":
+ pytest.skip("focus management requires JS")
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ _wait_for_alpine(page)
+
+ page.locator("#open-modal").click()
+ _wait_for_modal_focus(page)
+ inside = "() => document.getElementById('e2e-modal').contains(document.activeElement)"
+ for _ in range(6):
+ page.keyboard.press("Tab")
+ assert page.evaluate(inside), "focus escaped the dialog on Tab"
+
+
+def test_jinja_open_panel_is_readable_without_js(
+ foundation_jinja_page, foundation_jinja_server_url
+):
+ """The reason the panel is not built from Foundation's accordion.
+
+ `.accordion-content` is `display: none` with no rule that ever un-hides it,
+ so this assertion is exactly what that markup would have failed.
+ """
+ page, js_mode = foundation_jinja_page
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ if js_mode == "js_on":
+ _wait_for_alpine(page)
+ expect(page.locator("#e2e-panel-open-body")).to_be_visible()
+
+
+def test_jinja_panel_toggle_reports_its_state(foundation_jinja_page, foundation_jinja_server_url):
+ page, js_mode = foundation_jinja_page
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ if js_mode == "js_on":
+ _wait_for_alpine(page)
+ expect(page.locator('button[aria-controls="e2e-panel-body"]')).to_have_attribute(
+ "aria-expanded", "false"
+ )
+ expect(page.locator('button[aria-controls="e2e-panel-open-body"]')).to_have_attribute(
+ "aria-expanded", "true"
+ )
+
+
+def test_jinja_form_field_renders(foundation_jinja_page, foundation_jinja_server_url):
+ page, _ = foundation_jinja_page
+ page.goto(f"{foundation_jinja_server_url}/form-field")
+ expect(page.locator("input[name='email']")).to_be_visible()
+ expect(page.locator("label[for='email']")).to_have_text("Email")
+
+
+def test_jinja_page_usable_without_js(foundation_jinja_page, foundation_jinja_server_url):
+ page, js_mode = foundation_jinja_page
+ if js_mode != "js_off":
+ pytest.skip("only runs in js_off mode")
+ page.goto(f"{foundation_jinja_server_url}/gallery")
+ expect(page.locator("body")).not_to_be_empty()
+ expect(page.locator(".top-bar")).to_be_visible()
+
+
+# --- django-cotton: one setting, no consumer template edits ----------------
+
+
+def test_cotton_form_field_renders_foundation_markup(
+ foundation_cotton_page, foundation_cotton_server_url
+):
+ page, _ = foundation_cotton_page
+ page.goto(f"{foundation_cotton_server_url}/form-field/")
+ expect(page.locator("input[name='email']")).to_be_attached()
+ expect(page.locator("label[for='email']")).to_be_attached()
+
+
+def test_cotton_form_field_has_no_bulma_markup(
+ foundation_cotton_page, foundation_cotton_server_url
+):
+ """The Bulma partial must not leak through the dispatch."""
+ page, _ = foundation_cotton_page
+ page.goto(f"{foundation_cotton_server_url}/form-field/")
+ expect(page.locator(".field")).to_have_count(0)
+
+
+def test_cotton_card_renders_foundation_markup(
+ foundation_cotton_page, foundation_cotton_server_url
+):
+ page, _ = foundation_cotton_page
+ page.goto(f"{foundation_cotton_server_url}/card/")
+ expect(page.locator(".card-section")).to_be_attached()
+ expect(page.locator(".card-divider").first).to_contain_text("Card Title")
+
+
+def test_cotton_card_slot_survives_the_dispatch(
+ foundation_cotton_page, foundation_cotton_server_url
+):
+ """`{% include %}` must carry {{ slot }} into the theme partial."""
+ page, _ = foundation_cotton_page
+ page.goto(f"{foundation_cotton_server_url}/card/")
+ expect(page.locator(".card-section")).to_contain_text("Card body content")
+
+
+def test_cotton_modal_named_slot_survives_the_dispatch(
+ foundation_cotton_page, foundation_cotton_server_url
+):
+ page, _ = foundation_cotton_page
+ page.goto(f"{foundation_cotton_server_url}/modal/")
+ expect(page.locator("#test-modal .reveal")).to_contain_text("Test Modal")
+ expect(page.locator("#test-modal .reveal")).to_contain_text("Modal body content")
+
+
+def test_cotton_modal_is_not_server_rendered_open(
+ foundation_cotton_page, foundation_cotton_server_url
+):
+ """The cotton gallery pages are bare fragments — no stylesheet, no Alpine.
+
+ So computed display proves nothing here (it would be measuring the
+ fixture), and Foundation has no `modal-open` class for the DaisyUI version
+ of this test to look for. What *is* assertable is the markup the dispatch
+ produced: the overlay carries the state binding and no server-rendered
+ inline `display`, so it is Alpine — not the server — that ever opens it.
+ Whether it is actually invisible when closed is asserted against the
+ JinjaX gallery, which does load Foundation's CSS.
+ """
+ page, _ = foundation_cotton_page
+ page.goto(f"{foundation_cotton_server_url}/modal/")
+ modal = page.locator("#test-modal")
+ expect(modal).to_be_attached()
+ expect(modal).to_have_attribute(":style", "open ? 'display: block' : 'display: none'")
+ assert modal.get_attribute("style") is None
+
+
+def test_cotton_modal_declares_dialog_semantics(
+ foundation_cotton_page, foundation_cotton_server_url
+):
+ page, _ = foundation_cotton_page
+ page.goto(f"{foundation_cotton_server_url}/modal/")
+ modal = page.locator("#test-modal")
+ expect(modal).to_have_attribute("role", "dialog")
+ expect(modal).to_have_attribute("aria-modal", "true")
+ expect(modal).to_have_attribute("aria-labelledby", "test-modal-title")
+
+
+def test_cotton_tabs_render_the_active_tab(foundation_cotton_page, foundation_cotton_server_url):
+ """`active` has to survive — unit tests bypass that compiler."""
+ page, _ = foundation_cotton_page
+ page.goto(f"{foundation_cotton_server_url}/tabs/")
+ expect(page.locator("li.tabs-title.is-active")).to_have_count(1)
+ expect(page.locator("li.tabs-title.is-active")).to_have_text("tab1")
+ expect(page.locator("[role='tab'][aria-selected='true']")).to_have_count(1)
+
+
+def test_cotton_panel_honors_its_open_prop(foundation_cotton_page, foundation_cotton_server_url):
+ page, _ = foundation_cotton_page
+ page.goto(f"{foundation_cotton_server_url}/panel/")
+ expect(page.locator("#open-panel-body")).not_to_have_attribute("x-cloak", "")
+ expect(page.locator('button[aria-controls="open-panel-body"]')).to_have_attribute(
+ "aria-expanded", "true"
+ )
+ expect(page.locator('button[aria-controls="closed-panel-body"]')).to_have_attribute(
+ "aria-expanded", "false"
+ )
+
+
+def test_cotton_theme_comments_do_not_leak_into_the_page(
+ foundation_cotton_page, foundation_cotton_server_url
+):
+ """Django's `{# #}` is single-line only — a multi-line one renders verbatim.
+
+ Every explanatory comment in the cotton partials is therefore a
+ `{% comment %}` block. This caught a real leak during the phase, so it is
+ pinned rather than left to review.
+ """
+ page, _ = foundation_cotton_page
+ for path in ("/form-field/", "/card/", "/modal/", "/panel/", "/tabs/"):
+ page.goto(f"{foundation_cotton_server_url}{path}")
+ body = page.locator("body").inner_text()
+ assert "{#" not in body
+ assert "{% comment" not in body
diff --git a/tests/integration/jinja_app/main.py b/tests/integration/jinja_app/main.py
index b2ffddf..6f42e2c 100644
--- a/tests/integration/jinja_app/main.py
+++ b/tests/integration/jinja_app/main.py
@@ -12,6 +12,9 @@
"bulma": "https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css",
"daisy": "https://cdn.jsdelivr.net/npm/daisyui@4.7.2/dist/full.min.css",
"bootstrap": "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css",
+ "foundation": (
+ "https://cdn.jsdelivr.net/npm/foundation-sites@6.7.5/dist/css/foundation.min.css"
+ ),
}
# DaisyUI ships component classes but no Tailwind utilities. The components use
@@ -22,9 +25,11 @@
_THEME_EXTRA_HEAD = {
"bulma": "",
"daisy": '',
- # Bootstrap ships prebuilt CSS and cf-ui deliberately loads none of its
- # JavaScript — Alpine owns modal, tab and panel state in every theme.
+ # Bootstrap and Foundation both ship prebuilt CSS, and cf-ui deliberately
+ # loads neither one's JavaScript — Alpine owns modal, tab and panel state in
+ # every theme, so no bootstrap.bundle.js and no foundation.js.
"bootstrap": "",
+ "foundation": "",
}
diff --git a/tests/unit/cotton/test_foundation.py b/tests/unit/cotton/test_foundation.py
new file mode 100644
index 0000000..7ba5423
--- /dev/null
+++ b/tests/unit/cotton/test_foundation.py
@@ -0,0 +1,267 @@
+"""Foundation 6 component set, django-cotton side (issue #23).
+
+These go through the public ``cotton/cf/.html`` entry points with
+``CF_UI_THEME = "foundation"``, so they exercise the dispatch wrapper as well
+as the partial. ``render_to_string`` bypasses the django-cotton compiler (props
+arrive as plain context), which is why the E2E tier still matters — but the
+``{% include %}`` dispatch itself is real Django template machinery and is
+fully exercised here.
+"""
+
+import pytest
+
+
+@pytest.fixture
+def foundation_render(settings, cotton_render):
+ settings.CF_UI_THEME = "foundation"
+ return cotton_render
+
+
+# --- Forms -----------------------------------------------------------------
+
+
+def test_form_field_uses_foundation_error_classes(foundation_render):
+ html = foundation_render("cf/form-field.html", name="email", label="Email", error="Required")
+ assert "is-invalid-input" in html
+ assert "is-invalid-label" in html
+ assert "Required" in html
+
+
+def test_form_field_error_text_is_visible(foundation_render):
+ """`.form-error` is `display: none` without `.is-visible`."""
+ html = foundation_render("cf/form-field.html", name="email", label="Email", error="Required")
+ assert "form-error is-visible" in html
+
+
+def test_form_field_without_an_error_stays_clean(foundation_render):
+ html = foundation_render("cf/form-field.html", name="email", label="Email Address")
+ assert "is-invalid-input" not in html
+ assert "form-error" not in html
+ assert "Email Address" in html
+
+
+def test_form_field_required_flag(foundation_render):
+ html = foundation_render("cf/form-field.html", name="email", label="Email", required="true")
+ assert "required" in html
+
+
+def test_form_field_input_class_still_applied(foundation_render):
+ html = foundation_render("cf/form-field.html", name="e", label="E", input_class="my-input")
+ assert "my-input" in html
+
+
+def test_select_error_uses_the_invalid_input_class(foundation_render):
+ html = foundation_render(
+ "cf/select.html",
+ name="choice",
+ label="Choose",
+ error="Pick one",
+ options=[{"value": "a", "label": "Option A"}],
+ )
+ assert "is-invalid-input" in html
+ assert "Option A" in html
+
+
+def test_textarea_error_uses_the_invalid_input_class(foundation_render):
+ html = foundation_render(
+ "cf/textarea.html", name="bio", label="Bio", value="Hello", rows="4", error="Too short"
+ )
+ assert "is-invalid-input" in html
+ assert "Hello" in html
+
+
+def test_checkbox_group_uses_a_fieldset(foundation_render):
+ html = foundation_render(
+ "cf/checkbox-group.html",
+ name="fruits",
+ label="Fruits",
+ choices=[{"value": "a", "label": "Apple"}],
+ selected=["a"],
+ )
+ assert 'class="fieldset' in html
+ assert "/`` — so this tier proves the foundation directory
+ships all 14 components, that they render under ``StrictUndefined``, and that
+they speak Foundation's class vocabulary rather than Bulma's.
+
+The theme is **CSS only**: Foundation's Reveal, Tabs, Accordion and Dropdown
+Menu are jQuery plugins, and adding jQuery would make a theme choice change a
+consuming app's dependency graph. Alpine keeps owning behaviour, so several
+tests here pin *which* Foundation state mechanism each component uses, because
+Foundation's are not uniform:
+
+* ``.tabs-panel``/``.is-active`` is a real CSS rule — bindable.
+* ``.reveal`` and ``.reveal-overlay`` are ``display: none`` with **no**
+ counterpart rule; the plugin opens them by writing an inline ``display``.
+ So does this theme, via ``:style``.
+* ``.accordion-content`` is ``display: none`` with no un-hiding rule at all,
+ which is why the panel is built from ``card``/``card-section`` instead.
+"""
+
+from collections.abc import Callable
+from pathlib import Path
+
+import pytest
+from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
+
+FOUNDATION_DIR = (
+ Path(__file__).parent.parent.parent.parent
+ / "src"
+ / "cf_ui"
+ / "templates"
+ / "jinja"
+ / "foundation"
+)
+
+COMPONENTS = [
+ "Breadcrumb",
+ "Card",
+ "CheckboxGroup",
+ "FormField",
+ "Modal",
+ "Navbar",
+ "Notification",
+ "Pagination",
+ "Panel",
+ "Progress",
+ "Select",
+ "Table",
+ "Tabs",
+ "Textarea",
+]
+
+# Minimum props each component needs to render at all.
+REQUIRED_PROPS = {
+ "Breadcrumb": {"items": [{"url": "/a", "label": "A"}]},
+ "Card": {},
+ "CheckboxGroup": {"name": "f", "label": "F", "choices": [{"value": "a", "label": "A"}]},
+ "FormField": {"name": "f", "label": "F"},
+ "Modal": {},
+ "Navbar": {},
+ "Notification": {"message": "Hi"},
+ "Pagination": {"page": 1, "total_pages": 3},
+ "Panel": {"title": "T"},
+ "Progress": {},
+ "Select": {"name": "f", "label": "F", "options": [{"value": "a", "label": "A"}]},
+ "Table": {"columns": [{"key": "k", "label": "K"}], "rows": [{"k": "v"}]},
+ "Tabs": {"tabs": [{"id": "one", "url": "/one"}]},
+ "Textarea": {"name": "f", "label": "F"},
+}
+
+# Bulma tokens that must not survive the port. `is-active` is deliberately
+# absent: Foundation uses it too (`.tabs-title.is-active`), so asserting on it
+# would forbid the correct markup.
+BULMA_MARKERS = (
+ "is-danger",
+ "card-header-title",
+ "navbar-burger",
+ "modal-card",
+ "pagination-link",
+)
+
+
+@pytest.fixture
+def render() -> Callable[..., str]:
+ env = Environment(
+ loader=FileSystemLoader(FOUNDATION_DIR),
+ autoescape=select_autoescape(["html"]),
+ undefined=StrictUndefined,
+ )
+
+ def _render(template_name: str, **ctx: object) -> str:
+ return env.get_template(template_name).render(**ctx)
+
+ return _render
+
+
+# --- Parity with the Bulma set ---------------------------------------------
+
+
+def test_foundation_ships_the_same_components_as_bulma():
+ bulma_dir = FOUNDATION_DIR.parent / "bulma"
+ assert sorted(p.name for p in FOUNDATION_DIR.glob("*.jinja")) == sorted(
+ p.name for p in bulma_dir.glob("*.jinja")
+ )
+
+
+def test_the_planned_stub_is_gone():
+ """The stub and a real component set must not coexist."""
+ assert not (FOUNDATION_DIR / "PLANNED.md").exists()
+
+
+@pytest.mark.parametrize("name", COMPONENTS)
+def test_component_renders_with_only_its_required_props(render, name):
+ """StrictUndefined: every optional prop needs an is-defined guard."""
+ html = render(f"{name}.jinja", **REQUIRED_PROPS[name])
+ assert html.strip()
+
+
+@pytest.mark.parametrize("name", COMPONENTS)
+def test_component_carries_no_bulma_class_names(render, name):
+ html = render(f"{name}.jinja", **REQUIRED_PROPS[name])
+ for marker in BULMA_MARKERS:
+ assert marker not in html, f"{name} still speaks Bulma"
+
+
+# --- The CSS-only constraint (#23) -----------------------------------------
+
+
+@pytest.mark.parametrize("name", COMPONENTS)
+def test_no_component_reaches_for_foundation_js(render, name):
+ """No jQuery, and no `data-` hooks that only Foundation's plugins read.
+
+ A theme that quietly required jQuery would change a consuming app's
+ dependency graph as a side effect of a `CF_UI_THEME` edit.
+ """
+ html = render(f"{name}.jinja", **REQUIRED_PROPS[name]).lower()
+ assert "jquery" not in html
+ for plugin_hook in (
+ "data-reveal",
+ "data-toggler",
+ "data-closable",
+ "data-accordion",
+ "data-tabs",
+ "data-dropdown-menu",
+ "data-responsive-toggle",
+ "data-abide",
+ ):
+ assert plugin_hook not in html, f"{name} depends on a Foundation plugin"
+
+
+# --- Forms -----------------------------------------------------------------
+
+
+def test_form_field_uses_foundation_error_classes(render):
+ """Foundation's Abide plugin only ever adds these three classes.
+
+ Server-rendering them is a complete substitute for running it.
+ """
+ html = render("FormField.jinja", name="email", label="Email", error="Required")
+ assert "is-invalid-input" in html
+ assert "is-invalid-label" in html
+ assert "Required" in html
+
+
+def test_form_field_error_text_is_visible(render):
+ """`.form-error` is `display: none` until `.is-visible` joins it.
+
+ Emitting the error text without it renders an invisible error message —
+ the failure mode is silent, which is why this is pinned.
+ """
+ html = render("FormField.jinja", name="email", label="Email", error="Required")
+ assert "form-error is-visible" in html
+
+
+def test_form_field_without_an_error_stays_clean(render):
+ html = render("FormField.jinja", name="email", label="Email")
+ assert "is-invalid-input" not in html
+ assert "form-error" not in html
+ assert 'name="email"' in html
+
+
+def test_form_field_input_class_still_applied(render):
+ html = render("FormField.jinja", name="email", label="Email", input_class="my-input")
+ assert "my-input" in html
+
+
+def test_select_marks_the_current_value(render):
+ html = render(
+ "Select.jinja",
+ name="c",
+ label="C",
+ value="a",
+ options=[{"value": "a", "label": "A"}, {"value": "b", "label": "B"}],
+ )
+ assert "selected" in html
+
+
+def test_select_error_uses_the_invalid_input_class(render):
+ html = render(
+ "Select.jinja",
+ name="c",
+ label="C",
+ error="Pick one",
+ options=[{"value": "a", "label": "Option A"}],
+ )
+ assert "is-invalid-input" in html
+ assert "Option A" in html
+
+
+def test_textarea_error_uses_the_invalid_input_class(render):
+ html = render("Textarea.jinja", name="bio", label="Bio", value="Hello", error="Too short")
+ assert "is-invalid-input" in html
+ assert "Hello" in html
+
+
+def test_checkbox_group_uses_a_fieldset(render):
+ """Foundation groups checkboxes in a `fieldset`/`legend`, not a bare label."""
+ html = render(
+ "CheckboxGroup.jinja",
+ name="f",
+ label="Fruits",
+ choices=[{"value": "a", "label": "Apple"}],
+ selected=["a"],
+ )
+ assert 'class="fieldset' in html
+ assert "` element.
+
+ Its compiled CSS gives bare `progress` only `vertical-align: baseline`;
+ every colour rule is `.progress. .progress-meter`, which needs a
+ child element the native control cannot have.
+ """
+ html = render("Progress.jinja", value=40, max=100, type="primary")
+ assert "progress-meter" in html
+ assert "width: 40%" in html
+ assert 'role="progressbar"' in html
+ assert 'aria-valuenow="40"' in html
+ assert 'aria-valuemax="100"' in html
+
+
+def test_progress_maps_danger_to_alert(render):
+ html = render("Progress.jinja", value=75, max=100, type="danger")
+ assert "progress alert" in html
+
+
+def test_progress_survives_a_zero_max(render):
+ """A division by zero here would be a 500 on an empty result set."""
+ html = render("Progress.jinja", value=0, max=0)
+ assert "width: 0%" in html
+
+
+def test_table_uses_the_scroll_wrapper(render):
+ html = render("Table.jinja", columns=[{"key": "n", "label": "Name"}], rows=[{"n": "Ada"}])
+ assert "table-scroll" in html
+ assert "is-striped" not in html
+ assert "Name" in html
+ assert "Ada" in html
+
+
+def test_pagination_uses_foundation_list_classes(render):
+ html = render("Pagination.jinja", page=2, total_pages=3, hx_url="/x", hx_target="#t")
+ assert "pagination-previous" in html
+ assert "pagination-next" in html
+ assert 'class="current"' in html
+ assert 'aria-current="page"' in html
+
+
+def test_pagination_disables_the_edges(render):
+ html = render("Pagination.jinja", page=1, total_pages=1, hx_url="/x", hx_target="#t")
+ assert "pagination-previous disabled" in html
+ assert "pagination-next disabled" in html
+
+
+def test_panel_keeps_the_alpine_contract(render):
+ html = render("Panel.jinja", title="Details", content="Inner")
+ assert 'x-data="cfPanel"' in html
+ assert "x-show" in html
+ assert "x-cloak" in html
+ assert "Inner" in html
+
+
+def test_panel_avoids_the_accordion(render):
+ """`.accordion-content` is `display: none` with no un-hiding rule.
+
+ Foundation's plugin opens it with an inline `slideDown()`, so a
+ server-open accordion panel would be invisible with Alpine off — exactly
+ what the accessibility phase forbids. `card-section` has no such rule.
+ """
+ html = render("Panel.jinja", title="T", content="Inner", open=True)
+ assert "accordion-content" not in html
+ assert "card-section" in html
+
+
+def test_navbar_keeps_the_alpine_contract(render):
+ html = render("Navbar.jinja", brand="Brand", start="S", end="E")
+ assert 'x-data="cfNavbar"' in html
+ assert "toggle()" in html
+ assert "top-bar-left" in html
+ assert "top-bar-right" in html
+
+
+def test_navbar_menu_stays_visible_on_desktop_when_collapsed(render):
+ """`hide-for-small-only`, not `hide`.
+
+ `.hide` is `display: none !important` at every width, so binding it to
+ `!menuOpen` would collapse the desktop menu too. The breakpoint-scoped
+ class is the one that reproduces what Foundation's own responsive toggle
+ does — and with Alpine off no class is emitted at all, so the menu is
+ simply visible.
+ """
+ html = render("Navbar.jinja", brand="Brand", start="S", end="E")
+ assert "hide-for-small-only" in html
+ assert "'hide':" not in html
+
+
+def test_breadcrumb_uses_foundation_breadcrumbs_class(render):
+ html = render("Breadcrumb.jinja", items=[{"url": "/a", "label": "A"}])
+ assert 'class="breadcrumbs' in html
+ assert 'aria-current="page"' in html
+
+
+def test_tabs_keeps_the_alpine_contract(render):
+ html = render("Tabs.jinja", tabs=[{"id": "one", "url": "/one"}], content="C")
+ assert 'x-data="cfTabs"' in html
+ # The id reaches Alpine as data, never as expression text (#32).
+ assert 'data-cf-tab="one"' in html
+ assert "setActive($el.dataset.cfTab)" in html
+ assert "tabs-title" in html
+
+
+def test_tabs_bind_aria_selected_because_the_css_reads_it(render):
+ """`.tabs-title > a[aria-selected=true]` is what restyles the active tab.
+
+ `.is-active` on the `` alone changes nothing visually, so the aria
+ attribute is load-bearing for appearance here, not only for the reader.
+ """
+ html = render("Tabs.jinja", tabs=[{"id": "one", "url": "/one"}], active="one", content="C")
+ assert 'aria-selected="true"' in html
+ assert ":aria-selected=" in html
diff --git a/tests/unit/test_accessibility.py b/tests/unit/test_accessibility.py
index 70f3269..3b008b1 100644
--- a/tests/unit/test_accessibility.py
+++ b/tests/unit/test_accessibility.py
@@ -25,13 +25,19 @@
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "src" / "cf_ui" / "templates"
JINJA_DIR = TEMPLATES_DIR / "jinja"
-THEMES = ["bulma", "daisy", "bootstrap"]
+THEMES = ["bulma", "daisy", "bootstrap", "foundation"]
#: The class each theme puts on the *selected* tab, and the element it sits on.
#: Bulma marks the `` ``; DaisyUI and Bootstrap mark the ````
-#: itself. Matched as a whole class token, so Bootstrap's bare ``active`` does
+#: itself; Foundation marks the ````, reusing Bulma's
+#: token. Matched as a whole class token, so Bootstrap's bare ``active`` does
#: not collide with Bulma's ``is-active``.
-ACTIVE_TAB_CLASS = {"bulma": "is-active", "daisy": "tab-active", "bootstrap": "active"}
+ACTIVE_TAB_CLASS = {
+ "bulma": "is-active",
+ "daisy": "tab-active",
+ "bootstrap": "active",
+ "foundation": "is-active",
+}
@pytest.fixture(params=THEMES)
diff --git a/tests/unit/test_theme_dispatch.py b/tests/unit/test_theme_dispatch.py
index e4094c8..b28a2e5 100644
--- a/tests/unit/test_theme_dispatch.py
+++ b/tests/unit/test_theme_dispatch.py
@@ -37,7 +37,7 @@
"textarea",
]
-IMPLEMENTED_THEMES = ["bulma", "daisy", "bootstrap"]
+IMPLEMENTED_THEMES = ["bulma", "daisy", "bootstrap", "foundation"]
# --- The theme registry ----------------------------------------------------
@@ -74,12 +74,18 @@ def test_resolve_theme_accepts_bootstrap():
assert resolve_theme("bootstrap") == "bootstrap"
+def test_resolve_theme_accepts_foundation():
+ from cf_ui.themes import resolve_theme
+
+ assert resolve_theme("foundation") == "foundation"
+
+
def test_resolve_theme_rejects_a_stub_theme_by_name():
- """foundation/fomantic are PLANNED.md stubs, not usable themes."""
+ """fomantic is still a PLANNED.md stub, not a usable theme."""
from cf_ui.themes import ThemeError, resolve_theme
- with pytest.raises(ThemeError, match="foundation"):
- resolve_theme("foundation")
+ with pytest.raises(ThemeError, match="fomantic"):
+ resolve_theme("fomantic")
def test_resolve_theme_error_names_the_available_themes():