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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.html` is now a wrapper that
declares `<c-vars>` and includes `cotton/_themes/<theme>/<name>.html` via the
new `{% cf_ui_theme_path %}` tag. Consumer templates are unchanged —
`<c-cf.card>` 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.
Expand All @@ -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
Expand Down
33 changes: 24 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<theme>/ # 14 JinjaX component templates (*.jinja) per theme
│ ├── cotton/cf/ # 14 public wrappers — <c-cf.x>, props + dispatch only
│ └── cotton/_themes/<theme>/ # 14 theme partials (*.html), included by the wrappers
└── static/cf_ui/
└── cf_ui_alpine.js # Alpine named components + $cf global store
```
Expand Down Expand Up @@ -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** `<c-vars>` — 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`.
40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ Component names are **theme-agnostic** — `<CfCard>` and `<c-cf.card>` 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]"
```

Expand Down Expand Up @@ -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/<theme>/cf/*.html`) so cf-ui can sit
> alongside any consumer project's own `templates/cotton/<app>/...` 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/<theme>/`,
> selected by `CF_UI_THEME`. Render `<c-cf.card>`; never reach into
> `_themes/` directly.
>
> The Jinja side needs no such indirection — `install_cf_ui(catalog, theme=…)`
> registers `templates/jinja/<theme>/` and JinjaX resolves `<CfCard>` 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.

---

Expand Down
165 changes: 165 additions & 0 deletions docs/daisyui.md
Original file line number Diff line number Diff line change
@@ -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: `<c-cf.card>` and
`<CfCard>` 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/<theme>/` with the catalog, so `<CfCard>` resolves to
whichever theme was installed.

**django-cotton** cannot do that. `<c-cf.card>` 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/<their-app>/*.html`. So the
public component stays at the fixed path `cotton/cf/<name>.html` and its
entire body is a dispatch:

```django
<c-vars header="" footer="" class="" />
{% load cf_ui %}{% cf_ui_theme_path "card" as cf_ui_partial %}{% include cf_ui_partial %}
```

The theme markup lives in `cotton/_themes/<theme>/<name>.html`. `{% include %}`
inherits the caller's context, so props and `{{ slot }}` reach the partial
without being redeclared — `<c-vars>` stays in exactly one place per
component. The leading underscore marks the directory private: render
`<c-cf.card>`, never `<c-_themes.daisy.card>`.

> **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" #}
<div class="alert alert-{{ type }}">
```

Every variant class in the DaisyUI templates is written out in full instead:

```django
<div class="alert {% if type == 'danger' %}alert-error{% elif type == 'success' %}alert-success{% else %}alert-info{% endif %}">
```

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.
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
12 changes: 11 additions & 1 deletion src/cf_ui/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
merge_value_sets,
resolve_composition,
)
from cf_ui.themes import ThemeError, resolve_theme


def axis_value_sets() -> dict:
Expand Down Expand Up @@ -38,12 +39,21 @@ class CfUiConfig(AppConfig):
# cotton templates live at cotton/<their-app>/*.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),
Expand Down
Loading
Loading