Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
(<https://v4.daisyui.com/docs/cdn/>) 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.**
Expand Down
66 changes: 66 additions & 0 deletions docs/daisyui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<https://v4.daisyui.com/docs/cdn/>)
prescribes two tags, in this order:

```html
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.24/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
```

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 `<link>`, then the Tailwind Play CDN `<script>` — the vendor's own order | Prototyping, demos, anywhere without a Tailwind build step |
| `"off"` | Neither tag. `cf_ui_axes.css`, the `[x-cloak]` style block, and any custom axis styles are unchanged | A consumer with a real Tailwind build (see below) — it already supplies both layers itself |

An unrecognized value fails at startup: Django raises `ImproperlyConfigured`
naming the valid values, the same treatment `CF_UI_THEME` and
`CF_UI_COMPOSITION` already get.

### Why `"play"` is the default, not `"off"`

The failure this switch exists to prevent is a silently half-styled page. A
default of `"off"` does not avoid that failure — it just changes who hits it:
anyone who sets `CF_UI_THEME = "daisy"`, follows the quickstart, and has not
yet read this page gets the exact same unstyled layout, with the same absence
of an error. `"play"` gives that consumer a working page immediately, plus an
unmissable, greppable signal in view-source (the comment) pointing at this
document. A consumer running a real Tailwind build — the production path — is
exactly the consumer who has read this far and can flip `CF_UI_DAISY_CDN` to
`"off"` deliberately.

**The CDN path, even in `"play"` mode, is not equivalent to a real Tailwind
build.** It compiles in the browser on every page load, which is a real
performance cost, and upstream does not support it in production. Treat
`"play"` as a way to see cf-ui's daisy theme working with zero build-tooling
setup — not as a deployment target.

## Switching to it

```python
Expand Down
18 changes: 17 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ gives the modal two owners, and `Alpine.store('cf').modal.open(id)` would then
mean something different under one theme than under the others.

cf-ui uses each framework's classes and markup structure and wires behavior
through Alpine, so the CDN stylesheet is all a consuming app needs.
through Alpine, so the CDN stylesheet is all a consuming app needs — for
Bulma, Bootstrap, Foundation, and Fomantic. **DaisyUI is the exception**: it
is a Tailwind plugin, so its CDN stylesheet is components only, with none of
the utility classes (`flex`, `w-full`, `gap-4`, …) the daisy templates use for
layout. `{% cf_ui_head %}` covers the gap by also emitting Tailwind's Play
CDN script, but that is a browser-side compile step, not a production
substitute for a real Tailwind build — see [DaisyUI](daisyui.md) before you
ship it.
[Bootstrap](bootstrap.md) is the full decision record — which of Bootstrap's
twelve JS components cf-ui replaces, what to do about the eight it does not,
and what changes at Bootstrap 6.
Expand Down Expand Up @@ -66,6 +73,15 @@ That order is load-bearing: both tags use `defer`, so DOM order determines
execution order, and cf-ui's file must register its components before Alpine
initializes them.

!!! warning "DaisyUI is not like the other four themes here"
For Bulma, Bootstrap, Foundation, and Fomantic, `{% cf_ui_head %}` emits
one self-contained stylesheet and that is the whole story. DaisyUI
compiles through Tailwind, so its CDN stylesheet alone has no utility
classes — `{% cf_ui_head %}` also emits Tailwind's Play CDN script for it,
controlled by `CF_UI_DAISY_CDN` (`"play"` by default, `"off"` for a real
Tailwind build). See [DaisyUI](daisyui.md) before choosing which one you
ship.

CDN versions are pinned defaults, overridable:

```python
Expand Down
18 changes: 13 additions & 5 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,26 @@ pinned CDN links for the active theme plus Alpine.js. See
opinion about where the stylesheet comes from — only that the class names it
emits are the ones that stylesheet defines.

!!! warning "DaisyUI needs one extra step"
DaisyUI compiles through Tailwind, so Tailwind's content scanner has to
reach cf-ui's templates inside `site-packages` — otherwise every class is
!!! warning "DaisyUI needs extra steps — for both the CDN and the self-hosted path"
DaisyUI is the one theme where the CDN link above is **not** enough on its
own. DaisyUI is a Tailwind plugin, so its CDN stylesheet ships component
classes only — no `.flex`, `.w-full`, or any other utility the shipped
templates rely on for layout. `{% cf_ui_head %}` covers this by also
emitting Tailwind's Play CDN script (`CF_UI_DAISY_CDN`, default `"play"`),
but that in-browser compile is not a production answer — see
[DaisyUI](daisyui.md#the-cdn-path-needs-two-tags-not-one).

For a real, self-hosted Tailwind build, its content scanner has to reach
cf-ui's templates inside `site-packages` — otherwise every class is
tree-shaken away, leaving correct markup with no styling and no error.
Get the glob from the package rather than hand-writing it:

```bash
python -m cf_ui.themes
```

[DaisyUI](daisyui.md) covers that in full, along with the Tailwind plugin
that fails your build on an unknown theme-axis value.
[DaisyUI](daisyui.md) covers both paths in full, along with the Tailwind
plugin that fails your build on an unknown theme-axis value.

## Verify the install

Expand Down
7 changes: 6 additions & 1 deletion src/cf_ui/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
merge_value_sets,
resolve_composition,
)
from cf_ui.themes import ThemeError, resolve_theme
from cf_ui.themes import ThemeError, resolve_daisy_cdn, resolve_theme


def axis_value_sets() -> dict:
Expand Down Expand Up @@ -54,6 +54,11 @@ def ready(self) -> None:
except ThemeError as exc:
raise ImproperlyConfigured(f"cf-ui: {exc}. Check CF_UI_THEME in settings.") from exc

try:
resolve_daisy_cdn(getattr(settings, "CF_UI_DAISY_CDN", None))
except ThemeError as exc:
raise ImproperlyConfigured(f"cf-ui: {exc}. Check CF_UI_DAISY_CDN in settings.") from exc

try:
resolve_composition(
getattr(settings, "CF_UI_COMPOSITION", None),
Expand Down
10 changes: 8 additions & 2 deletions src/cf_ui/templates/cf_ui/assets.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
they are inert rather than an error, so plain Jinja2 apps still render.
#}

{% macro cf_ui_head(theme="bulma", versions={}, cf_axes_url="/static/cf_ui/cf_ui_axes.css") %}{% autoescape true %}
{% macro cf_ui_head(theme="bulma", versions={}, cf_axes_url="/static/cf_ui/cf_ui_axes.css", daisy_cdn="play") %}{% autoescape true %}
{%- set v = {
"bulma": "1.0.2",
"bootstrap": "5.3.3",
Expand All @@ -29,8 +29,14 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/foundation-sites@{{ v.foundation }}/dist/css/foundation.min.css">
{%- elif theme == "fomantic" -%}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fomantic-ui@{{ v.fomantic }}/dist/semantic.min.css">
{%- elif theme == "daisy" -%}
{%- elif theme == "daisy" and daisy_cdn != "off" -%}
<!-- cf-ui: daisyUI CDN mode ("play"). Loads Tailwind's Play CDN, which
compiles utilities in the browser; upstream labels it "for development
purposes only, and not intended for production"
(https://v4.daisyui.com/docs/cdn/). For production, run a real
Tailwind build and set CF_UI_DAISY_CDN = "off". -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/daisyui@{{ v.daisy }}/dist/full.min.css">
<script src="https://cdn.tailwindcss.com"></script>
{%- endif %}
<link rel="stylesheet" href="{{ cf_axes_url }}">
<style>[x-cloak] { display: none !important; }</style>
Expand Down
25 changes: 23 additions & 2 deletions src/cf_ui/templatetags/cf_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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 = '<script src="https://cdn.tailwindcss.com"></script>'
_DAISY_PLAY_COMMENT = (
'<!-- cf-ui: daisyUI CDN mode ("play"). Loads Tailwind\'s Play CDN, which\n'
' compiles utilities in the browser; upstream labels it "for development\n'
' purposes only, and not intended for production"\n'
" (https://v4.daisyui.com/docs/cdn/). For production, run a real\n"
' Tailwind build and set CF_UI_DAISY_CDN = "off". -->'
)
_DEFAULTS = {
"bulma": "1.0.2",
"bootstrap": "5.3.3",
Expand Down Expand Up @@ -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'<link rel="stylesheet" href="{url}">')
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'<link rel="stylesheet" href="{url}">')

Expand Down
27 changes: 27 additions & 0 deletions src/cf_ui/themes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<c-cf.form-field>``.
#:
#: A name here must have a partial under every theme in :data:`THEMES` —
Expand Down Expand Up @@ -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:
Expand Down
52 changes: 20 additions & 32 deletions tests/integration/jinja_app/main.py
Original file line number Diff line number Diff line change
@@ -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": '<script src="https://cdn.tailwindcss.com"></script>',
# 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 `<head>` 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:
Expand Down Expand Up @@ -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"""<!DOCTYPE html>
<html>
<head>
{_THEME_EXTRA_HEAD[theme]}
<link rel="stylesheet" href="{_THEME_CSS[theme]}">
<style>[x-cloak] {{ display: none !important; }}</style>
{head_html}
</head>
<body>
<section class="section">
Expand Down
Loading
Loading