From 81be1d01aedf2e10fc612c36188b066b84e648e3 Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Fri, 31 Jul 2026 15:28:29 -0400 Subject: [PATCH 1/3] feat(primitives): add the primitives layer contract and Cf:Button (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cf-ui shipped 14 structural components and no primitives, so a consuming app abstracted its rare markup and hand-wrote its common markup. This lands the contract for all of Tier 1 and the first component against it. The prop contract is settled for the whole tier at once, not per component, because primitives compose: an icon sits inside a button, a badge inside a heading. Four axes, each a closed set — variant, size, state, level — declared in cf_ui/primitives.py and documented in docs/primitives.md. The classes are deliberately duplicated between primitives.py and the templates, because they have to be. daisyUI compiles through Tailwind, whose scanner reads source text: a class assembled at render time is tree-shaken out of the build with no error and an unstyled page as the only symptom. Emitting it from Python has the same effect. So the templates spell every class out longhand and primitives.py is the reviewable source of truth, with the parity test binding them in both directions — every class in the map must appear literally in the templates, and every class in the templates must be in the map or on a short explicit list of layout utilities. That duplication is also why validate() exists. A literal {% if %} chain has no else: handed variant="purple" it matches nothing and renders a correct-looking element with no colour. Every primitive calls the guard once, so a bad value raises at the call site naming the values that would have worked. Open questions from the issue, decided: - Icons stay slot-only. cf-ui supplies sizing and alignment; the caller supplies the glyph. Adopting an icon set would make a UI kit choose its consumers' icon vendor, which is the mistake axes.py already refuses for brand colour. - href switches the element. state="disabled" with an href renders an with no href attribute plus role="link" and aria-disabled="true" — an cannot be disabled, and leaving the href on produces a control that is still focusable, still Enter-activatable, and still middle-clickable. - Primitives take no markup props at all. Everything that can carry markup arrives through the slot, under the caller's own escaping policy, so docs/escaping.md needed no new rule. - grid is left undecided and tracked as Tier 3. Four frameworks ship 12-column systems with different vocabularies and daisyUI ships none, so it is a genuine asymmetry rather than a thin adapter. Bulma and Foundation both call the base class `button`, and daisyUI mirrors Bootstrap's `btn`, so a default-props button is genuinely identical across two pairs of themes. test_theme_dispatch's distinctness check now renders a state, the same remedy it already used for the Bootstrap/daisyUI alert collision. Component counts in four existing guards now derive from themes.COMPONENTS rather than a literal 14, with the registry asserted non-empty so the derived form cannot pass over nothing. test_theme_dispatch keeps its independent literal list, which is what stops the chain circling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf --- docs/components.md | 23 +- docs/primitives.md | 208 ++++++++++ justfile | 3 + mkdocs.yml | 1 + src/cf_ui/fastapi.py | 2 + src/cf_ui/litestar.py | 2 + src/cf_ui/primitives.py | 365 ++++++++++++++++++ src/cf_ui/static/cf_ui/cf_ui_primitives.json | 174 +++++++++ .../cotton/_themes/bootstrap/button.html | 1 + .../cotton/_themes/bulma/button.html | 1 + .../cotton/_themes/daisy/button.html | 1 + .../cotton/_themes/fomantic/button.html | 1 + .../cotton/_themes/foundation/button.html | 1 + src/cf_ui/templates/cotton/cf/button.html | 2 + .../templates/jinja/bootstrap/Button.jinja | 16 + src/cf_ui/templates/jinja/bulma/Button.jinja | 16 + src/cf_ui/templates/jinja/daisy/Button.jinja | 16 + .../templates/jinja/fomantic/Button.jinja | 16 + .../templates/jinja/foundation/Button.jinja | 16 + src/cf_ui/templatetags/cf_ui.py | 20 + src/cf_ui/themes.py | 7 + tests/unit/jinja/test_fomantic.py | 8 +- tests/unit/test_autoescape.py | 14 +- tests/unit/test_extras_docs.py | 7 +- tests/unit/test_primitives.py | 349 +++++++++++++++++ tests/unit/test_tailwind_content.py | 7 +- tests/unit/test_theme_dispatch.py | 8 + 27 files changed, 1279 insertions(+), 6 deletions(-) create mode 100644 docs/primitives.md create mode 100644 src/cf_ui/primitives.py create mode 100644 src/cf_ui/static/cf_ui/cf_ui_primitives.json create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/button.html create mode 100644 src/cf_ui/templates/cotton/_themes/bulma/button.html create mode 100644 src/cf_ui/templates/cotton/_themes/daisy/button.html create mode 100644 src/cf_ui/templates/cotton/_themes/fomantic/button.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/button.html create mode 100644 src/cf_ui/templates/cotton/cf/button.html create mode 100644 src/cf_ui/templates/jinja/bootstrap/Button.jinja create mode 100644 src/cf_ui/templates/jinja/bulma/Button.jinja create mode 100644 src/cf_ui/templates/jinja/daisy/Button.jinja create mode 100644 src/cf_ui/templates/jinja/fomantic/Button.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Button.jinja create mode 100644 tests/unit/test_primitives.py diff --git a/docs/components.md b/docs/components.md index 47d69c0..069b888 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,27 @@ 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 ` - {# 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. #} + {% comment %} + 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. + {% endcomment %} diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/badge.html b/src/cf_ui/templates/cotton/_themes/fomantic/badge.html new file mode 100644 index 0000000..fc31a20 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/badge.html @@ -0,0 +1 @@ +{{ slot }} diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/heading.html b/src/cf_ui/templates/cotton/_themes/fomantic/heading.html new file mode 100644 index 0000000..650ac26 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/heading.html @@ -0,0 +1,13 @@ +{% comment %} `emphasis="subtle"` is `grey`, not Fomantic's own `sub` header, and that is + deliberate. `.ui.sub.header` sets `font-size: .85714286em` and is defined *after* every + size variation (`.ui.small.header` … `.ui.massive.header`) at identical specificity + (0,3,0), so source order hands it the cascade and the `size` axis silently stops doing + anything — an h2 carrying `ui header massive sub` renders at .857em, the same as a tiny + one. `.ui.grey.header` only sets `color`, so emphasis and size stay independent, which + is the whole point of this component. + + `size` also has no `medium` step to map `normal` onto: Fomantic ships mini, tiny, small, + large, big, huge and massive, with nothing between small and large. Emitting no size + class would fall back to the *tag's* size and re-couple size to level, so `normal` takes + `large` (1.71em) — the token reads oddly, the rendered size does not. {% endcomment %} +{{ slot }} diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/icon.html b/src/cf_ui/templates/cotton/_themes/fomantic/icon.html new file mode 100644 index 0000000..3c85ac3 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/icon.html @@ -0,0 +1 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/label.html b/src/cf_ui/templates/cotton/_themes/fomantic/label.html new file mode 100644 index 0000000..9883118 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/label.html @@ -0,0 +1,6 @@ +{% comment %}Fomantic's only label rule is `.ui.form .field > label`, so the +styling comes from the ancestry rather than a class — hence the two wrappers, +exactly as `form-field.html` carries them. Size lands on the form +(`.ui.small.form` / `.ui.large.form`) because `.field > label` is sized in +`em`.{% endcomment %} +
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/badge.html b/src/cf_ui/templates/cotton/_themes/foundation/badge.html new file mode 100644 index 0000000..1a89fab --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/badge.html @@ -0,0 +1 @@ +{{ slot }} diff --git a/src/cf_ui/templates/cotton/_themes/foundation/heading.html b/src/cf_ui/templates/cotton/_themes/foundation/heading.html new file mode 100644 index 0000000..3e73392 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/heading.html @@ -0,0 +1 @@ +{{ slot }} diff --git a/src/cf_ui/templates/cotton/_themes/foundation/icon.html b/src/cf_ui/templates/cotton/_themes/foundation/icon.html new file mode 100644 index 0000000..38d682f --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/icon.html @@ -0,0 +1 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/foundation/label.html b/src/cf_ui/templates/cotton/_themes/foundation/label.html new file mode 100644 index 0000000..afac493 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/label.html @@ -0,0 +1,5 @@ +{% comment %}No base class: Foundation styles form labels off the element, and +its `.label` class is the badge component. `lead` (125%) is the only real +font-size class outside the button scale; there is no smaller counterpart, so +`small` maps to nothing.{% endcomment %} + diff --git a/src/cf_ui/templates/cotton/cf/badge.html b/src/cf_ui/templates/cotton/cf/badge.html new file mode 100644 index 0000000..628f45f --- /dev/null +++ b/src/cf_ui/templates/cotton/cf/badge.html @@ -0,0 +1,2 @@ + +{% load cf_ui %}{% cf_ui_validate "badge" variant=variant size=size %}{% cf_ui_theme_path "badge" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/heading.html b/src/cf_ui/templates/cotton/cf/heading.html new file mode 100644 index 0000000..ba66cd1 --- /dev/null +++ b/src/cf_ui/templates/cotton/cf/heading.html @@ -0,0 +1,2 @@ + +{% load cf_ui %}{% cf_ui_validate "heading" level=level size=size emphasis=emphasis %}{% cf_ui_theme_path "heading" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/icon.html b/src/cf_ui/templates/cotton/cf/icon.html new file mode 100644 index 0000000..a17b72e --- /dev/null +++ b/src/cf_ui/templates/cotton/cf/icon.html @@ -0,0 +1,2 @@ + +{% load cf_ui %}{% cf_ui_validate "icon" size=size %}{% cf_ui_theme_path "icon" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/label.html b/src/cf_ui/templates/cotton/cf/label.html new file mode 100644 index 0000000..cc363da --- /dev/null +++ b/src/cf_ui/templates/cotton/cf/label.html @@ -0,0 +1,14 @@ + +{% comment %} +`for=for` is a trip-wire, not a prop. The attribute is named `for_id` because +`for` is a Python reserved word and JinjaX cannot express it — but +django-cotton accepts `for` happily, so would render +valid HTML with no `for` attribute and a silently broken label/control +association. Routing it through the guard turns that into a +PrimitiveConfigError. `label` declares no `for` axis, so any non-empty value +raises; an absent one resolves to "" and is skipped. + +`{% comment %}`, not `{# #}`: Django's `{# #}` is single-line only, and a +multi-line one renders every line in between as literal text into the page. +{% endcomment %} +{% load cf_ui %}{% cf_ui_validate "label" size=size for=for %}{% cf_ui_theme_path "label" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/jinja/bootstrap/Badge.jinja b/src/cf_ui/templates/jinja/bootstrap/Badge.jinja new file mode 100644 index 0000000..fe3a289 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Badge.jinja @@ -0,0 +1,9 @@ +{#def content="", variant="neutral", size="normal", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set variant = variant if variant is defined else "neutral" %} +{% set size = size if size is defined else "normal" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{- cf_ui_validate("badge", variant=variant, size=size) -}} +{{ content }} +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/bootstrap/Button.jinja b/src/cf_ui/templates/jinja/bootstrap/Button.jinja index a68d849..186fc38 100644 --- a/src/cf_ui/templates/jinja/bootstrap/Button.jinja +++ b/src/cf_ui/templates/jinja/bootstrap/Button.jinja @@ -13,4 +13,4 @@ <{{ cf_tag }} class="btn{% if variant == 'primary' %} btn-primary{% elif variant == 'secondary' %} btn-secondary{% elif variant == 'success' %} btn-success{% elif variant == 'warning' %} btn-warning{% elif variant == 'danger' %} btn-danger{% elif variant == 'info' %} btn-info{% elif variant == 'neutral' %} btn-light{% endif %}{% if size == 'small' %} btn-sm{% elif size == 'large' %} btn-lg{% endif %}{% if state == 'disabled' %} disabled{% endif %}{% if full_width %} w-100{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}" {%- if href %}{% if state == 'disabled' %} role="link" aria-disabled="true"{% else %} href="{{ href }}"{% endif %} {%- else %} type="{{ type }}"{% if state == 'disabled' %} disabled{% endif %}{% endif %}>{% if state == 'loading' %} {% endif %}{{ content }} -{% endautoescape %} \ No newline at end of file +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/bootstrap/Heading.jinja b/src/cf_ui/templates/jinja/bootstrap/Heading.jinja new file mode 100644 index 0000000..1e3843e --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Heading.jinja @@ -0,0 +1,10 @@ +{#def content="", level="2", size="normal", emphasis="normal", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set level = level if level is defined else "2" %} +{% set size = size if size is defined else "normal" %} +{% set emphasis = emphasis if emphasis is defined else "normal" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("heading", level=level, size=size, emphasis=emphasis) }} +{{ content }} +{% endautoescape %} \ No newline at end of file diff --git a/src/cf_ui/templates/jinja/bootstrap/Icon.jinja b/src/cf_ui/templates/jinja/bootstrap/Icon.jinja new file mode 100644 index 0000000..d64fe54 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Icon.jinja @@ -0,0 +1,10 @@ +{#def content="", size="normal", label="", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set size = size if size is defined else "normal" %} +{% set label = label if label is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("icon", size=size) }} + +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/bootstrap/Label.jinja b/src/cf_ui/templates/jinja/bootstrap/Label.jinja new file mode 100644 index 0000000..d90b60a --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Label.jinja @@ -0,0 +1,10 @@ +{#def content="", size="normal", for_id="", required=false, extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set size = size if size is defined else "normal" %} +{% set for_id = for_id if for_id is defined else "" %} +{% set required = required if required is defined else false %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("label", size=size) }} + +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/bulma/Badge.jinja b/src/cf_ui/templates/jinja/bulma/Badge.jinja new file mode 100644 index 0000000..a6a8309 --- /dev/null +++ b/src/cf_ui/templates/jinja/bulma/Badge.jinja @@ -0,0 +1,9 @@ +{#def content="", variant="neutral", size="normal", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set variant = variant if variant is defined else "neutral" %} +{% set size = size if size is defined else "normal" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{- cf_ui_validate("badge", variant=variant, size=size) -}} +{{ content }} +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/bulma/Button.jinja b/src/cf_ui/templates/jinja/bulma/Button.jinja index 9f5b716..c349d86 100644 --- a/src/cf_ui/templates/jinja/bulma/Button.jinja +++ b/src/cf_ui/templates/jinja/bulma/Button.jinja @@ -13,4 +13,4 @@ <{{ cf_tag }} class="button{% if variant == 'primary' %} is-primary{% elif variant == 'secondary' %} is-link{% elif variant == 'success' %} is-success{% elif variant == 'warning' %} is-warning{% elif variant == 'danger' %} is-danger{% elif variant == 'info' %} is-info{% endif %}{% if size == 'small' %} is-small{% elif size == 'large' %} is-large{% endif %}{% if state == 'loading' %} is-loading{% endif %}{% if full_width %} is-fullwidth{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}" {%- if href %}{% if state == 'disabled' %} role="link" aria-disabled="true"{% else %} href="{{ href }}"{% endif %} {%- else %} type="{{ type }}"{% if state == 'disabled' %} disabled{% endif %}{% endif %}>{{ content }} -{% endautoescape %} \ No newline at end of file +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/bulma/Heading.jinja b/src/cf_ui/templates/jinja/bulma/Heading.jinja new file mode 100644 index 0000000..64c5b35 --- /dev/null +++ b/src/cf_ui/templates/jinja/bulma/Heading.jinja @@ -0,0 +1,17 @@ +{#def content="", level="2", size="normal", emphasis="normal", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set level = level if level is defined else "2" %} +{% set size = size if size is defined else "normal" %} +{% set emphasis = emphasis if emphasis is defined else "normal" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("heading", level=level, size=size, emphasis=emphasis) }} +{# `title` and `subtitle` are Bulma alternatives, not additive — `.subtitle` is + defined after `.title` at equal specificity, so an element carrying both + renders as a subtitle with the title rules dead underneath. The emphasis + axis therefore carries the base class, and this branch is an `else` rather + than an `elif` so exactly one of the two is always emitted: an `emphasis` + that reached here empty would otherwise produce a heading with no Bulma + class at all, which is a broken render rather than a missing modifier. #} +{{ content }} +{% endautoescape %} \ No newline at end of file diff --git a/src/cf_ui/templates/jinja/bulma/Icon.jinja b/src/cf_ui/templates/jinja/bulma/Icon.jinja new file mode 100644 index 0000000..f34405d --- /dev/null +++ b/src/cf_ui/templates/jinja/bulma/Icon.jinja @@ -0,0 +1,10 @@ +{#def content="", size="normal", label="", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set size = size if size is defined else "normal" %} +{% set label = label if label is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("icon", size=size) }} + +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/bulma/Label.jinja b/src/cf_ui/templates/jinja/bulma/Label.jinja new file mode 100644 index 0000000..3504ec2 --- /dev/null +++ b/src/cf_ui/templates/jinja/bulma/Label.jinja @@ -0,0 +1,10 @@ +{#def content="", size="normal", for_id="", required=false, extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set size = size if size is defined else "normal" %} +{% set for_id = for_id if for_id is defined else "" %} +{% set required = required if required is defined else false %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("label", size=size) }} + +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/daisy/Badge.jinja b/src/cf_ui/templates/jinja/daisy/Badge.jinja new file mode 100644 index 0000000..dc4684d --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Badge.jinja @@ -0,0 +1,9 @@ +{#def content="", variant="neutral", size="normal", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set variant = variant if variant is defined else "neutral" %} +{% set size = size if size is defined else "normal" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{- cf_ui_validate("badge", variant=variant, size=size) -}} +{{ content }} +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/daisy/Button.jinja b/src/cf_ui/templates/jinja/daisy/Button.jinja index 5ec7633..52fbecd 100644 --- a/src/cf_ui/templates/jinja/daisy/Button.jinja +++ b/src/cf_ui/templates/jinja/daisy/Button.jinja @@ -13,4 +13,4 @@ <{{ cf_tag }} class="btn{% if variant == 'primary' %} btn-primary{% elif variant == 'secondary' %} btn-secondary{% elif variant == 'success' %} btn-success{% elif variant == 'warning' %} btn-warning{% elif variant == 'danger' %} btn-error{% elif variant == 'info' %} btn-info{% elif variant == 'neutral' %} btn-neutral{% endif %}{% if size == 'small' %} btn-sm{% elif size == 'large' %} btn-lg{% endif %}{% if state == 'disabled' %} btn-disabled{% endif %}{% if full_width %} btn-block{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}" {%- if href %}{% if state == 'disabled' %} role="link" aria-disabled="true"{% else %} href="{{ href }}"{% endif %} {%- else %} type="{{ type }}"{% if state == 'disabled' %} disabled{% endif %}{% endif %}>{% if state == 'loading' %} {% endif %}{{ content }} -{% endautoescape %} \ No newline at end of file +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/daisy/Heading.jinja b/src/cf_ui/templates/jinja/daisy/Heading.jinja new file mode 100644 index 0000000..43e8ee2 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Heading.jinja @@ -0,0 +1,15 @@ +{#def content="", level="2", size="normal", emphasis="normal", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set level = level if level is defined else "2" %} +{% set size = size if size is defined else "normal" %} +{% set emphasis = emphasis if emphasis is defined else "normal" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("heading", level=level, size=size, emphasis=emphasis) }} +{# `font-bold` is the base rather than a flourish: Tailwind's Preflight resets + `font-size` and `font-weight` on h1–h6, so an unclassed heading here renders + as body text. daisyUI ships no heading component, so the type scale is + Tailwind's own — these utilities are absent from the daisyUI CDN bundle and + only exist in a real Tailwind build. #} +{{ content }} +{% endautoescape %} \ No newline at end of file diff --git a/src/cf_ui/templates/jinja/daisy/Icon.jinja b/src/cf_ui/templates/jinja/daisy/Icon.jinja new file mode 100644 index 0000000..16a06e7 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Icon.jinja @@ -0,0 +1,10 @@ +{#def content="", size="normal", label="", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set size = size if size is defined else "normal" %} +{% set label = label if label is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("icon", size=size) }} + +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/daisy/Label.jinja b/src/cf_ui/templates/jinja/daisy/Label.jinja new file mode 100644 index 0000000..6218afb --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Label.jinja @@ -0,0 +1,18 @@ +{#def content="", size="normal", for_id="", required=false, extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set size = size if size is defined else "normal" %} +{% set for_id = for_id if for_id is defined else "" %} +{% set required = required if required is defined else false %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("label", size=size) }} +{# `.label` is the flex row; `.label-text` is what actually carries the font + size and colour — the same pair `FormField.jinja` renders. Size has to go on + the inner span because `.label-text` sets `font-size` itself, so a utility + on the outer `
`. + + Size therefore lands on the form, not the label: `.ui.small.form` and + `.ui.large.form` set the root font-size and `.field > label` is sized in + `em`, so it scales with them. #} +
+{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/foundation/Badge.jinja b/src/cf_ui/templates/jinja/foundation/Badge.jinja new file mode 100644 index 0000000..e855e6a --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Badge.jinja @@ -0,0 +1,9 @@ +{#def content="", variant="neutral", size="normal", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set variant = variant if variant is defined else "neutral" %} +{% set size = size if size is defined else "normal" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{- cf_ui_validate("badge", variant=variant, size=size) -}} +{{ content }} +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/foundation/Button.jinja b/src/cf_ui/templates/jinja/foundation/Button.jinja index facda35..f9a466e 100644 --- a/src/cf_ui/templates/jinja/foundation/Button.jinja +++ b/src/cf_ui/templates/jinja/foundation/Button.jinja @@ -13,4 +13,4 @@ <{{ cf_tag }} class="button{% if variant == 'primary' %} primary{% elif variant == 'secondary' %} secondary{% elif variant == 'success' %} success{% elif variant == 'warning' %} warning{% elif variant == 'danger' %} alert{% elif variant == 'info' %} secondary{% endif %}{% if size == 'small' %} small{% elif size == 'large' %} large{% endif %}{% if state == 'disabled' %} disabled{% endif %}{% if full_width %} expanded{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}" {%- if href %}{% if state == 'disabled' %} role="link" aria-disabled="true"{% else %} href="{{ href }}"{% endif %} {%- else %} type="{{ type }}"{% if state == 'disabled' %} disabled{% endif %}{% endif %}>{{ content }} -{% endautoescape %} \ No newline at end of file +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/foundation/Heading.jinja b/src/cf_ui/templates/jinja/foundation/Heading.jinja new file mode 100644 index 0000000..d0cd1af --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Heading.jinja @@ -0,0 +1,10 @@ +{#def content="", level="2", size="normal", emphasis="normal", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set level = level if level is defined else "2" %} +{% set size = size if size is defined else "normal" %} +{% set emphasis = emphasis if emphasis is defined else "normal" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("heading", level=level, size=size, emphasis=emphasis) }} +{{ content }} +{% endautoescape %} \ No newline at end of file diff --git a/src/cf_ui/templates/jinja/foundation/Icon.jinja b/src/cf_ui/templates/jinja/foundation/Icon.jinja new file mode 100644 index 0000000..3e7752a --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Icon.jinja @@ -0,0 +1,10 @@ +{#def content="", size="normal", label="", extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set size = size if size is defined else "normal" %} +{% set label = label if label is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("icon", size=size) }} + +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/foundation/Label.jinja b/src/cf_ui/templates/jinja/foundation/Label.jinja new file mode 100644 index 0000000..f788a0d --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Label.jinja @@ -0,0 +1,18 @@ +{#def content="", size="normal", for_id="", required=false, extra_class="" #} +{% autoescape true %} +{% set content = content if content is defined else "" %} +{% set size = size if size is defined else "normal" %} +{% set for_id = for_id if for_id is defined else "" %} +{% set required = required if required is defined else false %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{{ cf_ui_validate("label", size=size) }} +{# No base class on purpose. Foundation styles form labels off the element + (`label { font-size: .875rem; font-weight: 400 }`), and its `.label` class is + the *badge* component — a blue pill with padding and a white foreground. + Applying it here would turn every field label into a tag. + + `lead` is the only real font-size class Foundation ships outside the button + scale, and at 125% it lands on 1.25rem — the same size `.button.large` uses. + There is no counterpart below the default, so `small` maps to nothing. #} + +{% endautoescape %} diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py index 17f8fed..22ebec9 100644 --- a/src/cf_ui/themes.py +++ b/src/cf_ui/themes.py @@ -38,11 +38,15 @@ #: render. ``tests/unit/test_primitives.py`` holds this tuple against the #: primitives that actually ship. COMPONENTS = ( + "badge", "breadcrumb", "button", "card", "checkbox-group", "form-field", + "heading", + "icon", + "label", "modal", "navbar", "notification", diff --git a/tests/unit/cotton/test_comment_syntax.py b/tests/unit/cotton/test_comment_syntax.py new file mode 100644 index 0000000..4c9cc9f --- /dev/null +++ b/tests/unit/cotton/test_comment_syntax.py @@ -0,0 +1,78 @@ +"""A multi-line ``{# #}`` renders into the page, so no cotton template has one. + +Django's comment regex is ``\\{#.*?#\\}`` **without** ``DOTALL``: ``{# #}`` is a +single-line construct. Open one on one line and close it on another and the +lexer never forms a comment token — every line in between is emitted as +literal text, straight into the consumer's rendered HTML. There is no error, +no warning, and no test failure unless something looks for it, which is what +this file does. + +Six shipped bootstrap and daisy partials carried the defect before #52 and +leaked their rationale comments — several paragraphs of prose about z-index +stacking and Tailwind layer ordering — into every page that rendered them. +``{% comment %}…{% endcomment %}`` is the multi-line form and is what every +cotton template uses now. + +This is a *cotton-only* rule. The Jinja templates in ``templates/jinja/`` run +under Jinja2, whose ``{# #}`` spans lines perfectly well. +""" + +import re +from pathlib import Path + +import pytest + +COTTON_DIR = Path(__file__).parent.parent.parent.parent / "src" / "cf_ui" / "templates" / "cotton" + +#: Django's own comment pattern, minus ``DOTALL`` — deliberately mirroring the +#: engine rather than approximating it. A match that spans a newline is +#: therefore impossible; what this finds is a ``{#`` whose ``#}`` never arrives +#: on the same line, which is precisely the broken shape. +SINGLE_LINE_COMMENT = re.compile(r"\{#.*?#\}") + +COTTON_TEMPLATES = sorted(COTTON_DIR.rglob("*.html")) + + +def _unclosed_comment_lines(source: str) -> list[tuple[int, str]]: + """Lines opening a ``{#`` that no ``#}`` closes before the newline.""" + offenders = [] + for number, line in enumerate(source.splitlines(), start=1): + stripped = SINGLE_LINE_COMMENT.sub("", line) + if "{#" in stripped: + offenders.append((number, line.strip())) + return offenders + + +def test_the_template_tree_is_not_empty(): + """Stops the parametrised guard below from passing on an empty collection.""" + assert len(COTTON_TEMPLATES) > 50 + + +@pytest.mark.parametrize("template", COTTON_TEMPLATES, ids=lambda p: f"{p.parent.name}/{p.name}") +def test_no_cotton_template_opens_a_multi_line_comment(template: Path): + offenders = _unclosed_comment_lines(template.read_text(encoding="utf-8")) + assert not offenders, ( + f"{template.name} opens a `{{#` that does not close on the same line — " + f"Django will render the following lines as literal page text. Use " + f"`{{% comment %}}`…`{{% endcomment %}}`. Lines: {offenders}" + ) + + +# ── The guard's own behaviour, pinned ───────────────────────────────────── + + +@pytest.mark.parametrize( + ("source", "expected_count"), + [ + ("{# fine #}\n
", 0), + ("
{# also fine #}
", 0), + ("{# broken\n across lines #}", 1), + (" {# indented\n and broken #}", 1), + ("{# ok #} {# broken\n#}", 1), + ("{% comment %}\nmany\nlines\n{% endcomment %}", 0), + ("
no comments at all
", 0), + ], + ids=lambda v: str(v)[:32], +) +def test_the_reader_flags_what_it_claims_to(source: str, expected_count: int): + assert len(_unclosed_comment_lines(source)) == expected_count diff --git a/tests/unit/test_primitives.py b/tests/unit/test_primitives.py index 284098b..72f8179 100644 --- a/tests/unit/test_primitives.py +++ b/tests/unit/test_primitives.py @@ -27,6 +27,7 @@ from cf_ui.primitives import ( CLASSES, + EMPHASIS, LEVELS, PRIMITIVES, SIZES, @@ -46,7 +47,7 @@ #: Components whose per-theme class maps ship in this phase. The prop contract #: is settled for all of Tier 1 (see ``docs/primitives.md``); the class maps #: land with each component's templates. -IMPLEMENTED = ("button",) +IMPLEMENTED = ("badge", "button", "heading", "icon", "label") # ── The vocabularies are closed ─────────────────────────────────────────── @@ -58,6 +59,7 @@ def test_the_vocabularies_are_registered_under_their_axis_names(): "size": SIZES, "state": STATES, "level": LEVELS, + "emphasis": EMPHASIS, } @@ -243,7 +245,7 @@ def test_the_templates_introduce_no_class_the_map_does_not_know(theme: str, comp Layout and utility classes that carry no axis meaning are allowed through by name, so the exemption is itself reviewable. """ - allowed = _mapped_classes(theme, component) | ALLOWED_UTILITY_CLASSES + allowed = _mapped_classes(theme, component) | ALLOWED_UTILITY_CLASSES[component] for path in ( JINJA_DIR / theme / f"{component.title()}.jinja", COTTON_THEME_DIR / theme / f"{component}.html", @@ -257,21 +259,76 @@ def test_the_templates_introduce_no_class_the_map_does_not_know(theme: str, comp #: Classes that appear in a primitive template without belonging to an axis. -#: Small and explicit on purpose: an open-ended allowance would turn the test +#: +#: Keyed **per component** rather than shared. ``label`` needs to wave through +#: tokens as generic as ``ui``, ``text``, ``form`` and ``field`` — Fomantic +#: styles labels by ancestry, so the partial renders the required +#: ``.ui.form > .field`` wrappers. Allowing those package-wide would gut the +#: check for every other primitive, which is the one thing this list must not +#: do. Small and explicit on purpose: an open-ended allowance turns the test #: above into a no-op. -ALLOWED_UTILITY_CLASSES = { - "is-fullwidth", # bulma - "w-100", # bootstrap - "expanded", # foundation - "fluid", # fomantic - "btn-block", # daisy - "loading", # daisy spinner element - "loading-spinner", - "spinner-border", # bootstrap spinner element - "spinner-border-sm", +ALLOWED_UTILITY_CLASSES: dict[str, set[str]] = { + "button": { + "is-fullwidth", # bulma + "w-100", # bootstrap + "expanded", # foundation + "fluid", # fomantic + "btn-block", # daisy + "loading", # daisy spinner element + "loading-spinner", + "spinner-border", # bootstrap spinner element + "spinner-border-sm", + }, + "badge": set(), + "heading": set(), + "icon": set(), + "label": { + # The required indicator's colour, per theme. Not an axis: `label` + # takes no variant, and this is the only coloured thing it renders. + "has-text-danger", # bulma + "text-danger", # bootstrap + "text-error", # daisy + "red", # fomantic, as span.ui.red.text + # Structural wrappers Fomantic requires: its only label rule is + # `.ui.form .field > label`, so the ancestry has to be rendered. + "ui", + "text", + "form", + "field", + # daisy puts the size class on this inner span, not the outer label. + "label-text", + }, } +def _guard_call(source: str, component: str) -> str: + """The text of the ``cf_ui_validate`` call for ``component``. + + Both spellings are one call on one line — Jinja's + ``{{ cf_ui_validate("button", variant=variant, ...) }}`` and Django's + ``{% cf_ui_validate "button" variant=variant ... %}`` — so the enclosing + delimiter is enough to bound it. Jinja's whitespace-control dashes are + optional and some templates carry them. + """ + match = re.search(r"\{[{%]-?\s*cf_ui_validate\b.*?-?[}%]\}", source, flags=re.S) + assert match, f"no cf_ui_validate call found for {component}" + return match.group(0) + + +def _assert_guard_covers_every_axis(source: str, component: str, where: str) -> None: + """Calling the guard is not enough — it has to be *passed* every axis. + + A guard invoked as ``cf_ui_validate("heading", level=level, size=size)`` + passes cleanly on ``emphasis="loud"``, which then matches no branch of the + literal chain and renders unstyled. Presence of the call name is what the + earlier version of this check tested, and dropping ``emphasis=emphasis`` + survived it. + """ + call = _guard_call(source, component) + for axis in PRIMITIVES[component]: + assert f"{axis}=" in call, f"{where} calls the guard without passing {axis!r}: {call}" + + @pytest.mark.parametrize("theme", THEMES) @pytest.mark.parametrize("component", IMPLEMENTED) def test_every_jinja_primitive_calls_the_guard(theme: str, component: str): @@ -282,6 +339,7 @@ def test_every_jinja_primitive_calls_the_guard(theme: str, component: str): """ jinja = (JINJA_DIR / theme / f"{component.title()}.jinja").read_text(encoding="utf-8") assert "cf_ui_validate" in jinja, f"jinja/{theme}/{component} does not call the guard" + _assert_guard_covers_every_axis(jinja, component, f"jinja/{theme}/{component}") @pytest.mark.parametrize("component", IMPLEMENTED) @@ -293,7 +351,49 @@ def test_the_cotton_wrapper_calls_the_guard(component: str): rather than being copied into five partials that could each drop it. """ wrapper = REPO_ROOT / "src" / "cf_ui" / "templates" / "cotton" / "cf" / f"{component}.html" - assert "cf_ui_validate" in wrapper.read_text(encoding="utf-8") + source = wrapper.read_text(encoding="utf-8") + assert "cf_ui_validate" in source + _assert_guard_covers_every_axis(source, component, f"cotton/cf/{component}") + + +# ── `for` is not a prop, and saying it is must not fail silently ────────── + + +def test_a_stray_for_attribute_on_the_cotton_label_raises(): + """```` is the mistake this trip-wire exists for. + + The attribute is ``for_id``, because ``for`` is a Python reserved word and + JinjaX cannot express it. django-cotton *can*, so the wrong spelling is + accepted by the compiler and produces a ``
` instead of a `