diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d572d9d..28033c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb5d94..cf39163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** + `` / ``, 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 + `` 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 + `` 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.** + `` 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 `` 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 — `` + 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 `` 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 diff --git a/CLAUDE.md b/CLAUDE.md index cda90b9..2851c8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -73,11 +75,23 @@ Templates live **inside** the Python package so hatchling includes them automati - Use `` for variable declarations, NOT `` — 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 (`]*\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 diff --git a/docs/components.md b/docs/components.md index 47d69c0..29fbf49 100644 --- a/docs/components.md +++ b/docs/components.md @@ -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=`. @@ -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` / `` + +| 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 `` instead of a `