feat(primitives): a primitives layer — button, badge, heading, label, icon (#52) - #53
Conversation
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 <a>
with no href attribute plus role="link" and aria-disabled="true" — an <a>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
) Completes Tier 1. `button` landed in the previous commit with the contract; this adds the remaining four primitives, each as a cotton wrapper plus five theme partials and five JinjaX templates, and closes acceptance criterion 1. `primitives.py` gains a fifth axis, `emphasis` (`normal` / `subtle`). It replaces what started as a `subtitle` boolean on `heading`: Bulma's `title` and `subtitle` are mutually exclusive, so the boolean forced the base class to carry a value the map could not express, and `classes_for` returned a string that was wrong for that theme. Promoting it to an axis also fixed a real daisyUI defect — `opacity-60` existed only in template text, invisible to `cf_ui_primitives.json`, so a Tailwind build would have shaken it out. Two guards were weaker than they read, both found by mutation: * `test_{every_jinja_primitive,the_cotton_wrapper}_calls_the_guard` checked only that the string `cf_ui_validate` appeared. Deleting `emphasis=emphasis` from the heading wrapper survived both. They now assert every axis the component declares is actually passed to the call. * The `for=for` trip-wire on the label wrapper had no test at all. `for` is a Python reserved word so the prop is `for_id`, but django-cotton accepts `for` happily and would render a `<label>` with no `for` attribute — valid HTML, broken control association, silent. Three tests now pin it: the wrong spelling raises, the right one renders, and an absent one does not trip. Also fixes a pre-existing bug the primitives work surfaced: Django's comment regex has no DOTALL, so a `{# #}` opened on one line and closed on another never forms a comment token and renders as page text. Six shipped partials were leaking paragraphs of rationale prose into consumers' HTML. All six now use `{% comment %}`; `tests/unit/cotton/test_comment_syntax.py` guards it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
|
Addresses the three findings from the review on PR #53. All three orbited one gap: the vocabulary system knew every axis's *values* but nothing about what a value turns into, so the empty-value rule, the class-map rule and the missing `type` guard each had to be decided by hand. `AXIS_KINDS` closes it. An axis's value becomes a `class`, a `tag` or an `attribute`, and that single 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 cotton wrappers forward props unconditionally, so an empty class-valued axis must pass — unstyled is the right outcome for a missing `variant`. An empty tag- or attribute-valued one raises, because it renders malformed markup, and the message names the kind and the values that would work. `CLASS_VALUED` is derived from `AXIS_KINDS`; no theme may map a non-class axis; both are tested. The five dead `"level": {"1": "", ...}` class-map entries are gone. `type` (button/submit/reset) becomes a real axis with a vocabulary and a guard — it was the second tag/attribute-valued axis the old `LEVELS` comment warned would arrive. `ALIASES` declares props whose HTML spelling cf-ui cannot use. `for` is a Python reserved word, so the label's prop is `for_id`, and django-cotton silently discards undeclared attributes — `<c-cf.label for="email">` rendered valid HTML with no `for` at all. The wrapper now forwards the HTML spelling into the guard purely so it can be rejected by name. Two guards were weaker than they read: `test_*_calls_the_guard` asserted only that the string `cf_ui_validate` appeared, and deleting `emphasis=emphasis` or `size=size` from the call left it green. They now assert every declared axis is passed. Every new mechanism here was mutation-proved: disabled, watched fail (12 failures), restored. Also adds djLint to prek and the justfile with `single_attribute_per_line = true`, reformatting 107 cotton and 95 jinja templates. A cf-ui template's whole contract lives in its opening tag, and they were authored on one line because Django has no whitespace-control syntax. Verified rather than assumed: all 190 primitive renders (5 themes x 19 components x 2 engines) are byte-identical with whitespace stripped, and the parsed DOM matches except for a space added after `;` in inline styles. It is not allowed near `class="..."` values, where added whitespace changes the rendered bytes. One assertion did depend on attribute layout (`"required>" in html`) and is now anchored to the `<input>` element instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
All three findings addressed —
|
Closes #52.
Adds a primitives layer —
button,badge,heading,label,icon— as base contract + 5 cotton theme partials + 5 JinjaX templates each. 55 new template files, one new module, one new docs page.Why this, and why now
cf-ui 0.2.0 shipped 14 structural components and zero primitives. Measured against a real consumer (RankedJobs, 108 templates, ~86 using CSS-framework classes), the most-used classes were
button204,label129,title108,table90,icon83 — cf-ui covered exactly one of those. Meanwhilemodal,tabs,panelandbreadcrumbhad zero uses between them. Adoption had been stuck at one component for three months, and this is the reason.Acceptance criteria
icon— see Q1 below.src/cf_ui/primitives.pydeclares the vocabularies and which primitive accepts which axis, before any template existed.docs/primitives.mddocuments the set, and composition is slot-based: an icon goes inside a button's slot, a badge inside a heading's — there are no markup props to compose through.ui label/positive/negative, not Bulma's vocabulary translated; Foundation collapsesinfoontosecondarybecause Foundation has no info button; daisyUI's heading emphasis isopacity-60, not asubtitleclass it does not have. Where a framework genuinely has no equivalent, the axis is inert rather than approximated with a utility that reaches the wrong value — documented per theme indocs/primitives.md.PrimitiveConfigErrorat render, naming the values that would have worked, from acf_ui_validatecall every primitive makes. See the wrinkle below.docs/primitives.md(new, in the nav) plus a## Primitivessection indocs/components.mdmatching the existing component pages.docs/escaping.mdalready covers slots.iconandproseremain the two deliberateMarkupsurfaces.The one place criterion 4 could not be met literally
The issue says "validated the same way axis token values are in
axes.py".axes.pycan emit its values from Python. Primitives cannot: daisyUI compiles through Tailwind, whose scanner reads source text, sobtn-{{ variant }}— or a class returned from a Python helper — is tree-shaken out of the build with no error and an unstyled element as the only symptom.So the classes are spelled out longhand in
{% if %}chains, and the same knowledge lives twice: inprimitives.pyand in 50 template files. What holds them together is a bidirectional parity test — every mapped class must appear literally in the template, and every literal class in the template must be one the map knows.classes_for()exists for tests, docs and consumers rendering outside the shipped templates; nothing in the templates calls it, deliberately.A literal
{% if %}chain has noelse, so an unknown value matches nothing and renders unstyled. That is whatcf_ui_validateis for: every primitive calls it once, in a position that renders its empty-string result, so a bad prop raises at the call site.The four open questions in the issue
axes.pyalready refuses for brand colour. There is no class-level abstraction spanning all five frameworks anywayhrefswitches the element;disabled+hrefrenders<a role="link" aria-disabled="true">with nohrefattribute<a>withouthrefis not focusable or activatable. Any other spelling makes "disabled" cosmeticIncluded fix, found while doing the above
Six shipped cotton partials were rendering their own source comments into the page. Django's comment regex is
\{#.*?#\}withoutDOTALL—{# #}is single-line only, so a comment opened on one line and closed on another never forms a comment token and every line between is emitted verbatim.bootstrap/{checkbox-group,modal,navbar,panel,progress}anddaisy/navbarwere each leaking a paragraph of rationale prose about z-index stacking or Tailwind layer ordering straight into consumers' HTML.I hit it by writing a primitives wrapper with the same mistake. All six now use
{% comment %},tests/unit/cotton/test_comment_syntax.pyguards against recurrence, and the rule is in CLAUDE.md's gotchas. Six one-comment edits with a test — small enough that shipping a documented-but-unenforced rule seemed worse than the scope drift.Guards proven non-vacuous
Every new guard was run against its exact defect. Ten mutations, all caught, all restored:
badge-{{ variant }})cf_ui_validatecallcf_ui_primitives.jsonvalidate()stops raisingemphasis=emphasisfrom the heading wrappersize=sizefrom a jinja icon callfor=fortrip-wireThe last three are the interesting ones: two guards read as if they checked the call and only checked that the call's name was present, and one documented behaviour had no test at all.
Verification
pytest2096 passed / 13 skipped (unit + integration) ·ruff checkclean ·ruff format --check77 files ·node --test65 passed ·mkdocs build --strictclean ·prek run --all-filesclean.Follow-ups, not in this PR
box/surface,prose/contentgrid(Q2's asymmetry)cf_ui_head()servesdaisyui@4.7.2/dist/full.min.css, which is components-only with no Tailwind utility layer — butModal.jinja,CheckboxGroup.jinjaandNavbar.jinjaall depend onflex,text-lg,font-bold,gap-4,py-4,mb-1. Verified against the live CDN; needs its own issue.serena/andclaudedocs/are untracked and ungitignored🤖 Generated with Claude Code