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
28 changes: 28 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,31 @@ repos:
- id: ruff
args: [--fix]
- id: ruff-format

# Two invocations because `profile` is a single global setting and the two
# template sets are different languages: `templates/cotton/**` is Django
# template language, `templates/jinja/**` is Jinja2. Running one profile
# over both misreads the other's syntax.
#
# `--reformat` rather than `--check`: this is a formatter, and a hook that
# only complains leaves the author to reproduce the fix by hand. Everything
# it does is layout inside an opening tag — see the rationale on
# `[tool.djlint]` in pyproject.toml, including the render-equivalence check
# behind the claim that this is safe.
- repo: https://github.com/djlint/djLint
rev: v1.43.1
hooks:
- id: djlint-reformat-django
name: djlint-reformat (cotton)
files: ^src/cf_ui/templates/cotton/.*\.html$
- id: djlint-django
name: djlint (cotton)
files: ^src/cf_ui/templates/cotton/.*\.html$
- id: djlint-reformat-jinja
name: djlint-reformat (jinja)
files: ^src/cf_ui/templates/jinja/.*\.jinja$
args: [--extension=jinja]
- id: djlint-jinja
name: djlint (jinja)
files: ^src/cf_ui/templates/jinja/.*\.jinja$
args: [--extension=jinja]
109 changes: 109 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,115 @@

## [Unreleased]

### Added — a primitives layer: button, badge, heading, label, icon (#52)

- **Five new components, on all five themes, in both template sets.**
`<Cf:Button>` / `<c-cf.button>`, and the same for `Badge`, `Heading`,
`Label`, `Icon`. cf-ui shipped 14 *structural* components and no primitives,
which is why adoption stalled: measured against a real consumer, `button`
was the single most-used CSS-framework class in the codebase (204 uses) and
cf-ui had nothing for it, while `modal`, `tabs`, `panel` and `breadcrumb`
had zero uses between them.

- **`src/cf_ui/primitives.py` is the closed vocabulary and the class map.**
`variant`, `size`, `state`, `level`, `emphasis` and `type` are fixed sets; a
value outside one raises `PrimitiveConfigError` naming the values that would
have worked. Which primitive accepts which axis is declared, so
`<Cf:Badge state="loading">` raises rather than rendering a badge with no
loading state.

- **Every axis declares what its value *becomes*: a class, a tag, or an
attribute.** `AXIS_KINDS` is that declaration, and it is what makes the
empty-value rule derivable rather than case-by-case. Django resolves a
missing context variable to `""` and the cotton wrappers forward props
unconditionally, so an absent class-valued axis has to be benign — it yields
an unstyled element, which is exactly what a missing `variant` should do. An
absent *tag*- or *attribute*-valued one does not: an empty `level` has no
`<h?>` to render, so it raises and the message says which kind of thing was
missing and what to pass. `CLASS_VALUED` is derived from `AXIS_KINDS`, no
theme may map a non-class axis, and both are enforced by tests — which is
also how `type` (`button` / `submit` / `reset`, the HTML default being
`submit` inside a form) got a vocabulary and a guard instead of passing
through unchecked.

- **`ALIASES` names the props whose HTML spelling cf-ui cannot use.**
`<c-cf.label for="email">` used to render valid HTML with no `for` attribute
at all: `for` is a Python reserved word so the prop is `for_id`, and
django-cotton silently discards attributes a component does not declare. The
wrapper now forwards the HTML spelling into the guard purely so it can be
rejected, and the error names `for_id`. This catches the *declared*
confusions, not arbitrary typos — `docs/primitives.md` says so plainly.

- **The classes are spelled out longhand in the templates, on purpose.**
daisyUI compiles through Tailwind, whose scanner reads source *text* — a
class assembled at render time (`btn-{{ variant }}`, or one returned from
Python) is tree-shaken out of the build with no error and an unstyled
element as the only symptom. So `primitives.py` and the templates hold the
same knowledge twice, and a bidirectional parity test in
`tests/unit/test_primitives.py` fails the build the moment they disagree —
in either direction. `classes_for()` exists for tests, docs and
outside-the-templates consumers; nothing in the shipped templates calls it.

- **`static/cf_ui/cf_ui_primitives.json`** is generated from the module by
`just primitives`, the way `cf_ui_axes.json` is generated from `axes.py`,
and a drift test fails if the committed copy is stale.

- **Escaping and accessibility are decided once, in the component.** A
disabled `<a>` renders `aria-disabled="true"` and *no* `href` — an anchor
without one is not focusable or activatable, so this is the only spelling
where "disabled" is more than cosmetic. An icon is either decorative
(`aria-hidden="true"`) or named (`role="img"` plus `aria-label`), never
both, and `role="img"` makes it a leaf so the caller's glyph markup is not
descended into. Primitives take slot content rather than markup props, so
`docs/escaping.md` needs no new rule.

