Skip to content

feat(primitives): a primitives layer — button, badge, heading, label, icon (#52) - #53

Merged
fsecada01 merged 3 commits into
masterfrom
feat/component-framework-ui-phase-52-primitives-layer
Aug 1, 2026
Merged

feat(primitives): a primitives layer — button, badge, heading, label, icon (#52)#53
fsecada01 merged 3 commits into
masterfrom
feat/component-framework-ui-phase-52-primitives-layer

Conversation

@fsecada01

Copy link
Copy Markdown
Owner

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 button 204, label 129, title 108, table 90, icon 83 — cf-ui covered exactly one of those. Meanwhile modal, tabs, panel and breadcrumb had zero uses between them. Adoption had been stuck at one component for three months, and this is the reason.

Acceptance criteria

  • Tier 1 implemented as base + 5 cotton themes + 5 jinja themes each. All five, including icon — see Q1 below.
  • Prop contracts designed as a set before implementation, and documented. src/cf_ui/primitives.py declares the vocabularies and which primitive accepts which axis, before any template existed. docs/primitives.md documents 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.
  • Every theme variant renders idiomatic markup. Fomantic uses ui label / positive / negative, not Bulma's vocabulary translated; Foundation collapses info onto secondary because Foundation has no info button; daisyUI's heading emphasis is opacity-60, not a subtitle class 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 in docs/primitives.md.
  • Closed sets validated the same way axis token values are. PrimitiveConfigError at render, naming the values that would have worked, from a cf_ui_validate call every primitive makes. See the wrinkle below.
  • Docs page per component on the docs site. docs/primitives.md (new, in the nav) plus a ## Primitives section in docs/components.md matching the existing component pages.
  • The escaping contract is stated for every prop that can carry markup. Primitives take slot content, not markup props, so no new rule was needed — docs/escaping.md already covers slots. icon and prose remain the two deliberate Markup surfaces.
  • Tier 2 and Tier 3 tracked separately. Follow-up issues listed at the bottom.

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.py can emit its values from Python. Primitives cannot: daisyUI compiles through Tailwind, whose scanner reads source text, so btn-{{ 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: in primitives.py and 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 no else, so an unknown value matches nothing and renders unstyled. That is what cf_ui_validate is 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

Q Decision Basis
Q1 icon (a) slot-only — cf-ui supplies sizing and the accessibility contract, the caller supplies the glyph. Icon stays in Tier 1 Option (b) makes a UI kit choose its consumers' icon vendor — the same mistake axes.py already refuses for brand colour. There is no class-level abstraction spanning all five frameworks anyway
Q2 grid Out of Tier 1, tracked as Tier 3 A genuine asymmetry between the frameworks, not a thin adapter. Not decided here
Q3 button href switches the element; disabled + href renders <a role="link" aria-disabled="true"> with no href attribute An <a> without href is not focusable or activatable. Any other spelling makes "disabled" cosmetic
Q4 escaping Primitives take slot content, not markup props Extends the existing contract rather than adding a second one

Included fix, found while doing the above

Six shipped cotton partials were rendering their own source comments into the page. 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 between is emitted verbatim. bootstrap/{checkbox-group,modal,navbar,panel,progress} and daisy/navbar were 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.py guards 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:

Mutation Guard that fired
computed a class (badge-{{ variant }}) jinja literal-class parity
stray class not in the map "templates introduce no class the map does not know"
dropped a jinja cf_ui_validate call per-theme guard check
dropped a cotton wrapper's call wrapper guard check
stale cf_ui_primitives.json JSON drift test
removed a variant from the map per-theme completeness
validate() stops raising vocabulary tests
dropped emphasis=emphasis from the heading wrapper nothing — guard strengthened, now caught
dropped size=size from a jinja icon call nothing — same fix, now caught
removed the label for=for trip-wire no test existed — three added, now caught

The 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

pytest 2096 passed / 13 skipped (unit + integration) · ruff check clean · ruff format --check 77 files · node --test 65 passed · mkdocs build --strict clean · prek run --all-files clean.

Follow-ups, not in this PR

  • Tier 2: box/surface, prose/content
  • Tier 3: grid (Q2's asymmetry)
  • daisyUI CDN bug (pre-existing, unrelated to Add a primitives layer: button, icon, badge, heading, label across all five themes #52): cf_ui_head() serves daisyui@4.7.2/dist/full.min.css, which is components-only with no Tailwind utility layer — but Modal.jinja, CheckboxGroup.jinja and Navbar.jinja all depend on flex, text-lg, font-bold, gap-4, py-4, mb-1. Verified against the live CDN; needs its own issue
  • .serena/ and claudedocs/ are untracked and ungitignored

🤖 Generated with Claude Code

fsecada01 and others added 2 commits July 31, 2026 15:28
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
@fsecada01 fsecada01 added the enhancement New feature or request label Jul 31, 2026
@fsecada01

Copy link
Copy Markdown
Owner Author

/review — adversarial pass over PR #53

80 files, +2612/−31. Reviewed the full diff, then probed the behaviour of the new templates directly through both engines rather than reading only.

Verdict: three findings, one of which I'd want resolved before merge. Everything else in the PR holds up under probing.


Overview

The PR does what it says: five primitives, ten template files each (5 cotton partials + 5 JinjaX), a closed-vocabulary module, a generated JSON export, and a docs page. The central design tension — Tailwind's scanner reads source text, so classes cannot be emitted from Python — is correctly identified and correctly resolved with duplicate-and-bind rather than single-source-and-emit. The bidirectional parity test is the load-bearing piece and it works: mutating a template to compute a class was caught in both directions.

The included {# #} fix is a genuine pre-existing bug, correctly diagnosed (Django's comment regex has no DOTALL), and now guarded.


Finding 1 — level renders malformed HTML when empty or None (medium)

validate() skips empty and None values by design — Django resolves a missing context variable to "", and the for=for trip-wire depends on that skip. That is fine for every axis whose value becomes a class: an empty value drops a class and you get an unstyled-but-valid element.

level is the one axis whose value becomes structure. <h{{ level }}> with an empty or null value produces an element that does not exist:

Django,  level=""    ->  <h class="title">Hi</h>
JinjaX,  level=None  ->  <hNone class="title is-4">Hi</hNone>
JinjaX,  level=""    ->  <h class="title is-4">Hi</h>

Both verified against the real engines with the installer bound, on all five themes.

Browsers parse <h> and <hNone> as unknown inline elements. The result is not a heading: no document-outline entry, no screen-reader heading navigation, no H-key jump. It looks right, because the title class still lands.

This is reachable from ordinary usage — <c-cf.heading :level="section.depth"> with a null depth, or the JinjaX equivalent. It is precisely the silent-failure class the PR exists to eliminate, appearing in the one axis where the guard's empty-skip is not benign.

Worth noting the fix is not obviously "stop skipping empties" — the for trip-wire needs that skip, and so does every class-valued axis under Django's missing-variable semantics. The distinction the vocabulary system is missing is between an axis whose empty value is benign and one whose empty value is malformed. That is a primitives.py change (a required-axis notion), not ten template edits — consistent with this repo's "replace the mechanism, don't patch it" rule.

Finding 2 — type sits outside the vocabulary system, and its failure mode is not benign (low)

type is a free string passed straight through: type="{{ type }}". Verified: type="sumbit" renders verbatim.

Per the HTML spec, an invalid type on <button> falls back to the missing-value default, which is submit. So a typo silently converts a deliberately non-submitting button into one that submits the enclosing form. cf-ui's default is button specifically to avoid that, and a typo defeats it.

type has exactly three valid values. The PR's own thesis is that small closed sets should be closed and validated. This one was left open.

Finding 3 — the for trip-wire is a one-off, and the same hazard exists elsewhere (low)

The label wrapper traps a misspelled for and raises. Good, and now tested three ways. But no other wrapper traps a misspelled non-axis prop, and at least one has the same shape of consequence:

<c-cf.icon labl="Delete item"> renders a decorative icon — aria-hidden="true", accessible name silently dropped. That is the same failure the for trip-wire exists to prevent, one typo away, unguarded.

Either the mechanism generalises (reject attributes the wrapper does not declare) or docs/primitives.md should say plainly that for is a deliberate one-off and every other misspelling fails silently. Right now it reads as if the trip-wire is the general policy.


Note, not a finding — href accepts javascript: URIs

Confirmed: <c-cf.button href="javascript:alert(1)"> renders the scheme verbatim. Django escapes the quotes, so there is no attribute-injection path — but the URI itself is not filtered.

This is not a regression: navbar, breadcrumb, pagination and tabs have taken caller-supplied URLs on the same terms since 0.1. Flagging for the record only; closing it is a separate, package-wide decision.


What holds up

  • The parity test is real. Computing a class, adding a stray class, and dropping a mapped value were each caught. This is the piece the whole approach rests on and it is not decorative.
  • The two guards strengthened mid-PR were genuinely weak. test_*_calls_the_guard asserted only that the string cf_ui_validate appeared — deleting emphasis=emphasis from the heading wrapper survived both, and deleting size=size from a JinjaX icon call survived too. Catching that by mutation rather than by reading is the right way round, and the fix (assert every declared axis is passed) is the correct generalisation.
  • The disabled-link decision is right. Dropping href entirely and adding role="link" aria-disabled="true" is the only spelling where a disabled anchor is actually inert; role="link" is needed precisely because removing href strips the implicit role. Bootstrap's .disabled adding pointer-events: none on top is a bonus, not the mechanism.
  • The icon accessibility contract is mutually exclusive by construction — a labelled icon can never also be aria-hidden, and role="img" correctly makes it a leaf so the caller's glyph markup is not descended into.
  • Idiomatic markup per theme, not translated Bulma. Fomantic's label goes through .ui.form .field > label ancestry with size on the form because .field > label is sized in em; Foundation collapses info onto secondary because Foundation has no info button. These are framework-specific facts, correctly applied rather than approximated.
  • Inert axes are left inert rather than faked. Bootstrap's fs-* scale bottoms out larger than a default badge, so badge size is a no-op there instead of reaching for a utility that lands on the wrong value. Documented per theme.
  • The empty-value/{% else %} interaction is handled. Bulma's title/subtitle are alternatives, not additive, so base is left empty and emphasis carries the base class — with {% else %} rather than {% elif %} so an empty value cannot drop it. This also fixed a live daisyUI bug: opacity-60 had existed only in template text, invisible to the JSON export and therefore shakeable by Tailwind.
  • Existing drift guards were re-derived from themes.COMPONENTS, not re-hardcoded, with non-empty assertions so they cannot pass vacuously — and test_theme_dispatch.COMPONENT_STEMS deliberately keeps an independent literal list so the chain does not become self-referential.

Coverage and conventions

  • Every acceptance criterion has a corresponding test. Ten mutations run, all restored.
  • Line length, ruff config, docstring style, and the "why" comment density all match the surrounding code.
  • No performance concern: validation is a dict lookup per render against tuples of at most 7 items.
  • No security concern beyond the pre-existing href note. All new props render through autoescaped contexts on both engines; the new templates carry {% autoescape true %} in the required position and the drift guard covers them.
  • mkdocs build --strict clean, docs sample test passes, JSON export regenerated and pinned.

Recommendation

Finding 1 is a correctness defect introduced by this PR, in the failure class the PR is about. I'd resolve it before merge rather than as a follow-up. Findings 2 and 3 are legitimate follow-ups.

Not fixing them unilaterally: Finding 1's fix is a design decision about where the empty-value rule lives, and it interacts with the for trip-wire that Finding 3 also touches — worth deciding once rather than patching twice.

@fsecada01 fsecada01 self-assigned this Jul 31, 2026
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
@fsecada01

Copy link
Copy Markdown
Owner Author

All three findings addressed — f6a666c

All three turned out to be the same defect wearing three hats: the vocabulary system knew every axis's values but nothing about what a value turns into. So "may this be empty?", "does this need a per-theme class map?" and "why is type unguarded?" each had to be answered by hand, in three different places, and were answered inconsistently.

Rather than patch the three sites, I gave the module the missing fact.

The mechanism: AXIS_KINDS

Every axis now declares whether its value becomes a class, a tag, or an attribute:

AXIS_KINDS = {
    "variant": "class", "size": "class", "state": "class", "emphasis": "class",
    "level": "tag", "type": "attribute",
}
CLASS_VALUED = frozenset(a for a, k in AXIS_KINDS.items() if k == "class")

That single fact decides both open questions, and three tests hold the line: every axis must declare a kind, CLASS_VALUED must be derivable from it, and no theme may map a non-class axis.

Finding 1 — validate() rejected empty values that must be legal

Django resolves a missing context variable to "", and the cotton wrappers forward props unconditionally. So an empty class-valued axis has to pass — an absent variant should render an unstyled element, which is the correct outcome, not a 500.

An empty tag- or attribute-valued axis is different: an empty level has no <h?> to render, so it produces malformed markup rather than unstyled markup. That one raises, and says so:

heading needs a level — it becomes the element's tag, so an empty one renders malformed markup rather than unstyled markup. Pass one of: 1, 2, 3, 4, 5, 6

Ordering matters and is tested: the empty-skip runs before the alias check, so for="" (the overwhelmingly common case — no for_id was passed) is silently benign rather than an error naming a prop the caller never used.

Finding 2 — level carried a dead per-theme class map

All five themes mapped "1""6" to "". It was empty because a level is a tag name, not a class — which is now a declared fact rather than five coincidences. The 30 entries are deleted, and test_no_theme_maps_a_non_class_axis fails the build if any come back.

This also retires the stale comment on LEVELS warning that the tag/class distinction was "worth knowing before a second tag-valued axis is added." type was that second axis.

Finding 3 — type passed through unvalidated

type now has a vocabulary (button, submit, reset) and is passed to the guard in all six button templates. Worth stating plainly why this matters beyond tidiness: a <button> inside a <form> defaults to type="submit", so a typo'd type="buton" silently becomes a form submission. Tests cover every valid value and reject an invalid one.

Two things found while fixing the above

Two guards were weaker than they read. test_*_calls_the_guard asserted only that the string cf_ui_validate appeared somewhere in the template. I deleted emphasis=emphasis from Heading and size=size from Label; both tests stayed green. They now assert every axis the primitive declares is actually passed in the call, on both engines.

A documented behaviour had zero coverage. The forfor_id trip-wire was described in a comment and tested nowhere. ALIASES now declares the mapping, validate() raises naming the prop that works, and three tests cover the stray attribute, the correct spelling, and the absent case. docs/primitives.md states honestly what this does not catch: an arbitrary misspelling (<c-cf.icon labl="…">) is still dropped by django-cotton without a word.

Every new mechanism was mutation-proved rather than merely observed green — each guard disabled, watched fail (12 failures), restored.


Also in this push: djLint on both template trees

Separate from the findings, at the author's request. Four prek hooks pinned at djLint v1.43.1 — two per tree, because profile is one global setting and templates/cotton/** is Django template language while templates/jinja/** is Jinja2 — plus just format-templates / just lint-templates, and just check now runs the lint pass.

single_attribute_per_line = true is the whole point. A cf-ui template's contract lives entirely 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, so the safe default was "emit no whitespace" — a wrapper's prop list was a 100-character run you had to read character by character. 107 cotton and 95 jinja templates reformatted.

Safety was checked, not 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 kept away from 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 attribute layout: "required>" in html, which relied on required being the last thing before the closing bracket. It is now anchored to the element — <input\b[^>]*\brequired\b[^>]*> — which is what it always meant and is strictly stronger.

Gate

2145 passed, 13 skipped · ruff check clean · ruff format --check clean · 65 JS tests pass · mkdocs build --strict clean · prek run --all-files — all 10 hooks pass, and djLint is idempotent (a second reformat pass finds nothing).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a primitives layer: button, icon, badge, heading, label across all five themes

1 participant