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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ jobs:
- name: Format check
run: ruff format --check src tests

# Run directly, not only through the pytest wrapper: that wrapper skips
# when node is absent, and a skip is not a pass. This step has no such
# escape hatch, so the plugin's own suite is always exercised in CI.
- name: Tailwind plugin tests (node)
run: |
node --version
node --test --test-reporter=spec "tests/js/**/*.test.mjs"

- name: Unit tests
run: pytest tests/unit/ -q --tb=short

Expand Down
7 changes: 6 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ repos:
- id: check-yaml
- id: check-merge-conflict

# Keep this in step with the `ruff` that `dev` resolves to (pyproject:
# `ruff>=0.1`), which is what CI runs. When the two drifted, v0.3.0 and
# 0.16.x disagreed about first-party imports and about `assert x, (msg)`
# wrapping, and each reverted the other's fix — so `prek` could pass while
# CI failed on the very same file.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
rev: v0.16.0
hooks:
- id: ruff
args: [--fix]
Expand Down
52 changes: 37 additions & 15 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
## [Unreleased]

### Added
- Tailwind plugin with build-time axis validation (#7): an unknown axis value now
**fails the CSS build** instead of silently producing an unstyled element
- `static/cf_ui/cf_ui_tailwind_plugin.mjs` — vendored into the wheel, not published
to npm, so its version can never skew from the definition it enforces. Imports
nothing but Node builtins and exports Tailwind's `{ handler, config }` shape,
usable via `@plugin` or a JS config
- `cf_ui.axes.axis_definition()` and `static/cf_ui/cf_ui_axes.json` — the axis
definition as data, so the plugin can read it from inside a CSS build where
Python is not. `python -m cf_ui.axes` now writes both generated files
- The `@media (color-gamut: p3)` layer is generated by the plugin from the same
definition rather than kept in sync by hand
- Optional contrast report (`contrastReport: true`) over every accent × surface ×
mode; warns rather than failing, so cf-ui does not decide when a consuming app
may compile
- Consumer value sets accepted by the plugin with the same `extend` / `replace`
semantics as `merge_value_sets` (#5)
- `tests/js/` — a `node --test` tier, run directly in CI and wrapped by pytest
- `docs/tailwind-plugin.md`; `just test-js` and `just axes`
- **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`
Expand All @@ -18,6 +36,20 @@
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
- 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.
- `cf_ui/axes.py` — single source of truth for axis definitions, named
compositions, value-set merging, CSS generation, and WCAG contrast checking
- `static/cf_ui/cf_ui_axes.css` — generated from `axes.py` via `python -m cf_ui.axes`,
delivered by the existing asset tags; a unit test fails on drift
- `CF_UI_COMPOSITION`, `CF_UI_AXIS_VALUES`, `CF_UI_AXIS_VALUES_MODE` Django settings,
validated at startup by `CfUiConfig.ready()`
- `{% cf_ui_root_attrs %}` template tag and `cf_ui_root_attrs()` Jinja macro —
one setting in, five attributes out
- `composition=` / `value_sets=` / `value_sets_mode=` on both `install_cf_ui()`
functions, registering the Jinja globals the macros delegate to
- `docs/theming.md` — axis reference, custom value sets, and the contrast requirement

### Changed
- E2E harness is parameterized by theme: `make_app(theme)` for the FastAPI
Expand All @@ -27,6 +59,11 @@
documented one

### Technical Notes
- `axes.py` remains the single source of truth. The plugin holds no copy of the
value sets, and a parity test compares both generators' output declaration by
declaration — selectors, custom properties, values, and the p3 layer
- Token values containing `;`, `{`, `}`, or a comment delimiter are rejected by the
plugin: they close their own declaration and write rules the app never authored
- 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
Expand All @@ -45,21 +82,6 @@
`@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.
- `cf_ui/axes.py` — single source of truth for axis definitions, named
compositions, value-set merging, CSS generation, and WCAG contrast checking
- `static/cf_ui/cf_ui_axes.css` — generated from `axes.py` via `python -m cf_ui.axes`,
delivered by the existing asset tags; a unit test fails on drift
- `CF_UI_COMPOSITION`, `CF_UI_AXIS_VALUES`, `CF_UI_AXIS_VALUES_MODE` Django settings,
validated at startup by `CfUiConfig.ready()`
- `{% cf_ui_root_attrs %}` template tag and `cf_ui_root_attrs()` Jinja macro —
one setting in, five attributes out
- `composition=` / `value_sets=` / `value_sets_mode=` on both `install_cf_ui()`
functions, registering the Jinja globals the macros delegate to
- `docs/theming.md` — axis reference, custom value sets, and the contrast requirement

## [0.1.1] — 2026-04-28

### Fixed
Expand Down
20 changes: 19 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ uv pip install -e ".[dev]" # install with all dev deps
playwright install chromium # install E2E browser

just test # unit tests only
just test-js # Tailwind plugin suite (node --test)
just axes # regenerate cf_ui_axes.css + cf_ui_axes.json
just test-integration # integration tests (real HTTP)
just test-e2e # E2E Playwright (requires chromium)
just test-all # full suite
Expand Down Expand Up @@ -46,7 +48,10 @@ src/cf_ui/
│ ├── 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
├── cf_ui_alpine.js # Alpine named components + $cf global store
├── cf_ui_axes.css # GENERATED from axes.py
├── cf_ui_axes.json # GENERATED from axes.py — read by the plugin below
└── cf_ui_tailwind_plugin.mjs # Tailwind plugin: build-time axis validation
```

Templates live **inside** the Python package so hatchling includes them automatically.
Expand All @@ -68,6 +73,19 @@ Templates live **inside** the Python package so hatchling includes them automati
**Django AppConfig:**
- Register as `"cf_ui.django.CfUiConfig"` (full class path), NOT `"cf_ui.django"` — `default_app_config` is removed in Django 4.2+

**Theme axes / Tailwind plugin:**
- `axes.py` is the single source of truth; `cf_ui_axes.css` **and** `cf_ui_axes.json`
are build products of it. Edit `axes.py`, run `just axes`, commit both — drift
tests fail otherwise
- The plugin holds no copy of the value sets, and a parity test compares its output
against `render_axis_css` declaration by declaration. Never add value data to the
`.mjs`
- The `.mjs` is vendored, so it may import **only** Node builtins — a bare
`tailwindcss/plugin` import will not resolve from site-packages. It exports
Tailwind's `{ handler, config }` shape by hand instead
- `node --test` runs as its own CI step because the pytest wrapper skips when node
is absent, and a skip is not a pass

**Alpine.js:**
- `cf_ui_alpine.js` must load BEFORE the Alpine CDN (both use `defer` — DOM order guarantees execution sequence)
- Modal control uses `cf-modal-open` / `cf-modal-close` custom events dispatched to the element by ID — NOT `_x_dataStack` (private API)
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,26 @@ Apps supply their own value sets via `CF_UI_AXIS_VALUES` (`value_sets=` for Jinj

→ Full guide: [`docs/theming.md`](docs/theming.md)

### Build-time validation (Tailwind)

The closed value sets are only a promise until something enforces them.
`data-accent="hotpink"` produces no error on its own — it produces an element
with no accent. cf-ui ships a Tailwind plugin that **fails the build** on an
unknown axis value, generates the axis CSS and its `@media (color-gamut: p3)`
layer from the same definition, and can warn with WCAG numbers for every
accent × surface × mode.

```css
@import "tailwindcss";
@plugin "../.venv/lib/python3.12/site-packages/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs";
```

The plugin is **vendored in the wheel, not published to npm** — a separate npm
version could disagree with the installed package about what a valid value is,
which is the exact failure it exists to prevent.

→ Full guide: [`docs/tailwind-plugin.md`](docs/tailwind-plugin.md)

---

## Alpine.js Integration
Expand Down
200 changes: 200 additions & 0 deletions docs/tailwind-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# The Tailwind plugin

`cf-ui` ships a Tailwind plugin that **validates axis values at build time** and
generates the axis CSS. The validation is the point: without it,
`data-accent="hotpink"` is not an error, it is an element with no accent —
found in review, or in production, rather than at build time.

```
src/cf_ui/static/cf_ui/
├── cf_ui_axes.json # generated by `python -m cf_ui.axes`
└── cf_ui_tailwind_plugin.mjs # reads the JSON above
```

## Distribution: vendored

The plugin is **not published to npm** and is not installed from git. It ships
inside the Python wheel, beside `cf_ui_alpine.js`, and consuming apps point
their Tailwind build at it in `site-packages`.

The reasoning is version coupling. The plugin's whole job is to enforce a
contract defined in `cf_ui/axes.py`; an npm package would introduce a second
version number that can disagree with the installed wheel, and the failure mode
of that disagreement is precisely the one this plugin exists to prevent —
values that validate against one definition and not the other. Shipping the
plugin in the wheel makes the two impossible to skew: there is one artifact,
one version, one definition.

The cost is an uglier import path. That is the trade being made deliberately.

### Wiring it up

Tailwind v4, CSS-first:

```css
@import "tailwindcss";
@plugin "../.venv/lib/python3.12/site-packages/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs";
```

Options can be passed on the CSS path too, but `@plugin` options are **flat
key/value pairs** — good for a named composition or a boolean:

```css
@plugin "../.venv/.../cf_ui_tailwind_plugin.mjs" {
composition: console;
contrastReport: true;
}
```

A per-axis map (`{ accent: "brand" }`) or a custom `valueSets` cannot be
expressed in that syntax. Use the JS config for those:

```js
import cfUiAxes from "../.venv/lib/python3.12/site-packages/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs";

export default {
plugins: [cfUiAxes({ composition: "console", contrastReport: true })],
};
```

Ask Python where the file is rather than hardcoding a virtualenv layout:

```bash
python -c "from cf_ui.axes import AXIS_DEFINITION_PATH as p; print(p.parent / 'cf_ui_tailwind_plugin.mjs')"
```

You still need `cf_ui.themes.tailwind_content_globs()` in your `content` /
`@source` config so the component classes survive tree-shaking — see
[daisyui.md](daisyui.md). The plugin generates custom properties, which
Tailwind never tree-shakes; the two concerns are separate.

## Options

| Option | Default | Meaning |
|---|---|---|
| `composition` | `null` | A named composition, a partial `{axis: value}` map, or `null` for the default. **Validated — an unknown value throws.** |
| `valueSets` | `null` | Your app's own `{axis: {name: tokens}}`. |
| `valueSetsMode` | `"extend"` | `"extend"` adds to the shipped set; `"replace"` discards the shipped values for each axis you supply. |
| `contrastReport` | `false` | Warn with every accent × surface × mode and its WCAG AA numbers. |
| `definition` | shipped JSON | Override the axis definition entirely. Mostly for tests. |

## What fails the build, and what only warns

**Fails** — these are unambiguously mistakes:

- an axis value that no value set declares
- an axis name that does not exist
- an unknown named composition
- a value name that is not lowercase-and-hyphens
- a mode-keyed value (`accent`, `surface`) missing `light` or `dark`
- a token that is not a CSS custom property
- a token value containing `;`, `{`, `}`, or a comment delimiter — those close
the declaration and write rules you did not author
- a value or composition name that exists only on `Object.prototype`
(`toString`, `hasOwnProperty`, …), and a value set keyed on `__proto__`

**Warns** — `contrastReport: true`:

- a combination below WCAG AA

Contrast is advisory on purpose. An unknown axis value is always wrong. A
contrast number below AA may be a deliberate choice in a value set the package
did not ship, and making it fatal would mean cf-ui decides when your app is
allowed to compile. The report exists so nobody can say they never saw the
numbers.

```
cf-ui: contrast report — 1 of 12 combinations fail WCAG AA:
faint x plain (light):
--cf-accent-content on --cf-accent: 1.09 < 4.5
--cf-accent-strong on --cf-ground: 1.13 < 4.5
```

## One definition, two generators — and a test that proves it

`cf_ui/axes.py` is still the single source of truth. It generates two
artifacts:

```bash
python -m cf_ui.axes # or: just axes
# wrote .../cf_ui_axes.css
# wrote .../cf_ui_axes.json
```

The plugin holds **no copy of the value sets**. It reads `cf_ui_axes.json` at
build time, which is why it does not need Python and why it cannot drift from
it.

That is guaranteed rather than asserted.
`tests/unit/test_tailwind_plugin.py` runs both generators and compares their
output declaration by declaration — every selector, every custom property,
every value, plus the `@media (color-gamut: p3)` layer. If `render_axis_css`
and the plugin ever disagree, those tests fail. A separate drift test pins the
committed JSON to `axis_definition()`, so editing `axes.py` without
regenerating is also caught.

If you add an axis value, edit `axes.py`, run `python -m cf_ui.axes`, and
commit both generated files.

## Custom value sets

```js
cfUiAxes({
valueSets: {
accent: {
brand: {
light: {
"--cf-accent": "#7c3aed",
"--cf-accent-content": "#ffffff",
"--cf-accent-strong": "#5b21b6",
},
dark: {
"--cf-accent": "#c4b5fd",
"--cf-accent-content": "#2e1065",
"--cf-accent-strong": "#ddd6fe",
},
},
},
},
composition: { accent: "brand" },
contrastReport: true,
});
```

`light` and `dark` must each be declared. Neither is derived from the other by
inversion — an inverted palette is a second theme nobody designed.

Base declarations must be sRGB hex, because that is what the contrast numbers
are computed against. Wide-gamut chroma goes in an optional `p3` block at the
same lightness, and the plugin generates the `@media (color-gamut: p3)` layer
from it:

```js
brand: {
light: { "--cf-accent": "#7c3aed", /* ... */ },
dark: { "--cf-accent": "#c4b5fd", /* ... */ },
p3: {
light: { "--cf-accent": "oklch(51.6% 0.246 293.9)" },
dark: { "--cf-accent": "oklch(81.1% 0.111 293.6)" },
},
}
```

## The axis value sets are a public API

Consuming apps that expose theming to *their own* end users — white-label or
multi-tenant products — cannot rely on whoever picks a value having read this
page. That is why the check is a build error and not a runtime warning.

It also means the shipped value names are treated as public API: additive
changes only, and a deprecation path for removals.

## Testing the plugin

```bash
just test-js # node --test, never skips
just test # pytest; runs the JS suite too, skipping if node is absent
```

CI runs `node --test` as its own step so the JS suite can never be silently
skipped there.
9 changes: 9 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ lint-fix:
test:
pytest tests/unit -q --tb=short

# The Tailwind plugin's own suite. `just test` runs it too, via a pytest
# wrapper that skips when node is missing; this recipe never skips.
test-js:
node --test --test-reporter=spec "tests/js/**/*.test.mjs"

# Rebuild cf_ui_axes.css and cf_ui_axes.json from axes.py.
axes:
python -m cf_ui.axes

test-integration:
pytest tests/integration -q --tb=short

Expand Down
Loading
Loading