From f1efffa7dd394eae20876491c340f2364b63474f Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Wed, 29 Jul 2026 00:13:41 -0400 Subject: [PATCH 1/3] feat(themes): implement DaisyUI theme and cotton theme dispatch (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full 14-component DaisyUI set to both template sets and the mechanism django-cotton needed to switch themes at all. The two engines dispatch differently, deliberately. JinjaX already switched by directory. django-cotton could not: resolves through a single global COTTON_DIR, which cf-ui must not touch (0.1.1 reverted exactly that, because it broke consumers whose own components live at cotton//*.html). So the public component stays at the fixed path cotton/cf/.html and becomes a wrapper that declares and includes cotton/_themes//.html. Consumer templates are untouched — the E2E tier proves it by rendering the Bulma gallery's own templates under CF_UI_THEME="daisy". Tailwind tree-shaking is the failure mode this theme actually risks, and it fails silently. Both causes are tested: cf_ui.themes publishes the absolute content globs (docs are checked against the same constant), and every variant class is written out in full rather than assembled as alert-{{ type }}, which the scanner cannot see. Alpine is unchanged — cfModal, cfNavbar, cfPanel, cfTabs and the $cf store are theme-independent; only the toggled class differs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf --- CHANGELOG.md | 50 +++- CLAUDE.md | 33 ++- README.md | 40 ++- docs/daisyui.md | 165 ++++++++++++ src/cf_ui/django.py | 12 +- .../cotton/_themes/bulma/breadcrumb.html | 13 + .../templates/cotton/_themes/bulma/card.html | 15 ++ .../cotton/_themes/bulma/checkbox-group.html | 15 ++ .../cotton/_themes/bulma/form-field.html | 12 + .../templates/cotton/_themes/bulma/modal.html | 19 ++ .../cotton/_themes/bulma/navbar.html | 20 ++ .../cotton/_themes/bulma/notification.html | 8 + .../cotton/_themes/bulma/pagination.html | 30 +++ .../templates/cotton/_themes/bulma/panel.html | 14 + .../cotton/_themes/bulma/progress.html | 6 + .../cotton/_themes/bulma/select.html | 15 ++ .../templates/cotton/_themes/bulma/table.html | 21 ++ .../templates/cotton/_themes/bulma/tabs.html | 14 + .../cotton/_themes/bulma/textarea.html | 10 + .../cotton/_themes/daisy/breadcrumb.html | 13 + .../templates/cotton/_themes/daisy/card.html | 9 + .../cotton/_themes/daisy/checkbox-group.html | 16 ++ .../cotton/_themes/daisy/form-field.html | 10 + .../templates/cotton/_themes/daisy/modal.html | 17 ++ .../cotton/_themes/daisy/navbar.html | 13 + .../cotton/_themes/daisy/notification.html | 12 + .../cotton/_themes/daisy/pagination.html | 26 ++ .../templates/cotton/_themes/daisy/panel.html | 13 + .../cotton/_themes/daisy/progress.html | 6 + .../cotton/_themes/daisy/select.html | 13 + .../templates/cotton/_themes/daisy/table.html | 21 ++ .../templates/cotton/_themes/daisy/tabs.html | 13 + .../cotton/_themes/daisy/textarea.html | 8 + src/cf_ui/templates/cotton/cf/breadcrumb.html | 14 +- src/cf_ui/templates/cotton/cf/card.html | 16 +- .../templates/cotton/cf/checkbox-group.html | 16 +- src/cf_ui/templates/cotton/cf/form-field.html | 13 +- src/cf_ui/templates/cotton/cf/modal.html | 20 +- src/cf_ui/templates/cotton/cf/navbar.html | 21 +- .../templates/cotton/cf/notification.html | 9 +- src/cf_ui/templates/cotton/cf/pagination.html | 31 +-- src/cf_ui/templates/cotton/cf/panel.html | 15 +- src/cf_ui/templates/cotton/cf/progress.html | 7 +- src/cf_ui/templates/cotton/cf/select.html | 16 +- src/cf_ui/templates/cotton/cf/table.html | 22 +- src/cf_ui/templates/cotton/cf/tabs.html | 15 +- src/cf_ui/templates/cotton/cf/textarea.html | 11 +- src/cf_ui/templates/cotton/daisy/PLANNED.md | 3 - .../templates/jinja/daisy/Breadcrumb.jinja | 16 ++ src/cf_ui/templates/jinja/daisy/Card.jinja | 14 + .../templates/jinja/daisy/CheckboxGroup.jinja | 22 ++ .../templates/jinja/daisy/FormField.jinja | 16 ++ src/cf_ui/templates/jinja/daisy/Modal.jinja | 23 ++ src/cf_ui/templates/jinja/daisy/Navbar.jinja | 18 ++ .../templates/jinja/daisy/Notification.jinja | 16 ++ src/cf_ui/templates/jinja/daisy/PLANNED.md | 3 - .../templates/jinja/daisy/Pagination.jinja | 33 +++ src/cf_ui/templates/jinja/daisy/Panel.jinja | 16 ++ .../templates/jinja/daisy/Progress.jinja | 12 + src/cf_ui/templates/jinja/daisy/Select.jinja | 19 ++ src/cf_ui/templates/jinja/daisy/Table.jinja | 24 ++ src/cf_ui/templates/jinja/daisy/Tabs.jinja | 18 ++ .../templates/jinja/daisy/Textarea.jinja | 14 + src/cf_ui/templatetags/cf_ui.py | 18 ++ src/cf_ui/themes.py | 105 ++++++++ tests/e2e/_e2e_django_settings.py | 4 +- tests/e2e/conftest.py | 81 ++++-- tests/e2e/test_daisy.py | 168 ++++++++++++ tests/integration/jinja_app/main.py | 154 ++++++----- tests/unit/cotton/test_daisy.py | 184 +++++++++++++ tests/unit/jinja/test_daisy.py | 254 ++++++++++++++++++ tests/unit/test_tailwind_content.py | 147 ++++++++++ tests/unit/test_theme_dispatch.py | 218 +++++++++++++++ 73 files changed, 2195 insertions(+), 333 deletions(-) create mode 100644 docs/daisyui.md create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/breadcrumb.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/card.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/form-field.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/modal.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/navbar.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/notification.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/pagination.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/panel.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/progress.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/select.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/table.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/tabs.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/textarea.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/breadcrumb.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/card.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/checkbox-group.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/form-field.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/modal.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/navbar.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/notification.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/pagination.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/panel.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/progress.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/select.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/table.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/tabs.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/textarea.html delete mode 100644 src/cf_ui/templates/cotton/daisy/PLANNED.md create mode 100644 src/cf_ui/templates/jinja/daisy/Breadcrumb.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Card.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/CheckboxGroup.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/FormField.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Modal.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Navbar.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Notification.jinja delete mode 100644 src/cf_ui/templates/jinja/daisy/PLANNED.md create mode 100644 src/cf_ui/templates/jinja/daisy/Pagination.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Panel.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Progress.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Select.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Table.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Tabs.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Textarea.jinja create mode 100644 src/cf_ui/themes.py create mode 100644 tests/e2e/test_daisy.py create mode 100644 tests/unit/cotton/test_daisy.py create mode 100644 tests/unit/jinja/test_daisy.py create mode 100644 tests/unit/test_tailwind_content.py create mode 100644 tests/unit/test_theme_dispatch.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fb5c7..a7f643f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,48 @@ ## [Unreleased] ### Added +- **DaisyUI theme (#6)** — all 14 components in both template sets + (`jinja/daisy/*.jinja` and `cotton/_themes/daisy/*.html`), covered by the + full three-tier suite including Playwright E2E in `js_on` and `js_off` +- `cf_ui/themes.py` — theme registry (`THEMES`, `COMPONENTS`, `resolve_theme`, + `cotton_partial`, `tailwind_content_globs`); `CF_UI_THEME` is now validated + by `CfUiConfig.ready()` instead of failing later as `TemplateDoesNotExist` +- django-cotton theme dispatch: `cotton/cf/.html` is now a wrapper that + declares `` and includes `cotton/_themes//.html` via the + new `{% cf_ui_theme_path %}` tag. Consumer templates are unchanged — + `` still resolves at the same path, and `COTTON_DIR` is still + untouched (see #4) +- `docs/daisyui.md` — theme switch, the Tailwind content glob, and preflight + coexistence while migrating off another framework +- `python -m cf_ui.themes` prints the absolute Tailwind content globs for the + installed package, so consumers do not hand-write a site-packages path + +### Changed +- E2E harness is parameterized by theme: `make_app(theme)` for the FastAPI + gallery and `CF_UI_E2E_THEME` for the Django server. The DaisyUI E2E run + reuses the Bulma consumer templates verbatim, which is what makes + "switching themes needs no template edits" an executed claim rather than a + documented one + +### Technical Notes +- Every DaisyUI variant class is written out in full + (`{% if type == 'danger' %}alert-error{% endif %}`, never `alert-{{ type }}`) + because Tailwind's scanner reads source text and cannot see a class assembled + at render time. A test scans the DaisyUI templates for split class tokens +- The prop vocabulary is unchanged across themes: `type="danger"` still works, + and the templates map it onto DaisyUI's `alert-error` / `progress-error` +- Alpine components are untouched — `cfModal`, `cfNavbar`, `cfPanel`, `cfTabs` + and the `$cf` store are theme-independent; only the toggled class differs + (`is-active` → `modal-open` / `tab-active`) +- Django rejects template variables beginning with an underscore, so the + dispatch variable is `cf_ui_partial`, not `_cf_partial` +- Light and dark are declared independently per value, never derived by inversion; + light is the unqualified selector, dark is `[data-theme="dark"][data-*="..."]` +- Base declarations are sRGB hex (what the contrast gate is computed against); + wide-gamut chroma is layered behind `@media (color-gamut: p3)` — not + `@supports (color: oklch(...))`, which is a no-op in every current browser +- Density drives Tailwind v4's `--spacing`; accent aliases to `--color-primary*` + - Theme composition axes (#5): five orthogonal style axes — accent, surface, form, density, type — each keyed on a data attribute and carrying a closed set of named values. `data-theme` remains the light/dark switch and is not an axis. @@ -18,14 +60,6 @@ functions, registering the Jinja globals the macros delegate to - `docs/theming.md` — axis reference, custom value sets, and the contrast requirement -### Technical Notes -- Light and dark are declared independently per value, never derived by inversion; - light is the unqualified selector, dark is `[data-theme="dark"][data-*="..."]` -- Base declarations are sRGB hex (what the contrast gate is computed against); - wide-gamut chroma is layered behind `@media (color-gamut: p3)` — not - `@supports (color: oklch(...))`, which is a no-op in every current browser -- Density drives Tailwind v4's `--spacing`; accent aliases to `--color-primary*` - ## [0.1.1] — 2026-04-28 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index c7aea90..1a2457a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,14 +35,16 @@ pytest tests/e2e/ --browser chromium -v # E2E tests src/cf_ui/ ├── __init__.py # exports JINJA_TEMPLATES_DIR, COTTON_TEMPLATES_DIR, __version__ ├── _version.py -├── django.py # AppConfig — AppConfig auto-registers COTTON_DIRS at startup +├── django.py # AppConfig — validates CF_UI_THEME + CF_UI_COMPOSITION at startup +├── themes.py # theme registry, cotton partial paths, Tailwind content globs ├── fastapi.py # install_cf_ui(catalog, theme) — add_folder(prefix="Cf") ├── litestar.py # install_cf_ui(config, theme) — appends to TemplateConfig.directory ├── templatetags/cf_ui.py # Django simple_tags: cf_ui_head, cf_ui_body, get_item, make_list_1_to_n ├── templates/ │ ├── cf_ui/assets.jinja # Jinja2 macros: cf_ui_head(), cf_ui_body() -│ ├── jinja/bulma/ # 14 JinjaX component templates (*.jinja) -│ └── cotton/bulma/cf/ # 14 django-cotton component templates (*.html) +│ ├── jinja// # 14 JinjaX component templates (*.jinja) per theme +│ ├── cotton/cf/ # 14 public wrappers — , props + dispatch only +│ └── cotton/_themes// # 14 theme partials (*.html), included by the wrappers └── static/cf_ui/ └── cf_ui_alpine.js # Alpine named components + $cf global store ``` @@ -92,9 +94,22 @@ The `Cf` prefix / `cf.` namespace prevents collision with consumer app component ## Adding a New Theme -1. Create `src/cf_ui/templates/jinja/{theme}/` and `src/cf_ui/templates/cotton/{theme}/cf/` -2. Copy and adapt all 14 component templates, replacing Bulma-specific classes -3. Add the theme's CDN URL to `_CDN_CSS` in `templatetags/cf_ui.py` and to `assets.jinja` -4. Add default version to `_DEFAULTS` in `templatetags/cf_ui.py` -5. Add a stub `pyproject.toml` extras entry (already present as empty `[]`) -6. Add unit tests in `tests/unit/jinja/` and `tests/unit/cotton/` +1. Create `src/cf_ui/templates/jinja/{theme}/` and + `src/cf_ui/templates/cotton/_themes/{theme}/` +2. Copy and adapt all 14 component templates, replacing Bulma-specific classes. + The cotton partials carry **no** `` — that lives on the wrapper in + `cotton/cf/`, which needs no edit for a new theme +3. Add `{theme}` to `THEMES` in `themes.py` — until then `resolve_theme` rejects + it at startup +4. Add the theme's CDN URL to `_CDN_CSS` in `templatetags/cf_ui.py` and to + `assets.jinja` +5. Add default version to `_DEFAULTS` in `templatetags/cf_ui.py` +6. Add a stub `pyproject.toml` extras entry (already present as empty `[]`) +7. Add unit tests in `tests/unit/jinja/` and `tests/unit/cotton/`; the + parametrized tests in `tests/unit/test_theme_dispatch.py` pick the theme up + automatically once step 3 lands + +**Tailwind-based themes only:** write every variant class out in full +(`{% if type == 'danger' %}alert-error{% endif %}`) — Tailwind's scanner reads +source text, so `alert-{{ type }}` gets tree-shaken away silently. See +`tests/unit/test_tailwind_content.py` and `docs/daisyui.md`. diff --git a/README.md b/README.md index 5f05792..3ee754a 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,13 @@ Component names are **theme-agnostic** — `` and `` render t ## Installation ```bash -# Bulma (v0.1 — only supported theme) +# Bulma pip install "cf-ui[bulma]" -# All themes (stubs for future Bootstrap, Foundation, Fomantic, DaisyUI) +# Tailwind + DaisyUI +pip install "cf-ui[daisy]" + +# All themes (Bootstrap, Foundation and Fomantic are still stubs) pip install "cf-ui[all]" ``` @@ -241,25 +244,44 @@ from cf_ui import JINJA_TEMPLATES_DIR, COTTON_TEMPLATES_DIR # COTTON_TEMPLATES_DIR / "cf" → Path to Cotton component templates ``` -> **Cotton templates are theme-agnostic on disk.** They live at +> **Cotton component paths are theme-agnostic.** The public components live at > `cotton/cf/*.html` (not `cotton//cf/*.html`) so cf-ui can sit > alongside any consumer project's own `templates/cotton//...` tree -> without colliding on `COTTON_DIR`. The active CSS framework is selected -> via `CF_UI_THEME` (used by the asset tags) — switching themes in a -> future release will happen inside the templates, not via the directory -> layout. +> without colliding on `COTTON_DIR`. Each one is a thin wrapper that declares +> its props and includes a theme partial from `cotton/_themes//`, +> selected by `CF_UI_THEME`. Render ``; never reach into +> `_themes/` directly. +> +> The Jinja side needs no such indirection — `install_cf_ui(catalog, theme=…)` +> registers `templates/jinja//` and JinjaX resolves `` from there. --- -## Planned Themes +## Themes | Theme | Status | |---|---| | Bulma | ✅ v0.1.0 | +| Tailwind + DaisyUI | ✅ — see [docs/daisyui.md](docs/daisyui.md) | | Bootstrap | 📋 Planned | | Foundation | 📋 Planned | | Fomantic UI | 📋 Planned | -| Tailwind + DaisyUI | 📋 Planned | + +Switching is one line — `CF_UI_THEME = "daisy"` on Django, `theme="daisy"` on +FastAPI/Litestar — and needs no template edits in the consuming app. An +unimplemented theme name is rejected at startup rather than at first render. + +**DaisyUI takes one extra step.** It compiles through Tailwind, so Tailwind's +content scanner has to reach cf-ui's templates in site-packages or every class +in them 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 +``` + +[docs/daisyui.md](docs/daisyui.md) covers that, plus disabling Tailwind's +preflight while an older framework is still styling the app. --- diff --git a/docs/daisyui.md b/docs/daisyui.md new file mode 100644 index 0000000..c9eb5d7 --- /dev/null +++ b/docs/daisyui.md @@ -0,0 +1,165 @@ +# DaisyUI theme + +DaisyUI is the one theme in cf-ui that is not just "a different class +vocabulary." It sits on Tailwind, so the consuming app compiles its own CSS +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. + +## Switching to it + +```python +# settings.py (Django) +CF_UI_THEME = "daisy" +``` + +```python +# FastAPI +from cf_ui.fastapi import install_cf_ui +install_cf_ui(catalog, theme="daisy") + +# Litestar +from cf_ui.litestar import install_cf_ui +install_cf_ui(template_config, theme="daisy") +``` + +That is the whole change. Component names do not move: `` and +`` mean the same thing under every theme, and no template in the +consuming app needs an edit. An unimplemented theme name is rejected at +startup (`ImproperlyConfigured` on Django, `ThemeError` elsewhere) rather than +surfacing later as a confusing `TemplateDoesNotExist`. + +### How the switch works + +The two template sets dispatch differently, and the asymmetry is deliberate. + +**Jinja2/JinjaX** switches by directory — `install_cf_ui` registers +`templates/jinja//` with the catalog, so `` resolves to +whichever theme was installed. + +**django-cotton** cannot do that. `` resolves through +django-cotton's single global `COTTON_DIR` prefix, and cf-ui deliberately +does not touch `COTTON_DIR` — an earlier release did, and it broke every +consumer whose own components lived at `cotton//*.html`. So the +public component stays at the fixed path `cotton/cf/.html` and its +entire body is a dispatch: + +```django + +{% load cf_ui %}{% cf_ui_theme_path "card" as cf_ui_partial %}{% include cf_ui_partial %} +``` + +The theme markup lives in `cotton/_themes//.html`. `{% include %}` +inherits the caller's context, so props and `{{ slot }}` reach the partial +without being redeclared — `` stays in exactly one place per +component. The leading underscore marks the directory private: render +``, never ``. + +> **Requires** `"libraries": {"cf_ui": "cf_ui.templatetags.cf_ui"}` in +> `TEMPLATES[0]["OPTIONS"]`. This was already required for `{% cf_ui_head %}`; +> under the dispatch it is also required for components to render at all, +> because each wrapper does `{% load cf_ui %}`. The `cf_ui.django` app label +> prevents templatetag autodiscovery, so there is no way around declaring it. + +## Tailwind content glob — read this one + +Tailwind removes every class it does not find while scanning your source. Your +own templates are covered by your existing config; **cf-ui's templates live in +site-packages and are not**. Miss them and the page renders with correct markup +and no styling at all — no error, no warning. + +Add cf-ui's installed template directory to the content sources: + +```js +// tailwind.config.js (Tailwind v3) +module.exports = { + content: [ + "./templates/**/*.html", + "../.venv/lib/python3.12/site-packages/cf_ui/templates/**/*.{html,jinja}", + ], +}; +``` + +```css +/* app.css (Tailwind v4) */ +@import "tailwindcss"; +@plugin "daisyui"; +@source "../.venv/lib/python3.12/site-packages/cf_ui/templates/**/*.{html,jinja}"; +``` + +The path depends on your interpreter and platform, so do not hand-write it. +Ask the package: + +```bash +python -m cf_ui.themes +``` + +```python +from cf_ui.themes import tailwind_content_globs +tailwind_content_globs() +# ['.../site-packages/cf_ui/templates/**/*.html', +# '.../site-packages/cf_ui/templates/**/*.jinja'] +``` + +`tests/unit/test_tailwind_content.py` fails if those globs stop reaching every +shipped template, and this document is checked against the same constant, so +the two cannot drift apart. + +### The second way classes get shaken out + +Tailwind's scanner reads source text; it does not render templates. A class +assembled at render time is therefore invisible to it: + +```django +{# Never do this — Tailwind never sees "alert-error" #} +
+``` + +Every variant class in the DaisyUI templates is written out in full instead: + +```django +
+``` + +This is also why the prop vocabulary did not change between themes. `type="danger"` +still works; DaisyUI has no `alert-danger`, so the templates map it onto +`alert-error` internally. A test scans every DaisyUI template for class tokens +split across a template construct and fails on any it finds. + +## Coexistence with an existing framework + +Tailwind's preflight resets margins, font sizes, list styles, and form control +appearance globally. Dropping it into an app that Bulma (or Bootstrap) still +styles will visibly damage the pages you have not migrated yet. + +While both frameworks are live, disable preflight: + +```js +// tailwind.config.js (v3) +module.exports = { corePlugins: { preflight: false } }; +``` + +```css +/* app.css (v4) — import the layers you want, omit the reset */ +@layer theme, components, utilities; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); +@plugin "daisyui"; +``` + +DaisyUI's own component classes do not depend on preflight, so cf-ui's +components render correctly without it. Re-enable it once the previous +framework's stylesheet is gone. + +## What stays the same + +The Alpine behaviors are theme-independent and unchanged: `cfModal`, +`cfNavbar`, `cfPanel`, `cfTabs`, and the `$cf` store all work identically +under DaisyUI. Only the classes the bindings toggle differ — Bulma's +`is-active` becomes DaisyUI's `modal-open` on the modal, and `tab-active` on +the tabs. Page code that calls `Alpine.store('cf').modal.open('id')` needs no +change. + +The [theme composition axes](theming.md) are also unaffected. They are keyed +on `data-*` attributes and CSS custom properties, independent of which CSS +framework is loaded. diff --git a/src/cf_ui/django.py b/src/cf_ui/django.py index 058d402..28b2929 100644 --- a/src/cf_ui/django.py +++ b/src/cf_ui/django.py @@ -7,6 +7,7 @@ merge_value_sets, resolve_composition, ) +from cf_ui.themes import ThemeError, resolve_theme def axis_value_sets() -> dict: @@ -38,12 +39,21 @@ class CfUiConfig(AppConfig): # cotton templates live at cotton//*.html. def ready(self) -> None: - """Validate the axis composition at startup, not at first render. + """Validate the theme and axis composition at startup. + + Both would otherwise surface at first render — an unknown theme as a + ``TemplateDoesNotExist`` naming a partial the app never wrote, which + is a poor way to learn that ``CF_UI_THEME`` holds a stub theme's name. Deliberately touches no template settings — see the note above. """ from django.conf import settings + try: + resolve_theme(getattr(settings, "CF_UI_THEME", None)) + except ThemeError as exc: + raise ImproperlyConfigured(f"cf-ui: {exc}. Check CF_UI_THEME in settings.") from exc + try: resolve_composition( getattr(settings, "CF_UI_COMPOSITION", None), diff --git a/src/cf_ui/templates/cotton/_themes/bulma/breadcrumb.html b/src/cf_ui/templates/cotton/_themes/bulma/breadcrumb.html new file mode 100644 index 0000000..c78083d --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/breadcrumb.html @@ -0,0 +1,13 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/bulma/card.html b/src/cf_ui/templates/cotton/_themes/bulma/card.html new file mode 100644 index 0000000..9f2e1b2 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/card.html @@ -0,0 +1,15 @@ +
+ {% if header %} +
+

{{ header }}

+
+ {% endif %} +
+
{{ slot }}
+
+ {% if footer %} +
+ +
+ {% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html b/src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html new file mode 100644 index 0000000..d8950d9 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html @@ -0,0 +1,15 @@ +
+ +
+ {% for choice in choices %} + + {% endfor %} +
+ {% if error %}

{{ error }}

{% endif %} +
\ No newline at end of file diff --git a/src/cf_ui/templates/cotton/_themes/bulma/form-field.html b/src/cf_ui/templates/cotton/_themes/bulma/form-field.html new file mode 100644 index 0000000..d8d2c31 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/form-field.html @@ -0,0 +1,12 @@ +
+ +
+ +
+ {% if error %}

{{ error }}

{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/modal.html b/src/cf_ui/templates/cotton/_themes/bulma/modal.html new file mode 100644 index 0000000..18a3387 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/modal.html @@ -0,0 +1,19 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/bulma/navbar.html b/src/cf_ui/templates/cotton/_themes/bulma/navbar.html new file mode 100644 index 0000000..5d80467 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/navbar.html @@ -0,0 +1,20 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/bulma/notification.html b/src/cf_ui/templates/cotton/_themes/bulma/notification.html new file mode 100644 index 0000000..4666c34 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/notification.html @@ -0,0 +1,8 @@ +
+ {% if dismissible == "true" %} + + {% endif %} + {{ message }} +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/pagination.html b/src/cf_ui/templates/cotton/_themes/bulma/pagination.html new file mode 100644 index 0000000..f2f42b6 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/pagination.html @@ -0,0 +1,30 @@ +{% load cf_ui %} + diff --git a/src/cf_ui/templates/cotton/_themes/bulma/panel.html b/src/cf_ui/templates/cotton/_themes/bulma/panel.html new file mode 100644 index 0000000..57b7d5f --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/panel.html @@ -0,0 +1,14 @@ +
+
+

{{ title }}

+ +
+
+ {{ slot }} +
+
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/progress.html b/src/cf_ui/templates/cotton/_themes/bulma/progress.html new file mode 100644 index 0000000..4e0945c --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/progress.html @@ -0,0 +1,6 @@ +
+ {% if label %}

{{ label }}

{% endif %} + {{ value }}% +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/select.html b/src/cf_ui/templates/cotton/_themes/bulma/select.html new file mode 100644 index 0000000..e64e598 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/select.html @@ -0,0 +1,15 @@ +
+ +
+
+ +
+
+ {% if error %}

{{ error }}

{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/table.html b/src/cf_ui/templates/cotton/_themes/bulma/table.html new file mode 100644 index 0000000..0bb6ed3 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/table.html @@ -0,0 +1,21 @@ +{% load cf_ui %} +
+ + + + {% for col in columns %} + + {% endfor %} + + + + {% for row in rows %} + + {% for col in columns %} + + {% endfor %} + + {% endfor %} + +
{{ col.label }}
{{ row|get_item:col.key }}
+
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/tabs.html b/src/cf_ui/templates/cotton/_themes/bulma/tabs.html new file mode 100644 index 0000000..7deb0b4 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/tabs.html @@ -0,0 +1,14 @@ +
+
+
    + {% for tab in tabs %} +
  • + {{ tab.id }} +
  • + {% endfor %} +
+
+
{{ slot }}
+
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/textarea.html b/src/cf_ui/templates/cotton/_themes/bulma/textarea.html new file mode 100644 index 0000000..6402f56 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/textarea.html @@ -0,0 +1,10 @@ +
+ +
+ +
+ {% if error %}

{{ error }}

{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/breadcrumb.html b/src/cf_ui/templates/cotton/_themes/daisy/breadcrumb.html new file mode 100644 index 0000000..4616bb8 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/breadcrumb.html @@ -0,0 +1,13 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/daisy/card.html b/src/cf_ui/templates/cotton/_themes/daisy/card.html new file mode 100644 index 0000000..67dd42d --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/card.html @@ -0,0 +1,9 @@ +
+
+ {% if header %}

{{ header }}

{% endif %} +
{{ slot }}
+ {% if footer %} +
{{ footer }}
+ {% endif %} +
+
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/checkbox-group.html b/src/cf_ui/templates/cotton/_themes/daisy/checkbox-group.html new file mode 100644 index 0000000..78d5062 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/checkbox-group.html @@ -0,0 +1,16 @@ +
+ +
+ {% for choice in choices %} + + {% endfor %} +
+ {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/form-field.html b/src/cf_ui/templates/cotton/_themes/daisy/form-field.html new file mode 100644 index 0000000..cd0850e --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/form-field.html @@ -0,0 +1,10 @@ +
+ + + {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/modal.html b/src/cf_ui/templates/cotton/_themes/daisy/modal.html new file mode 100644 index 0000000..bf0ad41 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/modal.html @@ -0,0 +1,17 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/daisy/navbar.html b/src/cf_ui/templates/cotton/_themes/daisy/navbar.html new file mode 100644 index 0000000..dcb34f0 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/navbar.html @@ -0,0 +1,13 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/daisy/notification.html b/src/cf_ui/templates/cotton/_themes/daisy/notification.html new file mode 100644 index 0000000..8d6bb11 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/notification.html @@ -0,0 +1,12 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/daisy/pagination.html b/src/cf_ui/templates/cotton/_themes/daisy/pagination.html new file mode 100644 index 0000000..6d23054 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/pagination.html @@ -0,0 +1,26 @@ +{% load cf_ui %} + diff --git a/src/cf_ui/templates/cotton/_themes/daisy/panel.html b/src/cf_ui/templates/cotton/_themes/daisy/panel.html new file mode 100644 index 0000000..2608638 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/panel.html @@ -0,0 +1,13 @@ +
+ +
+ {{ slot }} +
+
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/progress.html b/src/cf_ui/templates/cotton/_themes/daisy/progress.html new file mode 100644 index 0000000..ab2e428 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/progress.html @@ -0,0 +1,6 @@ +
+ {% if label %}

{{ label }}

{% endif %} + {{ value }}% +
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/select.html b/src/cf_ui/templates/cotton/_themes/daisy/select.html new file mode 100644 index 0000000..424afd3 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/select.html @@ -0,0 +1,13 @@ +
+ + + {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/table.html b/src/cf_ui/templates/cotton/_themes/daisy/table.html new file mode 100644 index 0000000..abe1c5e --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/table.html @@ -0,0 +1,21 @@ +{% load cf_ui %} +
+ + + + {% for col in columns %} + + {% endfor %} + + + + {% for row in rows %} + + {% for col in columns %} + + {% endfor %} + + {% endfor %} + +
{{ col.label }}
{{ row|get_item:col.key }}
+
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/tabs.html b/src/cf_ui/templates/cotton/_themes/daisy/tabs.html new file mode 100644 index 0000000..b7f2271 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/tabs.html @@ -0,0 +1,13 @@ +
+
+ {% for tab in tabs %} + {{ tab.id }} + {% endfor %} +
+
{{ slot }}
+
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/textarea.html b/src/cf_ui/templates/cotton/_themes/daisy/textarea.html new file mode 100644 index 0000000..9244472 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/textarea.html @@ -0,0 +1,8 @@ +
+ + + {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/cotton/cf/breadcrumb.html b/src/cf_ui/templates/cotton/cf/breadcrumb.html index 72e1ff7..2fac730 100644 --- a/src/cf_ui/templates/cotton/cf/breadcrumb.html +++ b/src/cf_ui/templates/cotton/cf/breadcrumb.html @@ -1,14 +1,2 @@ - +{% load cf_ui %}{% cf_ui_theme_path "breadcrumb" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/card.html b/src/cf_ui/templates/cotton/cf/card.html index 53c2ce0..99db637 100644 --- a/src/cf_ui/templates/cotton/cf/card.html +++ b/src/cf_ui/templates/cotton/cf/card.html @@ -1,16 +1,2 @@ -
- {% if header %} -
-

{{ header }}

-
- {% endif %} -
-
{{ slot }}
-
- {% if footer %} -
- -
- {% endif %} -
+{% load cf_ui %}{% cf_ui_theme_path "card" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/checkbox-group.html b/src/cf_ui/templates/cotton/cf/checkbox-group.html index 4725c12..9798a1d 100644 --- a/src/cf_ui/templates/cotton/cf/checkbox-group.html +++ b/src/cf_ui/templates/cotton/cf/checkbox-group.html @@ -1,16 +1,2 @@ -
- -
- {% for choice in choices %} - - {% endfor %} -
- {% if error %}

{{ error }}

{% endif %} -
\ No newline at end of file +{% load cf_ui %}{% cf_ui_theme_path "checkbox-group" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/form-field.html b/src/cf_ui/templates/cotton/cf/form-field.html index e5622a5..16ccf1c 100644 --- a/src/cf_ui/templates/cotton/cf/form-field.html +++ b/src/cf_ui/templates/cotton/cf/form-field.html @@ -1,13 +1,2 @@ -
- -
- -
- {% if error %}

{{ error }}

{% endif %} -
+{% load cf_ui %}{% cf_ui_theme_path "form-field" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/modal.html b/src/cf_ui/templates/cotton/cf/modal.html index 4ab8e60..3326396 100644 --- a/src/cf_ui/templates/cotton/cf/modal.html +++ b/src/cf_ui/templates/cotton/cf/modal.html @@ -1,20 +1,2 @@ - +{% load cf_ui %}{% cf_ui_theme_path "modal" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/navbar.html b/src/cf_ui/templates/cotton/cf/navbar.html index 7e54967..9133b5c 100644 --- a/src/cf_ui/templates/cotton/cf/navbar.html +++ b/src/cf_ui/templates/cotton/cf/navbar.html @@ -1,21 +1,2 @@ - +{% load cf_ui %}{% cf_ui_theme_path "navbar" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/notification.html b/src/cf_ui/templates/cotton/cf/notification.html index 4944285..0d1a641 100644 --- a/src/cf_ui/templates/cotton/cf/notification.html +++ b/src/cf_ui/templates/cotton/cf/notification.html @@ -1,9 +1,2 @@ -
- {% if dismissible == "true" %} - - {% endif %} - {{ message }} -
+{% load cf_ui %}{% cf_ui_theme_path "notification" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/pagination.html b/src/cf_ui/templates/cotton/cf/pagination.html index 8df7307..8e3e9ac 100644 --- a/src/cf_ui/templates/cotton/cf/pagination.html +++ b/src/cf_ui/templates/cotton/cf/pagination.html @@ -1,31 +1,2 @@ -{% load cf_ui %} - +{% load cf_ui %}{% cf_ui_theme_path "pagination" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/panel.html b/src/cf_ui/templates/cotton/cf/panel.html index 76bd9f4..5c264c6 100644 --- a/src/cf_ui/templates/cotton/cf/panel.html +++ b/src/cf_ui/templates/cotton/cf/panel.html @@ -1,15 +1,2 @@ -
-
-

{{ title }}

- -
-
- {{ slot }} -
-
+{% load cf_ui %}{% cf_ui_theme_path "panel" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/progress.html b/src/cf_ui/templates/cotton/cf/progress.html index 3a66eaf..b8dce55 100644 --- a/src/cf_ui/templates/cotton/cf/progress.html +++ b/src/cf_ui/templates/cotton/cf/progress.html @@ -1,7 +1,2 @@ -
- {% if label %}

{{ label }}

{% endif %} - {{ value }}% -
+{% load cf_ui %}{% cf_ui_theme_path "progress" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/select.html b/src/cf_ui/templates/cotton/cf/select.html index 072dcf6..5025de5 100644 --- a/src/cf_ui/templates/cotton/cf/select.html +++ b/src/cf_ui/templates/cotton/cf/select.html @@ -1,16 +1,2 @@ -
- -
-
- -
-
- {% if error %}

{{ error }}

{% endif %} -
+{% load cf_ui %}{% cf_ui_theme_path "select" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/table.html b/src/cf_ui/templates/cotton/cf/table.html index 9c6b891..d27a427 100644 --- a/src/cf_ui/templates/cotton/cf/table.html +++ b/src/cf_ui/templates/cotton/cf/table.html @@ -1,22 +1,2 @@ -{% load cf_ui %} -
- - - - {% for col in columns %} - - {% endfor %} - - - - {% for row in rows %} - - {% for col in columns %} - - {% endfor %} - - {% endfor %} - -
{{ col.label }}
{{ row|get_item:col.key }}
-
+{% load cf_ui %}{% cf_ui_theme_path "table" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/tabs.html b/src/cf_ui/templates/cotton/cf/tabs.html index c9cd9ee..fe69836 100644 --- a/src/cf_ui/templates/cotton/cf/tabs.html +++ b/src/cf_ui/templates/cotton/cf/tabs.html @@ -1,15 +1,2 @@ -
-
-
    - {% for tab in tabs %} -
  • - {{ tab.id }} -
  • - {% endfor %} -
-
-
{{ slot }}
-
+{% load cf_ui %}{% cf_ui_theme_path "tabs" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/textarea.html b/src/cf_ui/templates/cotton/cf/textarea.html index 6ff1a11..13da7a8 100644 --- a/src/cf_ui/templates/cotton/cf/textarea.html +++ b/src/cf_ui/templates/cotton/cf/textarea.html @@ -1,11 +1,2 @@ -
- -
- -
- {% if error %}

{{ error }}

{% endif %} -
+{% load cf_ui %}{% cf_ui_theme_path "textarea" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/daisy/PLANNED.md b/src/cf_ui/templates/cotton/daisy/PLANNED.md deleted file mode 100644 index efa17cd..0000000 --- a/src/cf_ui/templates/cotton/daisy/PLANNED.md +++ /dev/null @@ -1,3 +0,0 @@ -# Theme: DaisyUI — Planned - -Components for this theme are tracked in GitHub Issues. diff --git a/src/cf_ui/templates/jinja/daisy/Breadcrumb.jinja b/src/cf_ui/templates/jinja/daisy/Breadcrumb.jinja new file mode 100644 index 0000000..daa1687 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Breadcrumb.jinja @@ -0,0 +1,16 @@ +{#def items=[], extra_class="" #} +{% set items = items if items is defined else [] %} +{% set extra_class = extra_class if extra_class is defined else "" %} + diff --git a/src/cf_ui/templates/jinja/daisy/Card.jinja b/src/cf_ui/templates/jinja/daisy/Card.jinja new file mode 100644 index 0000000..b6d3423 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Card.jinja @@ -0,0 +1,14 @@ +{#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 "" %} +
+
+ {% if header %}

{{ header }}

{% endif %} +
{{ content }}
+ {% if footer %} +
{{ footer }}
+ {% endif %} +
+
diff --git a/src/cf_ui/templates/jinja/daisy/CheckboxGroup.jinja b/src/cf_ui/templates/jinja/daisy/CheckboxGroup.jinja new file mode 100644 index 0000000..ac44489 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/CheckboxGroup.jinja @@ -0,0 +1,22 @@ +{#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 "" %} +
+ +
+ {% for choice in choices %} + + {% endfor %} +
+ {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/jinja/daisy/FormField.jinja b/src/cf_ui/templates/jinja/daisy/FormField.jinja new file mode 100644 index 0000000..2d4f35b --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/FormField.jinja @@ -0,0 +1,16 @@ +{#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 "" %} +
+ + + {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/jinja/daisy/Modal.jinja b/src/cf_ui/templates/jinja/daisy/Modal.jinja new file mode 100644 index 0000000..2ecb46d --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Modal.jinja @@ -0,0 +1,23 @@ +{#def id="modal", header="", content="", footer="", 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 extra_class = extra_class if extra_class is defined else "" %} + diff --git a/src/cf_ui/templates/jinja/daisy/Navbar.jinja b/src/cf_ui/templates/jinja/daisy/Navbar.jinja new file mode 100644 index 0000000..cc5d195 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Navbar.jinja @@ -0,0 +1,18 @@ +{#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 "" %} + diff --git a/src/cf_ui/templates/jinja/daisy/Notification.jinja b/src/cf_ui/templates/jinja/daisy/Notification.jinja new file mode 100644 index 0000000..77e407f --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Notification.jinja @@ -0,0 +1,16 @@ +{#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 "" %} + diff --git a/src/cf_ui/templates/jinja/daisy/PLANNED.md b/src/cf_ui/templates/jinja/daisy/PLANNED.md deleted file mode 100644 index efa17cd..0000000 --- a/src/cf_ui/templates/jinja/daisy/PLANNED.md +++ /dev/null @@ -1,3 +0,0 @@ -# Theme: DaisyUI — Planned - -Components for this theme are tracked in GitHub Issues. diff --git a/src/cf_ui/templates/jinja/daisy/Pagination.jinja b/src/cf_ui/templates/jinja/daisy/Pagination.jinja new file mode 100644 index 0000000..8c94a60 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Pagination.jinja @@ -0,0 +1,33 @@ +{#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 "" %} + diff --git a/src/cf_ui/templates/jinja/daisy/Panel.jinja b/src/cf_ui/templates/jinja/daisy/Panel.jinja new file mode 100644 index 0000000..efcad7c --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Panel.jinja @@ -0,0 +1,16 @@ +{#def title, content="", open=false, extra_class="" #} +{% set content = content if content is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +
+ +
+ {{ content }} +
+
diff --git a/src/cf_ui/templates/jinja/daisy/Progress.jinja b/src/cf_ui/templates/jinja/daisy/Progress.jinja new file mode 100644 index 0000000..aad8e56 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Progress.jinja @@ -0,0 +1,12 @@ +{#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 "" %} +
+ {% if label %}

{{ label }}

{% endif %} + {{ value }}% +
diff --git a/src/cf_ui/templates/jinja/daisy/Select.jinja b/src/cf_ui/templates/jinja/daisy/Select.jinja new file mode 100644 index 0000000..f7946e4 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Select.jinja @@ -0,0 +1,19 @@ +{#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 "" %} +
+ + + {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/jinja/daisy/Table.jinja b/src/cf_ui/templates/jinja/daisy/Table.jinja new file mode 100644 index 0000000..d0a4938 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Table.jinja @@ -0,0 +1,24 @@ +{#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 "" %} +
+ + + + {% for col in columns %} + + {% endfor %} + + + + {% for row in rows %} + + {% for col in columns %} + + {% endfor %} + + {% endfor %} + +
{{ col.label }}
{{ row[col.key] }}
+
diff --git a/src/cf_ui/templates/jinja/daisy/Tabs.jinja b/src/cf_ui/templates/jinja/daisy/Tabs.jinja new file mode 100644 index 0000000..6d55128 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Tabs.jinja @@ -0,0 +1,18 @@ +{#def tabs=[], hx_target="tab-content", content="", extra_class="" #} +{% set tabs = tabs if tabs is defined else [] %} +{% set content = content if content 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 "" %} +
+
+ {% for tab in tabs %} + {{ tab.id }} + {% endfor %} +
+
{{ content }}
+
diff --git a/src/cf_ui/templates/jinja/daisy/Textarea.jinja b/src/cf_ui/templates/jinja/daisy/Textarea.jinja new file mode 100644 index 0000000..79e820e --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Textarea.jinja @@ -0,0 +1,14 @@ +{#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 "" %} +
+ + + {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templatetags/cf_ui.py b/src/cf_ui/templatetags/cf_ui.py index 5e7d3c9..2868dde 100644 --- a/src/cf_ui/templatetags/cf_ui.py +++ b/src/cf_ui/templatetags/cf_ui.py @@ -5,6 +5,7 @@ from cf_ui.axes import root_attrs, style_element from cf_ui.django import axis_value_sets +from cf_ui.themes import cotton_partial register = template.Library() @@ -77,6 +78,23 @@ def cf_ui_root_attrs() -> str: return mark_safe(root_attrs(composition, value_sets=axis_value_sets())) +@register.simple_tag +def cf_ui_theme_path(component: str) -> str: + """Resolve a component to its partial for the configured theme. + + Used by the public wrappers at ``cotton/cf/.html``:: + + {% cf_ui_theme_path "card" as cf_ui_partial %}{% include cf_ui_partial %} + + (Django rejects template variables that begin with an underscore, so the + name is prefixed rather than marked private.) + + ``{% include %}`` inherits the caller's context, so the component's own + props and ``{{ slot }}`` reach the partial without being re-declared. + """ + return cotton_partial(component, getattr(settings, "CF_UI_THEME", None)) + + @register.simple_tag def cf_ui_body(alpine: bool = True) -> str: if not alpine: diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py new file mode 100644 index 0000000..b73c4f5 --- /dev/null +++ b/src/cf_ui/themes.py @@ -0,0 +1,105 @@ +"""Theme registry and django-cotton theme dispatch. + +The two template sets switch themes differently, for reasons that are not +cosmetic: + +* **Jinja2/JinjaX** switches by *directory*. ``install_cf_ui`` registers + ``templates/jinja//`` with the catalog, so ```` resolves to + whichever theme was installed. Nothing in this module is needed for that. + +* **django-cotton** cannot. ```` resolves through django-cotton's + single global ``COTTON_DIR`` prefix, and cf-ui deliberately does not touch + ``COTTON_DIR`` — an earlier release did, and it broke every consumer whose + own components lived at ``cotton//*.html`` + (see tests/unit/test_consumer_compatibility.py). + + So the public component stays at the fixed, theme-free path + ``cotton/cf/.html`` and its body is one ``{% include %}`` of a + per-theme partial at ``cotton/_themes//.html``. The consuming + app keeps writing ````; ``CF_UI_THEME`` moves all 14 components + at once. +""" + +from pathlib import Path + +_HERE = Path(__file__).parent + +#: Themes with a real component set. The other directories under +#: ``templates/`` hold a ``PLANNED.md`` stub and are not selectable. +THEMES = ("bulma", "daisy") + +DEFAULT_THEME = "bulma" + +#: django-cotton file stems, as used by ````. +COMPONENTS = ( + "breadcrumb", + "card", + "checkbox-group", + "form-field", + "modal", + "navbar", + "notification", + "pagination", + "panel", + "progress", + "select", + "table", + "tabs", + "textarea", +) + +#: Where the per-theme cotton partials live, relative to a template root. +#: Leading underscore marks them private — consumers render ````, +#: never ````. +COTTON_THEME_ROOT = "cotton/_themes" + +#: The tail of the Tailwind content glob, in Tailwind's own brace syntax. +#: Kept as a constant so docs and code cannot drift apart. +TAILWIND_CONTENT_SUFFIX = "cf_ui/templates/**/*.{html,jinja}" + + +class ThemeError(ValueError): + """Raised for an unknown theme or component name.""" + + +def resolve_theme(theme: str | None = None) -> str: + """Validate a theme name, defaulting to Bulma. + + Fails loudly here rather than as a ``TemplateDoesNotExist`` at first + render — a stub theme name is a configuration mistake, not a missing file. + """ + if not theme: + return DEFAULT_THEME + if theme not in THEMES: + available = ", ".join(THEMES) + raise ThemeError(f"unknown theme {theme!r} — implemented themes are: {available}") + return theme + + +def cotton_partial(component: str, theme: str | None = None) -> str: + """Template path of a component's partial for ``theme``.""" + if component not in COMPONENTS: + known = ", ".join(COMPONENTS) + raise ThemeError(f"unknown component {component!r} — known components: {known}") + return f"{COTTON_THEME_ROOT}/{resolve_theme(theme)}/{component}.html" + + +def tailwind_content_globs() -> list[str]: + """Absolute globs a Tailwind build must scan to keep cf-ui's classes. + + DaisyUI compiles through Tailwind, so any class in these templates that + Tailwind's scanner never sees is removed from the output — silently, with + no error and an unstyled page as the only symptom. Paths are absolute and + derived from ``cf_ui.__file__`` so they work from a site-packages install, + an editable install, or a vendored copy. + """ + templates = _HERE / "templates" + return [ + str(templates / "**" / "*.html"), + str(templates / "**" / "*.jinja"), + ] + + +if __name__ == "__main__": # pragma: no cover - developer convenience + for pattern in tailwind_content_globs(): + print(pattern) diff --git a/tests/e2e/_e2e_django_settings.py b/tests/e2e/_e2e_django_settings.py index 8a692b0..71a634f 100644 --- a/tests/e2e/_e2e_django_settings.py +++ b/tests/e2e/_e2e_django_settings.py @@ -8,6 +8,7 @@ the shared Django settings used by unit and integration tests. """ +import os from pathlib import Path from cf_ui import JINJA_TEMPLATES_DIR @@ -41,7 +42,8 @@ }, } ] -CF_UI_THEME = "bulma" +# Set by the conftest fixture so one server script covers both themes. +CF_UI_THEME = os.environ.get("CF_UI_E2E_THEME", "bulma") # django-cotton default COTTON_DIR="cotton" resolves -> # cotton/cf/card.html, picked up via APP_DIRS from cf_ui's package templates. # Allow hyphenated filenames (form-field.html, checkbox-group.html) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index ff26b28..4e41381 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,3 +1,4 @@ +import os import subprocess import sys import threading @@ -9,17 +10,36 @@ import uvicorn -def _run_fastapi_server(host: str = "127.0.0.1", port: int = 8771) -> None: - from tests.integration.jinja_app.main import app +def _run_fastapi_server(theme: str = "bulma", host: str = "127.0.0.1", port: int = 8771) -> None: + from tests.integration.jinja_app.main import make_app - config = uvicorn.Config(app, host=host, port=port, log_level="error") + config = uvicorn.Config(make_app(theme), host=host, port=port, log_level="error") server = uvicorn.Server(config) server.run() +def _start_cotton_server(port: int, theme: str) -> subprocess.Popen: + """Start the Django cotton E2E server as a subprocess. + + A separate process so it gets its own Django configuration including + django_cotton, without contaminating the pytest process's settings (which + unit and integration tests rely on). The theme arrives via the environment + because Django settings are read once at startup. + """ + server_script = Path(__file__).parent / "_django_e2e_server.py" + env = {**os.environ, "CF_UI_E2E_THEME": theme} + return subprocess.Popen( + [sys.executable, str(server_script), "127.0.0.1", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + ) + + @pytest.fixture(scope="session") def jinja_server_url() -> Generator[str, None, None]: - thread = threading.Thread(target=_run_fastapi_server, daemon=True) + thread = threading.Thread(target=_run_fastapi_server, args=("bulma", "127.0.0.1", 8771)) + thread.daemon = True thread.start() time.sleep(2.0) yield "http://127.0.0.1:8771" @@ -27,24 +47,35 @@ def jinja_server_url() -> Generator[str, None, None]: @pytest.fixture(scope="session") def cotton_server_url() -> Generator[str, None, None]: - """ - Start the Django cotton E2E server as a subprocess so it has its own - Python process with isolated Django settings that include django_cotton. - This prevents contaminating the main pytest process's Django configuration - (which unit and integration tests rely on). - """ - server_script = Path(__file__).parent / "_django_e2e_server.py" - proc = subprocess.Popen( - [sys.executable, str(server_script), "127.0.0.1", "8772"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + proc = _start_cotton_server(8772, "bulma") time.sleep(2.0) yield "http://127.0.0.1:8772" proc.terminate() proc.wait(timeout=5) +@pytest.fixture(scope="session") +def daisy_jinja_server_url() -> Generator[str, None, None]: + thread = threading.Thread(target=_run_fastapi_server, args=("daisy", "127.0.0.1", 8773)) + thread.daemon = True + thread.start() + time.sleep(2.0) + yield "http://127.0.0.1:8773" + + +@pytest.fixture(scope="session") +def daisy_cotton_server_url() -> Generator[str, None, None]: + """The same consumer templates as the Bulma server, CF_UI_THEME="daisy". + + Nothing else differs — that is the claim this tier exists to check. + """ + proc = _start_cotton_server(8774, "daisy") + time.sleep(2.0) + yield "http://127.0.0.1:8774" + 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")) @@ -61,3 +92,21 @@ def cotton_page(request, browser, cotton_server_url): page.set_default_timeout(5000) yield page, request.param ctx.close() + + +@pytest.fixture(params=["js_on", "js_off"]) +def daisy_jinja_page(request, browser, daisy_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 daisy_cotton_page(request, browser, daisy_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_daisy.py b/tests/e2e/test_daisy.py new file mode 100644 index 0000000..68ad3f9 --- /dev/null +++ b/tests/e2e/test_daisy.py @@ -0,0 +1,168 @@ +"""DaisyUI E2E coverage (issue #6), parameterized over js_on / js_off. + +The cotton half matters most: these pages render the *same* consumer templates +as the Bulma E2E server — ``tests/integration/cotton_app/templates/`` is not +duplicated or edited — through the real django-cotton compiler, with nothing +changed but ``CF_UI_THEME``. That is the acceptance criterion, executed rather +than asserted. +""" + +import re + +import pytest +from playwright.sync_api import expect + + +def _wait_for_alpine(page) -> None: + page.wait_for_function( + "() => window.Alpine !== undefined && document.querySelectorAll('[x-cloak]').length === 0", + timeout=8000, + ) + + +# --- django-cotton: one setting, no consumer template edits ---------------- + + +def test_cotton_form_field_renders_daisy_markup(daisy_cotton_page, daisy_cotton_server_url): + page, _ = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/form-field/") + expect(page.locator("input[name='email']")).to_be_attached() + expect(page.locator(".form-control")).to_be_attached() + expect(page.locator("input.input-bordered")).to_be_attached() + + +def test_cotton_form_field_has_no_bulma_markup(daisy_cotton_page, daisy_cotton_server_url): + """The Bulma partial must not leak through the dispatch.""" + page, _ = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/form-field/") + expect(page.locator(".field")).to_have_count(0) + + +def test_cotton_card_renders_daisy_markup(daisy_cotton_page, daisy_cotton_server_url): + page, _ = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/card/") + expect(page.locator(".card-body")).to_be_attached() + expect(page.locator(".card-title")).to_have_text("Card Title") + expect(page.locator(".card-actions")).to_contain_text("Card Footer") + + +def test_cotton_card_slot_survives_the_dispatch(daisy_cotton_page, daisy_cotton_server_url): + """`{% include %}` must carry {{ slot }} into the theme partial.""" + page, _ = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/card/") + expect(page.locator(".card-body")).to_contain_text("Card body content") + + +def test_cotton_modal_named_slot_survives_the_dispatch(daisy_cotton_page, daisy_cotton_server_url): + page, _ = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/modal/") + expect(page.locator("#test-modal .modal-box")).to_contain_text("Test Modal") + expect(page.locator("#test-modal .modal-box")).to_contain_text("Modal body content") + + +def test_cotton_modal_closed_by_default(daisy_cotton_page, daisy_cotton_server_url): + page, js_mode = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/modal/") + modal = page.locator("#test-modal") + expect(modal).to_be_attached() + if js_mode == "js_on": + expect(modal).not_to_have_class(re.compile(r"modal-open")) + + +# --- JinjaX: same Alpine contract, DaisyUI classes ------------------------- + + +def test_jinja_modal_opens_and_closes(daisy_jinja_page, daisy_jinja_server_url): + """cfModal is theme-independent; only the toggled class differs.""" + page, js_mode = daisy_jinja_page + page.goto(f"{daisy_jinja_server_url}/gallery") + + modal = page.locator("#e2e-modal") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(modal).not_to_have_class(re.compile(r"modal-open")) + page.evaluate("Alpine.store('cf').modal.open('e2e-modal')") + expect(modal).to_have_class(re.compile(r"modal-open")) + modal.locator("button[aria-label='close']").click() + expect(modal).not_to_have_class(re.compile(r"modal-open")) + else: + expect(modal).to_be_attached() + + +def test_jinja_notification_dismisses(daisy_jinja_page, daisy_jinja_server_url): + page, js_mode = daisy_jinja_page + page.goto(f"{daisy_jinja_server_url}/gallery") + notification = page.locator(".alert") + + 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(daisy_jinja_page, daisy_jinja_server_url): + page, js_mode = daisy_jinja_page + page.goto(f"{daisy_jinja_server_url}/gallery") + panel_body = page.locator(".card-body").first + + if js_mode == "js_on": + _wait_for_alpine(page) + expect(panel_body).to_be_hidden() + # Scoped to the panel's own header — the navbar burger also carries + # aria-expanded, and it comes first in the document. + page.locator("div.card > button").first.click() + expect(panel_body).to_be_visible() + else: + expect(panel_body).to_be_attached() + + +def test_jinja_navbar_burger_toggles_menu(daisy_jinja_page, daisy_jinja_server_url): + page, js_mode = daisy_jinja_page + page.goto(f"{daisy_jinja_server_url}/gallery") + + if js_mode == "js_on": + _wait_for_alpine(page) + page.set_viewport_size({"width": 600, "height": 800}) + # classList membership, not a regex — "lg:flex" is always present and + # a word-boundary pattern matches inside it. + has_flex = "el => el.classList.contains('flex')" + menu = page.locator(".navbar-end") + assert menu.evaluate(has_flex) is False + page.locator("button[aria-label='menu']").click() + expect(menu).to_have_class(re.compile(r"\bflex\b")) + assert menu.evaluate(has_flex) is True + else: + expect(page.locator(".navbar")).to_be_attached() + + +def test_jinja_tabs_activate(daisy_jinja_page, daisy_jinja_server_url): + page, js_mode = daisy_jinja_page + page.goto(f"{daisy_jinja_server_url}/gallery") + first_tab = page.locator("[role='tab']").first + + if js_mode == "js_on": + _wait_for_alpine(page) + expect(first_tab).not_to_have_class(re.compile(r"tab-active")) + first_tab.click() + expect(first_tab).to_have_class(re.compile(r"tab-active")) + else: + expect(first_tab).to_be_attached() + + +def test_jinja_form_field_renders(daisy_jinja_page, daisy_jinja_server_url): + page, _ = daisy_jinja_page + page.goto(f"{daisy_jinja_server_url}/form-field") + expect(page.locator("input[name='email']")).to_be_visible() + expect(page.locator(".label-text")).to_have_text("Email") + + +def test_jinja_page_usable_without_js(daisy_jinja_page, daisy_jinja_server_url): + page, js_mode = daisy_jinja_page + if js_mode != "js_off": + pytest.skip("only runs in js_off mode") + page.goto(f"{daisy_jinja_server_url}/gallery") + expect(page.locator("section.section")).to_be_visible() + expect(page.locator("body")).not_to_be_empty() diff --git a/tests/integration/jinja_app/main.py b/tests/integration/jinja_app/main.py index d408d3a..4dcd773 100644 --- a/tests/integration/jinja_app/main.py +++ b/tests/integration/jinja_app/main.py @@ -8,79 +8,86 @@ _CF_UI_STATIC_DIR = JINJA_TEMPLATES_DIR.parent.parent / "static" / "cf_ui" -catalog = Catalog() -install_cf_ui(catalog, theme="bulma") - -app = FastAPI() -app.mount("/static/cf_ui", StaticFiles(directory=str(_CF_UI_STATIC_DIR)), name="cf_ui_static") - - -@app.get("/form-field", response_class=HTMLResponse) -async def form_field(): - return catalog.render( - "Cf:FormField", - name="email", - label="Email", - value="", - error="", - type="email", - required=False, - extra_class="", - ) - - -@app.get("/modal", response_class=HTMLResponse) -async def modal(): - return catalog.render("Cf:Modal", id="test-modal", extra_class="") - - -@app.get("/card", response_class=HTMLResponse) -async def card(): - return catalog.render( - "Cf:Card", - _content="Card body", - header="Card Title", - footer="", - extra_class="", - ) - - -@app.get("/navbar", response_class=HTMLResponse) -async def navbar(): - return catalog.render("Cf:Navbar", brand="", start="", end="", extra_class="") - - -@app.get("/tabs", response_class=HTMLResponse) -async def tabs(): - return catalog.render( - "Cf:Tabs", - tabs=[{"id": "one", "url": "/tab/one/"}, {"id": "two", "url": "/tab/two/"}], - hx_target="tab-content", - extra_class="", - ) - - -@app.get("/gallery", response_class=HTMLResponse) -async def gallery(): - modal_html = catalog.render("Cf:Modal", id="e2e-modal", extra_class="") - notification_html = catalog.render( - "Cf:Notification", message="Hello!", type="info", dismissible=True, extra_class="" - ) - navbar_html = catalog.render("Cf:Navbar", brand="Brand", start="", end="", extra_class="") - panel_html = catalog.render( - "Cf:Panel", title="Accordion", _content="Hidden content", open=False, extra_class="" - ) - tabs_html = catalog.render( - "Cf:Tabs", - tabs=[{"id": "tab1", "url": "/tab/one/"}, {"id": "tab2", "url": "/tab/two/"}], - hx_target="tab-content", - _content="Initial content", - extra_class="", - ) - return f""" +_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", +} + + +def make_app(theme: str = "bulma") -> FastAPI: + """Build a JinjaX gallery app for one theme. + + Parameterized by theme so the E2E tier can run the same pages under both + Bulma and DaisyUI — the component names and props are identical, which is + the property being tested. + """ + catalog = Catalog() + install_cf_ui(catalog, theme=theme) + + app = FastAPI() + app.mount("/static/cf_ui", StaticFiles(directory=str(_CF_UI_STATIC_DIR)), name="cf_ui_static") + + @app.get("/form-field", response_class=HTMLResponse) + async def form_field(): + return catalog.render( + "Cf:FormField", + name="email", + label="Email", + value="", + error="", + type="email", + required=False, + extra_class="", + ) + + @app.get("/modal", response_class=HTMLResponse) + async def modal(): + return catalog.render("Cf:Modal", id="test-modal", extra_class="") + + @app.get("/card", response_class=HTMLResponse) + async def card(): + return catalog.render( + "Cf:Card", + _content="Card body", + header="Card Title", + footer="", + extra_class="", + ) + + @app.get("/navbar", response_class=HTMLResponse) + async def navbar(): + return catalog.render("Cf:Navbar", brand="", start="", end="", extra_class="") + + @app.get("/tabs", response_class=HTMLResponse) + async def tabs(): + return catalog.render( + "Cf:Tabs", + tabs=[{"id": "one", "url": "/tab/one/"}, {"id": "two", "url": "/tab/two/"}], + hx_target="tab-content", + extra_class="", + ) + + @app.get("/gallery", response_class=HTMLResponse) + async def gallery(): + modal_html = catalog.render("Cf:Modal", id="e2e-modal", extra_class="") + notification_html = catalog.render( + "Cf:Notification", message="Hello!", type="info", dismissible=True, extra_class="" + ) + navbar_html = catalog.render("Cf:Navbar", brand="Brand", start="", end="", extra_class="") + panel_html = catalog.render( + "Cf:Panel", title="Accordion", _content="Hidden content", open=False, extra_class="" + ) + tabs_html = catalog.render( + "Cf:Tabs", + tabs=[{"id": "tab1", "url": "/tab/one/"}, {"id": "tab2", "url": "/tab/two/"}], + hx_target="tab-content", + _content="Initial content", + extra_class="", + ) + return f""" - + @@ -96,3 +103,8 @@ async def gallery(): """ + + return app + + +app = make_app("bulma") diff --git a/tests/unit/cotton/test_daisy.py b/tests/unit/cotton/test_daisy.py new file mode 100644 index 0000000..5a48001 --- /dev/null +++ b/tests/unit/cotton/test_daisy.py @@ -0,0 +1,184 @@ +"""DaisyUI component set, django-cotton side (issue #6). + +These go through the public ``cotton/cf/.html`` entry points with +``CF_UI_THEME = "daisy"``, 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 daisy_render(settings, cotton_render): + settings.CF_UI_THEME = "daisy" + return cotton_render + + +# --- Forms ----------------------------------------------------------------- + + +def test_form_field_uses_daisy_input_classes(daisy_render): + html = daisy_render("cf/form-field.html", name="email", label="Email Address") + assert "form-control" in html + assert "input-bordered" in html + assert "Email Address" in html + + +def test_form_field_error_uses_daisy_error_classes(daisy_render): + html = daisy_render("cf/form-field.html", name="email", label="Email", error="Required") + assert "input-error" in html + assert "text-error" in html + assert "Required" in html + + +def test_form_field_required_flag(daisy_render): + html = daisy_render("cf/form-field.html", name="email", label="Email", required="true") + assert "required" in html + + +def test_form_field_input_class_still_applied(daisy_render): + html = daisy_render("cf/form-field.html", name="e", label="E", input_class="input-lg") + assert "input-lg" in html + + +def test_select_uses_daisy_select_classes(daisy_render): + html = daisy_render( + "cf/select.html", + name="choice", + label="Choose", + options=[{"value": "a", "label": "Option A"}], + ) + assert "select-bordered" in html + assert "Option A" in html + + +def test_textarea_uses_daisy_textarea_classes(daisy_render): + html = daisy_render("cf/textarea.html", name="bio", label="Bio", value="Hello", rows="4") + assert "textarea-bordered" in html + assert "Hello" in html + + +def test_checkbox_group_uses_daisy_checkbox_class(daisy_render): + html = daisy_render( + "cf/checkbox-group.html", + name="fruits", + label="Fruits", + choices=[{"value": "a", "label": "Apple"}], + selected=["a"], + ) + assert 'class="checkbox' in html + assert "checked" in html + assert "Apple" in html + + +def test_checkbox_group_control_class_still_applied(daisy_render): + html = daisy_render( + "cf/checkbox-group.html", + name="f", + label="F", + choices=[{"value": "a", "label": "A"}], + selected=[], + control_class="flex-row", + ) + assert "flex-row" in html + + +# --- Feedback -------------------------------------------------------------- + + +def test_modal_keeps_the_alpine_contract(daisy_render): + html = daisy_render("cf/modal.html", id="my-modal") + assert 'id="my-modal"' in html + assert 'x-data="cfModal"' in html + assert "initModal" in html + assert "close()" in html + + +def test_modal_uses_daisy_modal_classes(daisy_render): + html = daisy_render("cf/modal.html", id="m") + assert "modal-box" in html + assert "modal-open" in html + + +def test_notification_maps_danger_to_daisy_error(daisy_render): + html = daisy_render("cf/notification.html", message="Boom", type="danger") + assert "alert-error" in html + assert "alert-danger" not in html + + +def test_notification_dismissible(daisy_render): + html = daisy_render( + "cf/notification.html", message="Saved!", type="success", dismissible="true" + ) + assert "alert-success" in html + assert "visible = false" in html + + +def test_notification_non_dismissible_omits_the_button(daisy_render): + html = daisy_render("cf/notification.html", message="Hi", type="info", dismissible="false") + assert "visible = false" not in html + + +def test_progress_uses_daisy_progress_classes(daisy_render): + html = daisy_render("cf/progress.html", value="40", max="100", type="primary") + assert "progress-primary" in html + assert 'value="40"' in html + + +# --- Content + navigation -------------------------------------------------- + + +def test_card_renders_header_body_footer(daisy_render): + html = daisy_render("cf/card.html", header="Title", slot="Body", footer="Foot") + assert "card-body" in html + assert "card-title" in html + assert "Body" in html + assert "Foot" in html + + +def test_table_uses_daisy_table_classes(daisy_render): + html = daisy_render( + "cf/table.html", columns=[{"key": "n", "label": "Name"}], rows=[{"n": "Ada"}] + ) + assert "table-zebra" in html + assert "is-striped" not in html + assert "Name" in html + assert "Ada" in html + + +def test_pagination_uses_the_join_group(daisy_render): + html = daisy_render( + "cf/pagination.html", page="2", total_pages="3", hx_url="/x", hx_target="#t" + ) + assert "join-item" in html + assert "btn-active" in html + + +def test_panel_keeps_the_alpine_contract(daisy_render): + html = daisy_render("cf/panel.html", title="Details", slot="Inner") + assert 'x-data="cfPanel"' in html + assert "x-cloak" in html + assert "Inner" in html + + +def test_navbar_keeps_the_alpine_contract(daisy_render): + html = daisy_render("cf/navbar.html", brand="Brand", start="S", end="E") + assert 'x-data="cfNavbar"' in html + assert "navbar-start" in html + assert "navbar-end" in html + + +def test_breadcrumb_uses_daisy_breadcrumbs_class(daisy_render): + html = daisy_render("cf/breadcrumb.html", items=[{"url": "/a", "label": "A"}]) + assert 'class="breadcrumbs' in html + assert 'aria-current="page"' in html + + +def test_tabs_keeps_the_alpine_contract(daisy_render): + html = daisy_render("cf/tabs.html", tabs=[{"id": "one", "url": "/one"}], slot="C") + assert 'x-data="cfTabs"' in html + assert "setActive('one')" in html + assert "tab-active" in html diff --git a/tests/unit/jinja/test_daisy.py b/tests/unit/jinja/test_daisy.py new file mode 100644 index 0000000..970787a --- /dev/null +++ b/tests/unit/jinja/test_daisy.py @@ -0,0 +1,254 @@ +"""DaisyUI component set, Jinja2/JinjaX side (issue #6). + +The Jinja theme switch is already directory-based — ``install_cf_ui`` registers +``templates/jinja//`` — so this tier only has to prove the daisy +directory ships all 14 components, that they render under ``StrictUndefined``, +and that they speak DaisyUI's class vocabulary rather than Bulma's. +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +DAISY_DIR = ( + Path(__file__).parent.parent.parent.parent / "src" / "cf_ui" / "templates" / "jinja" / "daisy" +) + +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"}, +} + + +@pytest.fixture +def render() -> Callable[..., str]: + env = Environment( + loader=FileSystemLoader(DAISY_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_daisy_ships_the_same_components_as_bulma(): + bulma_dir = DAISY_DIR.parent / "bulma" + assert sorted(p.name for p in DAISY_DIR.glob("*.jinja")) == sorted( + p.name for p in bulma_dir.glob("*.jinja") + ) + + +@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 bulma_marker in ("is-danger", "is-active", "card-header-title", "navbar-burger", "help"): + assert bulma_marker not in html, f"{name} still speaks Bulma" + + +# --- Forms ----------------------------------------------------------------- + + +def test_form_field_uses_daisy_input_classes(render): + html = render("FormField.jinja", name="email", label="Email") + assert "form-control" in html + assert "input-bordered" in html + assert 'name="email"' in html + + +def test_form_field_error_uses_daisy_error_classes(render): + html = render("FormField.jinja", name="email", label="Email", error="Required") + assert "input-error" in html + assert "text-error" in html + assert "Required" in html + + +def test_form_field_input_class_still_applied(render): + html = render("FormField.jinja", name="email", label="Email", input_class="input-lg") + assert "input-lg" in html + + +def test_select_uses_daisy_select_classes(render): + html = render( + "Select.jinja", name="c", label="C", options=[{"value": "a", "label": "Option A"}] + ) + assert "select-bordered" in html + assert "Option A" 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_textarea_uses_daisy_textarea_classes(render): + html = render("Textarea.jinja", name="bio", label="Bio", value="Hello") + assert "textarea-bordered" in html + assert "Hello" in html + + +def test_checkbox_group_uses_daisy_checkbox_class(render): + html = render( + "CheckboxGroup.jinja", + name="f", + label="F", + choices=[{"value": "a", "label": "Apple"}], + selected=["a"], + ) + assert 'class="checkbox' in html + assert "checked" in html + assert "Apple" in html + + +# --- Feedback -------------------------------------------------------------- + + +def test_modal_keeps_the_alpine_contract(render): + html = render("Modal.jinja", id="my-modal") + assert 'id="my-modal"' in html + assert 'x-data="cfModal"' in html + assert "initModal" in html + assert "close()" in html + + +def test_modal_uses_daisy_modal_classes(render): + html = render("Modal.jinja", id="m") + assert "modal-box" in html + assert "modal-open" in html + + +def test_notification_maps_danger_to_daisy_error(render): + """DaisyUI has no `alert-danger`; the prop vocabulary stays stable.""" + html = render("Notification.jinja", message="Boom", type="danger") + assert "alert-error" in html + assert "alert-danger" not in html + + +def test_notification_success_and_dismiss(render): + html = render("Notification.jinja", message="Saved!", type="success", dismissible=True) + assert "alert-success" in html + assert "Saved!" in html + assert "visible = false" in html + + +def test_notification_non_dismissible_omits_the_button(render): + html = render("Notification.jinja", message="Hi", type="info", dismissible=False) + assert "visible = false" not in html + + +def test_progress_uses_daisy_progress_classes(render): + html = render("Progress.jinja", value=40, max=100, type="primary") + assert "progress-primary" in html + assert 'value="40"' in html + assert 'max="100"' in html + + +def test_progress_maps_danger_to_error(render): + html = render("Progress.jinja", value=75, max=100, type="danger") + assert "progress-error" in html + + +# --- Content + navigation -------------------------------------------------- + + +def test_card_renders_header_body_footer(render): + html = render("Card.jinja", header="Title", content="Body", footer="Foot") + assert "card-body" in html + assert "card-title" in html + assert "Title" in html + assert "Body" in html + assert "Foot" in html + + +def test_table_uses_daisy_table_classes(render): + html = render("Table.jinja", columns=[{"key": "n", "label": "Name"}], rows=[{"n": "Ada"}]) + assert "table-zebra" in html + assert "is-striped" not in html + assert "Name" in html + assert "Ada" in html + + +def test_pagination_uses_the_join_group(render): + html = render("Pagination.jinja", page=2, total_pages=3, hx_url="/x", hx_target="#t") + assert "join-item" in html + assert "btn-active" in html + assert 'aria-current="page"' 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_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 "navbar-start" in html + assert "navbar-end" in html + + +def test_breadcrumb_uses_daisy_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 + assert "setActive('one')" in html + assert "tab-active" in html diff --git a/tests/unit/test_tailwind_content.py b/tests/unit/test_tailwind_content.py new file mode 100644 index 0000000..e0657ce --- /dev/null +++ b/tests/unit/test_tailwind_content.py @@ -0,0 +1,147 @@ +"""Tailwind tree-shaking guards for the DaisyUI theme (issue #6). + +DaisyUI sits on Tailwind, so a consuming app builds its own CSS. Every class +cf-ui's templates use has to be visible to Tailwind's content scanner, or it +is shaken out and the page renders unstyled. That fails silently — nothing +errors, the markup is just naked. + +There are exactly two ways it happens, and both are tested here: + +1. The content glob does not reach the installed package's templates. +2. A class name is assembled at render time (``alert-{{ type }}``), so the + complete token never appears in the source the scanner reads. +""" + +import glob as globlib +import re +from pathlib import Path + +import pytest + +PKG_DIR = Path(__file__).parent.parent.parent / "src" / "cf_ui" +TEMPLATES_DIR = PKG_DIR / "templates" + +DAISY_TEMPLATES = sorted( + [*(TEMPLATES_DIR / "jinja" / "daisy").glob("*.jinja")] + + [*(TEMPLATES_DIR / "cotton" / "_themes" / "daisy").glob("*.html")] +) + + +# --- Failure mode 1: the glob does not reach the templates ----------------- + + +def test_tailwind_content_globs_are_published(): + from cf_ui.themes import tailwind_content_globs + + patterns = tailwind_content_globs() + assert patterns, "no content globs published" + assert all(isinstance(p, str) for p in patterns) + + +def test_content_globs_resolve_to_absolute_paths(): + """A consumer's tailwind.config.js lives elsewhere — relative would break.""" + from cf_ui.themes import tailwind_content_globs + + for pattern in tailwind_content_globs(): + assert Path(pattern).is_absolute(), pattern + + +def test_content_globs_cover_every_daisy_template(): + """The test that fails when the templates would be tree-shaken away.""" + from cf_ui.themes import tailwind_content_globs + + covered = set() + for pattern in tailwind_content_globs(): + covered.update(Path(p).resolve() for p in globlib.glob(pattern, recursive=True)) + + assert DAISY_TEMPLATES, "no daisy templates found — the suite would pass vacuously" + missing = [t for t in DAISY_TEMPLATES if t.resolve() not in covered] + assert not missing, f"not reachable by the documented content glob: {missing}" + + +def test_content_globs_cover_the_public_cotton_wrappers(): + """The wrappers hold no classes today, but the glob must not exclude them.""" + from cf_ui.themes import tailwind_content_globs + + covered = set() + for pattern in tailwind_content_globs(): + covered.update(Path(p).resolve() for p in globlib.glob(pattern, recursive=True)) + + wrapper = (TEMPLATES_DIR / "cotton" / "cf" / "card.html").resolve() + assert wrapper in covered + + +def test_content_globs_are_rooted_in_the_installed_package(): + """Derived from ``cf_ui.__file__``, so a site-packages install works too.""" + from cf_ui.themes import tailwind_content_globs + + for pattern in tailwind_content_globs(): + assert str(PKG_DIR.resolve()) in str(Path(pattern).resolve()) + + +def test_documentation_publishes_the_same_glob(): + """Drift guard: docs that disagree with the code cause silent unstyled pages.""" + from cf_ui.themes import TAILWIND_CONTENT_SUFFIX + + doc = (Path(__file__).parent.parent.parent / "docs" / "daisyui.md").read_text(encoding="utf-8") + assert TAILWIND_CONTENT_SUFFIX in doc + + +# --- Failure mode 2: class names assembled at render time ------------------ + +# A class token split across a template construct is invisible to the scanner. +# alert-{{ type }} -> flagged (variable glued to a prefix) +# alert-{% if x %}error{% -> flagged (tag glued to a prefix) +# input-bordered{% if e %} -> fine (complete token, then a tag) +# {{ extra_class }} -> fine (a whole class handed in by the consumer) +_SPLIT_TOKEN = re.compile(r"[\w-]\{\{|\}\}[\w-]|-\{%|%\}-") +_CLASS_ATTR = re.compile(r"""\bclass="([^"]*)\"""") + + +def test_the_daisy_template_set_is_complete(): + """Without this, the parametrized scan below silently degrades to a skip.""" + assert len(DAISY_TEMPLATES) == 28, f"expected 14 jinja + 14 cotton, got {DAISY_TEMPLATES}" + + +@pytest.mark.parametrize("template", DAISY_TEMPLATES, ids=lambda p: p.name) +def test_no_daisy_class_name_is_assembled_at_render_time(template): + source = template.read_text(encoding="utf-8") + offenders = [value for value in _CLASS_ATTR.findall(source) if _SPLIT_TOKEN.search(value)] + assert not offenders, ( + f"{template.name}: class token split across a template construct — " + f"Tailwind will tree-shake it: {offenders}" + ) + + +def test_the_split_token_detector_actually_detects_a_split(): + """Meta-test: a guard that cannot fail is not a guard.""" + assert _SPLIT_TOKEN.search("alert alert-{{ type }}") + assert _SPLIT_TOKEN.search("alert alert-{% if e %}error{% endif %}") + assert not _SPLIT_TOKEN.search("input input-bordered {{ extra_class }}") + assert not _SPLIT_TOKEN.search("input input-bordered{% if error %} input-error{% endif %}") + + +# --- The type -> daisy class maps emit whole tokens ------------------------ + + +@pytest.mark.parametrize( + "prefix,expected", + [ + ("alert", {"alert-info", "alert-success", "alert-warning", "alert-error"}), + ( + "progress", + { + "progress-primary", + "progress-info", + "progress-success", + "progress-warning", + "progress-error", + }, + ), + ], +) +def test_variant_class_names_appear_literally_in_the_sources(prefix, expected): + """Whatever a component maps `type=` onto must be greppable as a whole word.""" + blob = "\n".join(t.read_text(encoding="utf-8") for t in DAISY_TEMPLATES) + missing = {name for name in expected if name not in blob} + assert not missing, f"{prefix} variants never appear literally: {missing}" diff --git a/tests/unit/test_theme_dispatch.py b/tests/unit/test_theme_dispatch.py new file mode 100644 index 0000000..a60af1f --- /dev/null +++ b/tests/unit/test_theme_dispatch.py @@ -0,0 +1,218 @@ +"""Theme dispatch for the django-cotton template set (issue #6). + +The Jinja side already switches themes by directory — ``install_cf_ui`` +registers ``templates/jinja//`` with JinjaX. Cotton has no equivalent: +```` resolves through django-cotton's single global ``COTTON_DIR`` +to exactly one file, and cf-ui deliberately does not touch ``COTTON_DIR`` +(see tests/unit/test_consumer_compatibility.py). + +So the public component at ``cotton/cf/.html`` stays a stable, theme-free +entry point and dispatches to a per-theme partial at +``cotton/_themes//.html``. One setting — ``CF_UI_THEME`` — moves +all 14 components at once, with no template edits in the consuming app. +""" + +from pathlib import Path + +import pytest + +TEMPLATES_DIR = Path(__file__).parent.parent.parent / "src" / "cf_ui" / "templates" +COTTON_DIR = TEMPLATES_DIR / "cotton" + +# Cotton file stems (hyphenated), as used by . +COMPONENT_STEMS = [ + "breadcrumb", + "card", + "checkbox-group", + "form-field", + "modal", + "navbar", + "notification", + "pagination", + "panel", + "progress", + "select", + "table", + "tabs", + "textarea", +] + +IMPLEMENTED_THEMES = ["bulma", "daisy"] + + +# --- The theme registry ---------------------------------------------------- + + +def test_themes_module_lists_the_implemented_themes(): + from cf_ui.themes import THEMES + + assert sorted(THEMES) == sorted(IMPLEMENTED_THEMES) + + +def test_components_tuple_matches_the_shipped_cotton_files(): + """Drift guard: the registry and the filesystem must not disagree.""" + from cf_ui.themes import COMPONENTS + + assert sorted(COMPONENTS) == sorted(COMPONENT_STEMS) + + +def test_resolve_theme_defaults_to_bulma(): + from cf_ui.themes import resolve_theme + + assert resolve_theme(None) == "bulma" + + +def test_resolve_theme_accepts_an_implemented_theme(): + from cf_ui.themes import resolve_theme + + assert resolve_theme("daisy") == "daisy" + + +def test_resolve_theme_rejects_a_stub_theme_by_name(): + """bootstrap/foundation/fomantic are PLANNED.md stubs, not usable themes.""" + from cf_ui.themes import ThemeError, resolve_theme + + with pytest.raises(ThemeError, match="bootstrap"): + resolve_theme("bootstrap") + + +def test_resolve_theme_error_names_the_available_themes(): + from cf_ui.themes import ThemeError, resolve_theme + + with pytest.raises(ThemeError) as exc: + resolve_theme("tailwind") + assert "daisy" in str(exc.value) + + +def test_cotton_partial_builds_the_theme_scoped_path(): + from cf_ui.themes import cotton_partial + + assert cotton_partial("modal", "daisy") == "cotton/_themes/daisy/modal.html" + + +def test_cotton_partial_rejects_an_unknown_component(): + """Guards against a typo in a wrapper silently resolving to nothing.""" + from cf_ui.themes import ThemeError, cotton_partial + + with pytest.raises(ThemeError, match="dropdown"): + cotton_partial("dropdown", "bulma") + + +# --- The partials exist on disk ------------------------------------------- + + +@pytest.mark.parametrize("theme", IMPLEMENTED_THEMES) +@pytest.mark.parametrize("stem", COMPONENT_STEMS) +def test_every_theme_ships_every_cotton_partial(theme, stem): + assert (COTTON_DIR / "_themes" / theme / f"{stem}.html").is_file() + + +@pytest.mark.parametrize("stem", COMPONENT_STEMS) +def test_public_cotton_component_holds_no_theme_markup(stem): + """The wrapper declares props and dispatches — nothing else. + + If Bulma class names leak back into cotton/cf/*.html, switching themes + stops being a config change, which is exactly what this phase promises. + """ + source = (COTTON_DIR / "cf" / f"{stem}.html").read_text(encoding="utf-8") + assert "{% include" in source + for bulma_marker in ('class="field', 'class="card', "is-danger", 'class="navbar'): + assert bulma_marker not in source + + +@pytest.mark.parametrize("stem", COMPONENT_STEMS) +def test_theme_partials_declare_no_cotton_vars(stem): + """ belongs on the public wrapper only — it is the prop contract. + + Duplicating it in each partial would mean 28 places to keep in sync. + """ + for theme in IMPLEMENTED_THEMES: + source = (COTTON_DIR / "_themes" / theme / f"{stem}.html").read_text(encoding="utf-8") + assert " Date: Wed, 29 Jul 2026 00:15:22 -0400 Subject: [PATCH 2/3] build: declare cf_ui first-party for isort so hooks and CI agree The pinned ruff-pre-commit hook (v0.3.0) and the ruff CI resolves from `dev` (0.16.x) infer first-party differently for a src/ layout, so each reverted the other's import fix: prek went green and CI then failed I001 on the file the hook had just "fixed". Stating known-first-party removes the ambiguity for both versions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf --- pyproject.toml | 8 ++++++++ .../templates/cotton/_themes/bulma/checkbox-group.html | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0e5e41c..29e0fdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,14 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] +[tool.ruff.lint.isort] +# Stated explicitly because the pinned pre-commit hook (ruff v0.3.0) and the +# ruff CI resolves from `dev` (0.16.x) infer this differently for a src/ +# layout: the older version treats `cf_ui` as third-party, the newer as +# first-party, so each reverts the other's import fix and `prek` can pass +# while CI fails I001 on the same file. +known-first-party = ["cf_ui"] + [tool.ruff.format] quote-style = "double" indent-style = "space" diff --git a/src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html b/src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html index d8950d9..190977a 100644 --- a/src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html +++ b/src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html @@ -12,4 +12,4 @@ {% endfor %}
{% if error %}

{{ error }}

{% endif %} -
\ No newline at end of file + From 853d98cdc5a4b1f92d5b07a0c298864680fa2a1f Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Wed, 29 Jul 2026 00:21:07 -0400 Subject: [PATCH 3/3] fix(themes): correct DaisyUI navbar toggle and Tailwind glob separators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found by adversarial review of the PR diff, both of which the original tests were too weak to catch. tailwind_content_globs() emitted native Windows paths. Those strings are pasted into a tailwind.config.js or an @source directive, where a backslash is a JavaScript escape and fast-glob reads it as an escape rather than a separator — so the glob matches nothing and every class is shaken out, which is the exact silent failure the API exists to prevent. The old test only asserted Path.is_absolute(), which backslashes satisfy. The navbar toggled `flex` onto an element that kept `hidden`. Both are display utilities in the same layer and Tailwind emits `hidden` last, so the class list changed and the menu still never appeared. The E2E test asserted classList membership, so it passed a broken component; it now asserts computed display, verified to fail against the old markup. The DaisyUI E2E gallery now loads the Tailwind play CDN. DaisyUI ships component classes but no utilities, so without it `hidden` and `lg:flex` resolved to nothing and this tier could not observe layout behavior at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf --- .../templates/cotton/_themes/daisy/navbar.html | 9 +++++++-- src/cf_ui/templates/jinja/daisy/Navbar.jinja | 9 +++++++-- src/cf_ui/themes.py | 10 ++++++++-- tests/e2e/test_daisy.py | 15 +++++++++------ tests/integration/jinja_app/main.py | 11 +++++++++++ tests/unit/test_tailwind_content.py | 13 +++++++++++++ 6 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/cf_ui/templates/cotton/_themes/daisy/navbar.html b/src/cf_ui/templates/cotton/_themes/daisy/navbar.html index dcb34f0..25f4455 100644 --- a/src/cf_ui/templates/cotton/_themes/daisy/navbar.html +++ b/src/cf_ui/templates/cotton/_themes/daisy/navbar.html @@ -8,6 +8,11 @@ :aria-expanded="menuOpen" @click="toggle()">☰ - - + {# Toggle `hidden` rather than adding `flex`: both are display utilities in + the same layer, and Tailwind emits `hidden` last, so adding `flex` to an + element that keeps `hidden` would never show it. Removing `hidden` leaves + the div at its default display on mobile, and `lg:flex` (a media query, + emitted after the base utilities) governs desktop either way. #} + + diff --git a/src/cf_ui/templates/jinja/daisy/Navbar.jinja b/src/cf_ui/templates/jinja/daisy/Navbar.jinja index cc5d195..f7b3f90 100644 --- a/src/cf_ui/templates/jinja/daisy/Navbar.jinja +++ b/src/cf_ui/templates/jinja/daisy/Navbar.jinja @@ -13,6 +13,11 @@ :aria-expanded="menuOpen" @click="toggle()">☰ - - + {# Toggle `hidden` rather than adding `flex`: both are display utilities in + the same layer, and Tailwind emits `hidden` last, so adding `flex` to an + element that keeps `hidden` would never show it. Removing `hidden` leaves + the div at its default display on mobile, and `lg:flex` (a media query, + emitted after the base utilities) governs desktop either way. #} + + diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py index b73c4f5..3fbd49e 100644 --- a/src/cf_ui/themes.py +++ b/src/cf_ui/themes.py @@ -92,11 +92,17 @@ def tailwind_content_globs() -> list[str]: no error and an unstyled page as the only symptom. Paths are absolute and derived from ``cf_ui.__file__`` so they work from a site-packages install, an editable install, or a vendored copy. + + Separators are always forward slashes, including on Windows. These strings + are pasted into a ``tailwind.config.js`` or an ``@source`` directive, where + a backslash is a JavaScript escape character and fast-glob (Tailwind's + matcher) treats it as an escape rather than a separator — a native Windows + path would match nothing and shake every class out, silently. """ templates = _HERE / "templates" return [ - str(templates / "**" / "*.html"), - str(templates / "**" / "*.jinja"), + (templates / "**" / "*.html").as_posix(), + (templates / "**" / "*.jinja").as_posix(), ] diff --git a/tests/e2e/test_daisy.py b/tests/e2e/test_daisy.py index 68ad3f9..4d08883 100644 --- a/tests/e2e/test_daisy.py +++ b/tests/e2e/test_daisy.py @@ -126,14 +126,17 @@ def test_jinja_navbar_burger_toggles_menu(daisy_jinja_page, daisy_jinja_server_u if js_mode == "js_on": _wait_for_alpine(page) page.set_viewport_size({"width": 600, "height": 800}) - # classList membership, not a regex — "lg:flex" is always present and - # a word-boundary pattern matches inside it. - has_flex = "el => el.classList.contains('flex')" menu = page.locator(".navbar-end") - assert menu.evaluate(has_flex) is False + # Assert the computed display, not the class list. An earlier version + # toggled `flex` onto an element that kept `hidden`; the class list + # changed and the menu still never appeared, because Tailwind emits + # `hidden` after `flex`. Only computed style catches that. + display = "el => getComputedStyle(el).display" + assert menu.evaluate(display) == "none" page.locator("button[aria-label='menu']").click() - expect(menu).to_have_class(re.compile(r"\bflex\b")) - assert menu.evaluate(has_flex) is True + # Not to_be_visible(): the gallery passes an empty `end` slot, so the + # box has zero height even once it is displayed. + assert menu.evaluate(display) != "none" else: expect(page.locator(".navbar")).to_be_attached() diff --git a/tests/integration/jinja_app/main.py b/tests/integration/jinja_app/main.py index 4dcd773..ad881ea 100644 --- a/tests/integration/jinja_app/main.py +++ b/tests/integration/jinja_app/main.py @@ -13,6 +13,16 @@ "daisy": "https://cdn.jsdelivr.net/npm/daisyui@4.7.2/dist/full.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": '', +} + def make_app(theme: str = "bulma") -> FastAPI: """Build a JinjaX gallery app for one theme. @@ -87,6 +97,7 @@ async def gallery(): return f""" + {_THEME_EXTRA_HEAD[theme]} diff --git a/tests/unit/test_tailwind_content.py b/tests/unit/test_tailwind_content.py index e0657ce..7d333b6 100644 --- a/tests/unit/test_tailwind_content.py +++ b/tests/unit/test_tailwind_content.py @@ -46,6 +46,19 @@ def test_content_globs_resolve_to_absolute_paths(): assert Path(pattern).is_absolute(), pattern +def test_content_globs_use_forward_slashes_on_every_platform(): + """These strings get pasted into JS, where a backslash is an escape. + + fast-glob (Tailwind's matcher) reads ``\\`` as an escape rather than a + separator, so a native Windows path matches nothing — and matching nothing + is indistinguishable from the tree-shaking this whole module prevents. + """ + from cf_ui.themes import tailwind_content_globs + + for pattern in tailwind_content_globs(): + assert "\\" not in pattern, pattern + + def test_content_globs_cover_every_daisy_template(): """The test that fails when the templates would be tree-shaken away.""" from cf_ui.themes import tailwind_content_globs