diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf39163..aea7670 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,27 @@
## [Unreleased]
+### Fixed
+
+- **`cf_ui_head(theme="daisy")` shipped half of daisyUI's own documented CDN
+ recipe, and that half silently drops every layout utility (#56).** DaisyUI
+ is a Tailwind *plugin* — its CDN stylesheet is the component layer only
+ (`.btn{`, `.card{`), never the utility layer (`.flex{`, `.w-full{`,
+ `.gap-4{`) that the shipped daisy templates depend on for layout. A
+ consumer following the quickstart with `CF_UI_THEME = "daisy"` got buttons
+ and cards that looked right sitting in a layout that did not work, with no
+ error to point at the cause. daisyUI's own CDN docs
+ () pair the stylesheet with Tailwind's
+ Play CDN script for exactly this reason; cf-ui was shipping only the first
+ tag. `cf_ui_head` / the `cf_ui_head` Jinja macro now emit both, in the
+ vendor's order, gated by a new `CF_UI_DAISY_CDN` setting (`"play"` default,
+ `"off"` for a consumer with a real Tailwind build supplying both layers
+ itself). An invalid value now fails at Django startup, matching
+ `CF_UI_THEME` and `CF_UI_COMPOSITION`. The other four themes are
+ unaffected — this only ever touched the daisy branch of `cf_ui_head`. See
+ [DaisyUI](docs/daisyui.md) for the full recipe and why `"play"` is the
+ default rather than `"off"`.
+
### Added — a primitives layer: button, badge, heading, label, icon (#52)
- **Five new components, on all five themes, in both template sets.**
diff --git a/docs/daisyui.md b/docs/daisyui.md
index c9eb5d7..9aad081 100644
--- a/docs/daisyui.md
+++ b/docs/daisyui.md
@@ -6,6 +6,72 @@ instead of linking a finished stylesheet. That changes two things — what
Tailwind has to scan, and what Tailwind's preflight does to whatever styling
you already had.
+## The CDN path needs two tags, not one
+
+DaisyUI is a Tailwind *plugin* — its CDN bundle
+(`daisyui@{version}/dist/full.min.css`) is the component layer only. It has
+`.btn{` and `.card{`, but no `.flex{`, `.w-full{`, `.gap-4{`, or any other
+Tailwind utility, because utilities are the host framework's job and a plugin
+bundle does not carry them. The shipped daisy templates lean on exactly those
+utilities for layout, so the stylesheet alone renders styled buttons and cards
+sitting in a broken layout — no error, no console warning, just a page that
+looks wrong in a way that does not point at the cause.
+
+DaisyUI's own CDN documentation ()
+prescribes two tags, in this order:
+
+```html
+
+
+```
+
+The second tag is Tailwind's **Play CDN** — a real Tailwind build that
+compiles utility classes in the browser, at request time. Upstream is explicit
+that this is "for development purposes only, and not intended for
+production."
+
+`{% cf_ui_head %}` / `cf_ui_head()` now emits that same pair for you, gated by
+one switch.
+
+### `CF_UI_DAISY_CDN`
+
+```python
+# settings.py (Django)
+CF_UI_DAISY_CDN = "play" # default — or "off"
+```
+
+```jinja
+{# Jinja2 / JinjaX #}
+{{ cf_ui_head(theme="daisy", daisy_cdn="play") }}
+```
+
+| Value | What `cf_ui_head` emits | When to use it |
+|---|---|---|
+| `"play"` (default) | An explanatory HTML comment, then the daisyUI stylesheet ``, then the Tailwind Play CDN `
{%- endif %}
diff --git a/src/cf_ui/templatetags/cf_ui.py b/src/cf_ui/templatetags/cf_ui.py
index 1931b7f..34e2f27 100644
--- a/src/cf_ui/templatetags/cf_ui.py
+++ b/src/cf_ui/templatetags/cf_ui.py
@@ -6,7 +6,7 @@
from cf_ui.axes import root_attrs, style_element
from cf_ui.django import axis_value_sets
from cf_ui.primitives import validate as validate_primitive
-from cf_ui.themes import cotton_partial
+from cf_ui.themes import cotton_partial, resolve_daisy_cdn
register = template.Library()
@@ -18,6 +18,18 @@
"daisy": "https://cdn.jsdelivr.net/npm/daisyui@{v}/dist/full.min.css",
}
_ALPINE_CDN = "https://cdn.jsdelivr.net/npm/alpinejs@{v}/dist/cdn.min.js"
+
+# daisyUI's own CDN recipe (https://v4.daisyui.com/docs/cdn/) is this
+# stylesheet paired with Tailwind's Play CDN script, in this order — daisyUI
+# is a Tailwind plugin, so the stylesheet alone has no utility layer (#56).
+_TAILWIND_PLAY_CDN = ''
+_DAISY_PLAY_COMMENT = (
+ ''
+)
_DEFAULTS = {
"bulma": "1.0.2",
"bootstrap": "5.3.3",
@@ -52,7 +64,16 @@ def cf_ui_head() -> str:
v = _versions()
parts = []
- if theme in _CDN_CSS:
+ if theme == "daisy":
+ daisy_cdn = resolve_daisy_cdn(getattr(settings, "CF_UI_DAISY_CDN", None))
+ if daisy_cdn == "play":
+ url = _CDN_CSS["daisy"].format(v=v.get("daisy", ""))
+ parts.append(_DAISY_PLAY_COMMENT)
+ parts.append(f'')
+ parts.append(_TAILWIND_PLAY_CDN)
+ # "off": a real Tailwind build supplies both the stylesheet and the
+ # utility layer, so cf-ui emits neither tag.
+ elif theme in _CDN_CSS:
url = _CDN_CSS[theme].format(v=v.get(theme, ""))
parts.append(f'')
diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py
index 22ebec9..971485e 100644
--- a/src/cf_ui/themes.py
+++ b/src/cf_ui/themes.py
@@ -30,6 +30,17 @@
DEFAULT_THEME = "bulma"
+#: daisyUI is a Tailwind *plugin* — its CDN bundle carries only the component
+#: layer (``.btn``, ``.card``), never the utility layer (``.flex``, ``.gap-4``,
+#: ``.w-full``) that the shipped daisy templates lean on for layout. daisyUI's
+#: own CDN docs (https://v4.daisyui.com/docs/cdn/) pair the stylesheet with
+#: Tailwind's Play CDN script for exactly this reason. ``"play"`` completes
+#: that documented pair; ``"off"`` is for a consumer with a real Tailwind
+#: build, who supplies both layers themselves (see docs/daisyui.md).
+DAISY_CDN_MODES = ("play", "off")
+
+DEFAULT_DAISY_CDN = "play"
+
#: django-cotton file stems, as used by ````.
#:
#: A name here must have a partial under every theme in :data:`THEMES` —
@@ -87,6 +98,22 @@ def resolve_theme(theme: str | None = None) -> str:
return theme
+def resolve_daisy_cdn(mode: str | None = None) -> str:
+ """Validate a daisy CDN mode, defaulting to ``"play"``.
+
+ Fails loudly here rather than silently — see :data:`DAISY_CDN_MODES` for
+ why the two values exist. A bad value is a configuration mistake, so it
+ is rejected at startup (:mod:`cf_ui.django`) the same way an unknown
+ ``CF_UI_THEME`` is, not left to surface as a half-styled page.
+ """
+ if not mode:
+ return DEFAULT_DAISY_CDN
+ if mode not in DAISY_CDN_MODES:
+ available = ", ".join(DAISY_CDN_MODES)
+ raise ThemeError(f"unknown daisy CDN mode {mode!r} — valid values are: {available}")
+ return mode
+
+
def cotton_partial(component: str, theme: str | None = None) -> str:
"""Template path of a component's partial for ``theme``."""
if component not in COMPONENTS:
diff --git a/tests/integration/jinja_app/main.py b/tests/integration/jinja_app/main.py
index 1684cef..003f597 100644
--- a/tests/integration/jinja_app/main.py
+++ b/tests/integration/jinja_app/main.py
@@ -1,41 +1,28 @@
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
+from jinja2 import Environment, FileSystemLoader, select_autoescape
from jinjax import Catalog
from markupsafe import Markup
from cf_ui import JINJA_TEMPLATES_DIR
from cf_ui.fastapi import install_cf_ui
-_CF_UI_STATIC_DIR = JINJA_TEMPLATES_DIR.parent.parent / "static" / "cf_ui"
-
-_THEME_CSS = {
- "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"
- ),
- "fomantic": "https://cdn.jsdelivr.net/npm/fomantic-ui@2.9.3/dist/semantic.min.css",
-}
-
-# DaisyUI ships component classes but no Tailwind utilities. The components use
-# utilities for layout and responsive behavior (`hidden`, `lg:flex`), so without
-# a Tailwind build those classes resolve to nothing and the E2E tier cannot see
-# whether a toggle actually changes anything. The play CDN is a real in-browser
-# Tailwind JIT, which makes the gallery representative of a consuming app.
-_THEME_EXTRA_HEAD = {
- "bulma": "",
- "daisy": '',
- # Bootstrap, Foundation and Fomantic all ship prebuilt CSS, and cf-ui
- # deliberately loads none of their JavaScript — Alpine owns modal, tab and
- # panel state in every theme, so no bootstrap.bundle.js, no foundation.js,
- # and none of Fomantic's jQuery Modal/Tab/Accordion/Dropdown modules. The
- # point of each theme is that the pages work without them.
- "bootstrap": "",
- "foundation": "",
- "fomantic": "",
-}
+_TEMPLATES_ROOT = JINJA_TEMPLATES_DIR.parent # .../cf_ui/templates
+_CF_UI_STATIC_DIR = _TEMPLATES_ROOT.parent / "static" / "cf_ui"
+
+# The gallery's `` is built through the real `cf_ui_head` macro rather
+# than a hand-maintained CDN URL table — see #56. It used to hand-roll its
+# own `_THEME_CSS` dict plus a `_THEME_EXTRA_HEAD["daisy"]` patch that
+# injected the Tailwind Play CDN script cf_ui_head itself failed to emit,
+# which meant this E2E tier was never exercising the shipped tag, only a
+# workaround for it. Routing through the actual macro is what makes deleting
+# that patch a real regression guard instead of a hope.
+_assets_env = Environment(
+ loader=FileSystemLoader(str(_TEMPLATES_ROOT)),
+ autoescape=select_autoescape(["html", "jinja"]),
+)
+_assets_module = _assets_env.get_template("cf_ui/assets.jinja").make_module()
def make_app(theme: str = "bulma") -> FastAPI:
@@ -139,12 +126,13 @@ async def gallery():
_content="Initial content",
extra_class="",
)
+ head_html = _assets_module.cf_ui_head(
+ theme=theme, cf_axes_url="/static/cf_ui/cf_ui_axes.css"
+ )
return f"""
- {_THEME_EXTRA_HEAD[theme]}
-
-
+ {head_html}
diff --git a/tests/unit/test_asset_tags.py b/tests/unit/test_asset_tags.py
index 67f8d88..cb61542 100644
--- a/tests/unit/test_asset_tags.py
+++ b/tests/unit/test_asset_tags.py
@@ -75,13 +75,13 @@ def test_cf_ui_body_cf_alpine_loads_before_alpine(settings):
# the agreement is executed here rather than eyeballed when a theme lands.
-def _assets_head(theme: str) -> str:
+def _assets_head(theme: str, daisy_cdn: str = "play") -> str:
env = Environment(
loader=FileSystemLoader(TEMPLATES_DIR),
autoescape=select_autoescape(["html", "jinja"]),
)
module = env.get_template("cf_ui/assets.jinja").make_module()
- return str(module.cf_ui_head(theme=theme))
+ return str(module.cf_ui_head(theme=theme, daisy_cdn=daisy_cdn))
@pytest.mark.parametrize("theme", ["bulma", "daisy", "bootstrap", "foundation", "fomantic"])
@@ -123,3 +123,140 @@ def test_bootstrap_head_links_the_stylesheet_and_no_bundle(settings):
assert "bootstrap.bundle" not in head
assert "bootstrap.bundle" not in cf_ui_body()
assert "bootstrap.bundle" not in _assets_head("bootstrap")
+
+
+# --- daisyUI CDN utilities (#56) --------------------------------------------
+#
+# daisyUI is a Tailwind *plugin*: its CDN bundle is the component layer only
+# (`.btn{`, `.card{`), never the utility layer (`.flex{`, `.w-full{`,
+# `.gap-4{`) the shipped daisy templates lean on for layout. daisyUI's own CDN
+# docs (https://v4.daisyui.com/docs/cdn/) pair the stylesheet with Tailwind's
+# Play CDN script for exactly that reason — cf-ui shipped the first tag and
+# silently dropped the second. `CF_UI_DAISY_CDN` ("play"/"off") controls it;
+# "play" (the default) completes the documented pair, "off" is for a consumer
+# with a real Tailwind build supplying both layers itself.
+
+_SCRIPT_TAG_RE = re.compile(r"'
+
+
+def test_daisy_head_in_play_mode_emits_the_tailwind_play_cdn_script(settings):
+ """daisyUI's own CDN recipe is a two-tag pair; "play" ships both."""
+ from cf_ui.templatetags.cf_ui import cf_ui_head
+
+ settings.CF_UI_THEME = "daisy"
+ settings.CF_UI_DAISY_CDN = "play"
+ settings.CF_UI_CDN_VERSIONS = {}
+
+ result = cf_ui_head()
+ assert _TAILWIND_PLAY_SCRIPT in result
+
+
+@pytest.mark.parametrize("theme", ["bulma", "bootstrap", "foundation", "fomantic"])
+def test_non_daisy_themes_emit_no_script_tag_from_cf_ui_head(settings, theme):
+ """The Play CDN script is a daisy-only concern — the other four themes
+ ship self-contained CSS and must render byte-identically to today."""
+ from cf_ui.templatetags.cf_ui import cf_ui_head
+
+ settings.CF_UI_THEME = theme
+ settings.CF_UI_CDN_VERSIONS = {}
+
+ assert not _SCRIPT_TAG_RE.search(cf_ui_head())
+ assert not _SCRIPT_TAG_RE.search(_assets_head(theme))
+
+
+def test_daisy_head_in_off_mode_emits_neither_stylesheet_nor_script(settings):
+ """ "off" is for a consumer with a real Tailwind build — cf-ui must not
+ hand it a stylesheet or script it did not ask for."""
+ from cf_ui.templatetags.cf_ui import cf_ui_head
+
+ settings.CF_UI_THEME = "daisy"
+ settings.CF_UI_DAISY_CDN = "off"
+ settings.CF_UI_CDN_VERSIONS = {}
+
+ result = cf_ui_head()
+ assert "daisyui@" not in result
+ assert "full.min.css" not in result
+ assert _TAILWIND_PLAY_SCRIPT not in result
+ assert not _SCRIPT_TAG_RE.search(result)
+
+
+def test_daisy_head_in_off_mode_still_emits_axes_css_and_xcloak_style(settings):
+ """Turning the CDN off must not turn off cf-ui's own assets."""
+ from cf_ui.templatetags.cf_ui import cf_ui_head
+
+ settings.CF_UI_THEME = "daisy"
+ settings.CF_UI_DAISY_CDN = "off"
+ settings.CF_UI_CDN_VERSIONS = {}
+
+ result = cf_ui_head()
+ assert "cf_ui_axes.css" in result
+ assert "[x-cloak]" in result
+ assert "display: none" in result
+
+
+def test_daisy_play_mode_carries_the_explanatory_comment(settings):
+ """The comment is the unmissable signal that this is a dev-only CDN."""
+ from cf_ui.templatetags.cf_ui import cf_ui_head
+
+ settings.CF_UI_THEME = "daisy"
+ settings.CF_UI_DAISY_CDN = "play"
+ settings.CF_UI_CDN_VERSIONS = {}
+
+ result = cf_ui_head()
+ assert "