- **`docs/primitives.md`** documents the shared contract, composition, the
per-theme places an axis is legitimately inert, and why the classes are
longhand. Tier 2 (`box`/`surface`, `prose`) and Tier 3 (`grid`) are tracked
separately so this could ship on its own.

### Added — djLint formats both template trees, one attribute per line

- **`prek` now reformats and lints `templates/cotton/**.html` and
`templates/jinja/**.jinja`.** Four hooks pinned at djLint v1.43.1, two per
tree, because `profile` is one global setting and the two trees are
different languages. Settings live in `[tool.djlint]`; `just
format-templates` and `just lint-templates` run the same thing by hand, and
`just check` now includes the lint pass.

- **`single_attribute_per_line = true` is the point of the exercise.** A
cf-ui template's entire contract lives in its opening tag — `<c-vars>`
declares every prop, and a theme partial's element carries a literal
`{% if %}` chain per axis. Authored on one line, because Django has no
whitespace-control syntax and the safe default was "emit no whitespace", a
wrapper's prop list was a 100-character run read character by character.
107 cotton and 95 jinja templates were reformatted.

- **Checked rather than assumed.** Whitespace *between* attributes is
insignificant in HTML; whitespace *inside* a `class` value is not — it
changes the rendered bytes and defeats every substring assertion in the
suite — so djLint is not allowed near the class chains. All 190 primitive
renders (5 themes × 19 components × 2 engines) came out byte-identical with
whitespace stripped, and the parsed DOM matched except for djLint adding a
space after `;` in inline `style` attributes. One assertion did depend on
layout — `"required>" in html`, which relied on the attribute being last
before the bracket — and is now anchored to the `<input>` tag itself, which
is what it always meant.

### Fixed — multi-line `{# #}` comments rendered into the page

- **Six shipped cotton partials leaked their own source comments as page
text.** Django's comment regex is `\{#.*?#\}` without `DOTALL`: `{# #}` is
single-line only, so a comment opened on one line and closed on another
never forms a comment token and every line in between is emitted verbatim.
Five bootstrap partials and one daisy partial were affected —
`checkbox-group`, `modal`, `navbar`, `panel`, `progress` and
`daisy/navbar` — each shipping a paragraph of rationale prose about z-index
stacking or Tailwind layer ordering straight into the consumer's HTML. All
six now use `{% comment %}`, and
`tests/unit/cotton/test_comment_syntax.py` fails the build on a recurrence.
Found while writing a primitives wrapper that made the same mistake.

### Changed — dependencies resolve from PyPI, not from git (#50)

- **Dropped the `[tool.uv.sources]` git pin for `component-framework`.** It
Expand Down
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ just test-all # full suite
just lint # ruff check
just lint-fix # ruff check --fix
just format # ruff format
just check # lint + unit tests
just format-templates # djlint --reformat, both template trees
just lint-templates # djlint check, both template trees
just check # lint + lint-templates + unit tests
just build # hatch build wheel

pytest tests/unit/ -v # unit tests
Expand Down Expand Up @@ -73,11 +75,23 @@ Templates live **inside** the Python package so hatchling includes them automati
- Use `<c-vars>` for variable declarations, NOT `<c-props>` — the rename landed in django-cotton **0.9.6**, not 2.x as this file long claimed. That is why the `[django]` extra floors at `>=2.0` (the tested series) and why `>=0.9` was a bug: it resolved 0.9.0–0.9.5, where the wrappers install and silently drop every prop (#47)
- `COTTON_DIR` (singular, string) sets the component root directory, not `COTTON_DIRS`
- Unit tests using `render_to_string` bypass the django-cotton compiler — only E2E tests exercise real Cotton compilation
- **The Django template language has no whitespace-control syntax.** Jinja's `{%- ... -%}` is a `TemplateSyntaxError` there (`Invalid block tag: '-'`), so a cotton partial that needs no stray whitespace has to keep the whole tag on one physical line
- **Django has no multi-line `{# #}` comment.** `{#` … `#}` is single-line only; open it on one line and close it on another and every line in between renders as literal text into the page. Use `{% comment %}…{% endcomment %}` for anything longer than one line
- `{% cf_ui_validate %}` (#52) is how a primitive wrapper rejects a bad prop. It returns `""`, so it must sit somewhere its output is *rendered* — inside `{% if %}` that never runs, or assigned via `as`, and the guard silently never fires
- Consumer Django projects must add `"libraries": {"cf_ui": "cf_ui.templatetags.cf_ui"}` to `TEMPLATES[0]["OPTIONS"]` — the `cf_ui.django` app name prevents templatetag autodiscovery

**Django AppConfig:**
- Register as `"cf_ui.django.CfUiConfig"` (full class path), NOT `"cf_ui.django"` — `default_app_config` is removed in Django 4.2+

**Primitives (`primitives.py`):**
- `AXIS_KINDS` says what an axis's value *becomes* — `"class"`, `"tag"` or `"attribute"` — and that one fact decides both whether the axis needs a per-theme class map and whether an empty value is benign. Django resolves a missing variable to `""` and the wrappers forward props unconditionally, so an empty class-valued axis **must** pass (unstyled is the correct outcome); an empty tag/attribute one raises, because it renders malformed markup. `CLASS_VALUED` is derived from it — never hand-maintain the two
- `ALIASES` holds props whose HTML spelling cf-ui cannot use (`for` → `for_id`). The wrapper forwards the HTML spelling into the guard *purely to reject it*, because django-cotton silently discards attributes a component does not declare. It catches declared confusions only — a plain typo is still dropped without a word
- A `test_*_calls_the_guard` test that only asserts the string `cf_ui_validate` appears is vacuous. Assert every declared axis is *passed* in the call — two guards silently lost an axis before that check existed

**djLint:**
- `prek` reformats both template trees with `single_attribute_per_line`. It is layout inside the opening tag only — never let it near a `class="…"` value, where added whitespace changes the rendered bytes and breaks substring assertions
- Because of it, no test may assume attribute ordering or adjacency. `"required>" in html` was such an assertion; anchor to the element (`<input\b[^>]*\brequired\b`) instead

**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
Expand Down
59 changes: 58 additions & 1 deletion docs/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Components

Fourteen components, identical prop names across every theme. Names are
Identical prop names across every theme. Names are
theme-agnostic — the same tag renders Bulma, Bootstrap, Foundation, Fomantic,
or DaisyUI depending on `CF_UI_THEME` / `theme=`.

Expand All @@ -18,6 +18,63 @@ Every component accepts `extra_class` for consumer CSS overrides. Form
components additionally accept `input_class` (and `CheckboxGroup`,
`control_class`) so you can style the control without restyling the wrapper.

## Primitives

Small, high-frequency elements whose `variant`/`size`/`state` values come from
closed vocabularies. See [Primitives](primitives.md) for the shared contract,
how they compose, and the disabled-link rule.

### `Cf:Button` / `<c-cf.button>`

| Prop | Default | Notes |
|---|---|---|
| `variant` | `"neutral"` | `primary` `secondary` `success` `warning` `danger` `info` `neutral` |
| `size` | `"normal"` | `small` `normal` `large` |
| `state` | `"normal"` | `normal` `loading` `disabled` |
| `href` | `""` | Non-empty renders an `<a>` instead of a `<button>` |
| `type` | `"button"` | `button` `submit` `reset`. `<button>` form only |
| `full_width` | `false` | |
| `extra_class` | `""` | |

An out-of-vocabulary value raises `PrimitiveConfigError` rather than rendering
an unstyled element.

### `Cf:Badge` / `<c-cf.badge>`

| Prop | Default | Notes |
|---|---|---|
| `variant` | `"neutral"` | |
| `size` | `"normal"` | Inert on Bootstrap and Foundation |
| `extra_class` | `""` | |

### `Cf:Heading` / `<c-cf.heading>`

| Prop | Default | Notes |
|---|---|---|
| `level` | `"2"` | `1`–`6`. Picks the tag; semantics only. Cannot be empty |
| `size` | `"normal"` | Visual size, independent of `level` |
| `emphasis` | `"normal"` | `normal` `subtle` |
| `extra_class` | `""` | |

### `Cf:Label` / `<c-cf.label>`

| Prop | Default | Notes |
|---|---|---|
| `size` | `"normal"` | No `small` step on Foundation |
| `for_id` | `""` | Becomes `for`. Spelling it `for` raises — `for` is a Python keyword |
| `required` | `false` | Renders an indicator announced as "required" |
| `extra_class` | `""` | |

### `Cf:Icon` / `<c-cf.icon>`

| Prop | Default | Notes |
|---|---|---|
| `size` | `"normal"` | Inert on Foundation |
| `label` | `""` | Empty ⇒ `aria-hidden`. Non-empty ⇒ `role="img"` + that name |
| `extra_class` | `""` | |

cf-ui ships no icons — put your own `<i>` or `<svg>` in the slot.

## Forms

### `Cf:FormField` / `<c-cf.form-field>`
Expand Down
Loading
Loading