Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ uv.lock

# mkdocs build output
site/

# Agent worktrees, created by isolated subagent runs.
.claude/worktrees/
66 changes: 65 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,71 @@

## [Unreleased]

### Fixed
### Added — primitives Tier 2: `box` and `prose` (#54)

- **Two container primitives, on all five themes, in both template sets.**
`<Cf:Box>` / `<c-cf.box>` is a plain bordered container — one element, no
imposed header/body/footer, which is what separates it from `card`.
`<Cf:Prose>` / `<c-cf.prose>` is a typographic reset for a block of
server-rendered or user-supplied markup. `box` takes `variant`; `prose`
takes `size`. A container's size is its content's business, and a
typographic reset has no colour, so neither takes more.

- **Named `box` and `prose`, not `surface` and `content`.** `box` is Bulma's
own class name; `surface` is Material vocabulary no shipped framework uses.
`content` was rejected twice over: it collides with Bulma's own `.content`
class, and `content` is already the prop every Jinja primitive uses for slot
text, so `<Cf:Content content="…">` would have been the spelling.

- **Fomantic's `primary` and `secondary` are not colours on a segment.** They
are its *emphasis* variation — `.ui.primary.segment` renders a subdued
treatment, not a brand fill. The variant maps to real hues (`blue`, `grey`,
`green`, `yellow`, `red`, `teal`) instead, because the one theme where the
vocabulary appears to match by name is the one theme where matching it would
be wrong. Verified against Fomantic's own SCSS rather than assumed.

- **`prose` is honest about where it does nothing.** On Bootstrap and
Foundation it emits no class at all, and that is correct rather than
missing: both style bare `h1`-`h6`, `p` and `ul` globally, so the reset this
component exists to scope is already in effect. Fomantic is different and
worse — it styles headings and paragraphs globally but ships **no** bare
`ul`, `ol` or `table` rule, so lists and tables inside a prose block fall
back to browser defaults and no scoping class fixes it. Documented in
`docs/primitives.md` with the workaround that does work, rather than papered
over with a cf-ui-authored type scale.

- **On daisy, `prose` requires `@tailwindcss/typography`** — the class is from
that plugin, not from daisyUI and not from Tailwind core. Declared as a
requirement in `docs/daisyui.md`. Without it the block renders unstyled,
which is the benign class-valued failure mode rather than broken markup.

- **`docs/escaping.md` now states the sanitization contract outright.**
`prose` is the first primitive whose purpose is wrapping caller-supplied
markup. Mechanically nothing changed — it is still slot-based and cf-ui's
own output is still escaped — but the thing that had never been written down
is that **cf-ui does not sanitize**. A caller reaching for `Markup` /
`mark_safe` has taken that on, and `nh3` or `bleach` is what does the job.

### Fixed — two test guards that could not fail

- **The theme-dispatch test was passing on whitespace.** It compared raw
rendered strings across the five themes and required all five to differ. A
`{% comment %}` block leaves its own blank lines behind, so two partials
emitting byte-identical markup still landed in different buckets on newline
count alone. Proven by mutation: gutting a partial's entire class chain left
the test green. It now compares collapsed markup, and allows two themes to
coincide only when their entry in `CLASSES` proves they agree — so `prose`
rendering alike on three themes passes for a stated reason, while a partial
that silently drops an axis still fails.

- **`IMPLEMENTED` was a hand-written tuple**, which made adding a primitive a
silent-coverage trap: register it in `PRIMITIVES` and `themes.COMPONENTS`,
forget this one line, and its templates shipped with zero parity and guard
coverage without anything failing. It is now derived from the intersection
of the two, so the failure mode inverts — a primitive registered without
templates fails loudly instead of quietly not being checked.

### Fixed — daisyUI's CDN recipe was missing its utility layer (#56)

- **`cf_ui_head(theme="daisy")` shipped half of daisyUI's own documented CDN
recipe, and that half silently drops every layout utility (#56).** DaisyUI
Expand Down
36 changes: 36 additions & 0 deletions docs/daisyui.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,42 @@ still works; DaisyUI has no `alert-danger`, so the templates map it onto
`alert-error` internally. A test scans every DaisyUI template for class tokens
split across a template construct and fails on any it finds.

## `Cf:Prose` needs `@tailwindcss/typography`

[`Cf:Prose` / `<c-cf.prose>`](primitives.md#cfprose-c-cfprose) maps to the
`prose` class on daisy — and `prose` is not a daisyUI class, and not part of
Tailwind core. It comes from
[`@tailwindcss/typography`](https://github.com/tailwindlabs/tailwindcss-typography),
a separate official plugin, and it is required for this one component on
this one theme only. No other cf-ui component under any theme needs it.

Add the plugin alongside daisyUI:

```bash
npm i -D @tailwindcss/typography
```

```js
// tailwind.config.js (Tailwind v3)
module.exports = {
plugins: [require("@tailwindcss/typography"), require("daisyui")],
};
```

```css
/* app.css (Tailwind v4) */
@import "tailwindcss";
@plugin "daisyui";
@plugin "@tailwindcss/typography";
```

Without it, `prose` (and the `prose-sm`/`prose-lg` classes `Cf:Prose` emits
for `size`) are simply class names Tailwind does not recognise. There is no
build error and no runtime error — the block renders with valid markup and
no typographic styling at all, the same silent-unstyled failure mode as a
missed [content glob](#tailwind-content-glob-read-this-one) above, just with
a missing plugin as the cause instead of a missing path.

## Coexistence with an existing framework

Tailwind's preflight resets margins, font sizes, list styles, and form control
Expand Down
34 changes: 34 additions & 0 deletions docs/escaping.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,40 @@ The axis attributes emitted by `cf_ui_root_attrs()` are `Markup` by
construction, so they survive; a hostile URL argument passed to
`cf_ui_head()` or `cf_ui_body()` is escaped.

## The prose contract

[`Cf:Prose` / `<c-cf.prose>`](primitives.md#cfprose-c-cfprose) is a
typographic reset block, and its whole purpose is to hold caller-supplied
markup — usually several elements of it, not one icon tag. Mechanically
nothing above changes: `prose` takes no markup prop, content arrives through
the slot exactly like every other primitive, and cf-ui's own template is
still wrapped in `{% autoescape true %}` regardless of what the calling
environment does. What has to be said plainly, because `prose` is the
component someone reaches for specifically to render a block of rich
content, is what that mechanism does and does not cover.

**cf-ui does not sanitize caller-supplied HTML.** If you wrap content in
`Markup` (Jinja) or `mark_safe` (Django) to get real markup into a `prose`
slot, you have told the template layer to trust that string completely —
cf-ui renders it as-is, the same way it renders any other `Markup` value
passed anywhere else in the package. `prose` does not inspect it, strip it,
or narrow which tags are allowed.

That is fine, and is exactly what `Markup`/`mark_safe` are for, when the
HTML originates from your own code or a trusted template. It stops being
fine the moment any part of that HTML came from a user — a comment body, a
bio field, a rendered Markdown document someone else authored. Marking
*that* safe and handing it to `prose` is a stored XSS vector, not a
cf-ui bug: you told the template layer not to escape it, and it didn't.

If the content is user-supplied, sanitize it **before** it reaches `Markup`
or `mark_safe` — with an allowlist-based HTML sanitizer such as
[`nh3`](https://pypi.org/project/nh3/) or
[`bleach`](https://pypi.org/project/bleach/), not with escaping, since the
whole point of `prose` is to render real markup rather than escaped text.
Sanitizing is the caller's responsibility; cf-ui's escaping guarantee starts
after that decision has already been made.

## Verifying it yourself

The unfriendliest configuration a consumer can produce — no installer,
Expand Down
184 changes: 169 additions & 15 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,19 @@ caller's obligation.
| `heading` | | ● | | ● | ● | |
| `label` | | ● | | | | |
| `icon` | | ● | | | | |
| `box` | ● | | | | | |
| `prose` | | ● | | | | |

`label` and `icon` take no `variant` on purpose. A label's colour belongs to
the field it labels and an icon's to whatever contains it; giving either its
own would create two sources of truth for one colour.

`box` takes `variant` and nothing else — a container's size is its content's
business, and no shipped framework models a "state" for a plain box. `prose`
takes only `size`: a typographic reset has no colour of its own, and giving
it `variant` would mean colouring every nested element it wraps rather than
the one thing this primitive actually decides.

`heading` separates `level` from `size` because they answer different
questions — `level` is the document outline (`<h1>`…`<h6>`, which screen
readers navigate by) and `size` is how big it looks. Coupling them forces a
Expand All @@ -150,6 +158,8 @@ worth knowing before you reach for an axis:
| `icon` | `size` is **inert** on Foundation, which has no icon wrapper and no font-size scale. On Bootstrap the scale is mixed: `small` is em-relative and composes inside a `btn-sm`, `large` is absolute and does not |
| `label` | `size` has no `small` step on Foundation |
| `button` | `info` renders as `secondary` on Foundation, which ships five button colours and no informational one |
| `box` | `variant` is **inert** on Bulma — `.box` has no colour modifier at all |
| `prose` | `size` is **inert** on Bootstrap, Foundation, and Fomantic — their base typography already applies document-wide, so there is no reset step left for `size` to control |

cf-ui does not paper over these with utilities that reach the wrong values.
Bootstrap's `fs-*` scale, for instance, bottoms out *larger* than a default
Expand Down Expand Up @@ -327,6 +337,146 @@ Icons, Bulma assumes Font Awesome, daisyUI assumes nothing — there is no
class-level abstraction spanning all five, and adopting one would make a UI kit
choose its consumers' icon vendor.

## `Cf:Box` / `<c-cf.box>`

| Prop | Default | Notes |
|---|---|---|
| `variant` | `"neutral"` | Inert on Bulma — see below |
| `extra_class` / `class` | `""` | |
| slot | — | Arbitrary content |

`box` is a plain bordered or elevated container: one element, no imposed
inner structure. It is named `box`, not `surface` — `box` is Bulma's actual
class name for this element, and `surface` is Material Design vocabulary
that none of the five shipped frameworks use.

```jinja
<Cf:Box variant="danger">
Something needs attention.
</Cf:Box>
```

```html
<c-cf.box variant="danger">
Something needs attention.
</c-cf.box>
```

Bootstrap has no box component, so cf-ui composes one from utilities —
`border rounded p-3`, with `variant` mapping to `border-primary` and so on.
`card` was considered and rejected: it imposes a header/body/footer structure
a plain box's caller cannot opt out of. Foundation renders `callout`, with
`variant` mapped onto its five colours (`primary secondary success warning
alert`); Foundation spells danger `alert` and ships no informational hue, so
`info` folds onto `secondary` — the same substitution `badge` already makes
on this theme. On daisy the border colour lives entirely in `variant`,
`neutral` included (`border-base-300`): splitting a default colour into
`base` and an override into `variant` would put two border-color utilities
of equal specificity on one element, leaving Tailwind's emission order,
rather than this map, to decide which one renders.

### Bulma: `variant` is inert

Bulma ships no colour modifier for `.box`. `has-background-*` classes exist,
but they set a saturated background without touching text colour — routing
`variant` through them would make a themed box unreadable rather than
themed. cf-ui leaves the axis inert on this theme instead of faking it: a
Bulma box looks the same regardless of `variant`.

### Fomantic: the hue trap

Fomantic renders a box as `ui segment` plus a hue — `blue grey green yellow
red teal`. This is the theme where the obvious implementation is wrong, and
worth saying plainly because it is the kind of trap this doc exists to
record.

`primary` and `secondary` are **not** colours on a Fomantic segment — they
are its *emphasis* variation. `.ui.primary.segment` renders a subdued
treatment, not a brand-coloured fill. Mapping `variant="primary"` onto the
literal word `primary` would compile, look plausible, and silently produce
the wrong result on the one theme where the variant name happens to match a
real Fomantic class. cf-ui maps `primary` to `blue` and `secondary` to
`grey` instead — real hues, not the words that look like they should work.

## `Cf:Prose` / `<c-cf.prose>`

| Prop | Default | Notes |
|---|---|---|
| `size` | `"normal"` | Inert on Bootstrap, Foundation, and Fomantic — see below |
| `extra_class` / `class` | `""` | |
| slot | — | Arbitrary rich content: headings, paragraphs, lists, tables |

`prose` is a typographic reset block: it styles nested `h1`–`h6`, `p`, `ul`,
and `table` without requiring a class on each one. It takes `size` and
nothing else, for the reason given above the axis table.

It is named `prose`, not `content`, for two concrete reasons. `content`
collides with Bulma's own `.content` class — the exact class this component
maps to on that theme — and `content` is already the prop name every Jinja
primitive uses for slot text, so the JinjaX spelling would have been a
`Content` component taking a `content` prop — self-contradictory. (Written
out rather than shown as a tag: `tests/unit/test_docs_samples.py` resolves
every `Cf:` tag in these docs against the real catalogue, so a component that
deliberately does not exist cannot be illustrated as one.)

```jinja
<Cf:Prose size="large">
<h2>Release notes</h2>
<p>Everything in this block gets typographic styling for free.</p>
</Cf:Prose>
```

```html
<c-cf.prose size="large">
<h2>Release notes</h2>
<p>Everything in this block gets typographic styling for free.</p>
</c-cf.prose>
```

On Bulma it maps to `.content`, with `is-small`/`is-large` for `size` — the
reference implementation the other four themes are measured against.

### Bootstrap and Foundation: no class, and that's correct

Both frameworks emit no class at all for `prose`, and `size` is inert on
both. That is not a gap. Bootstrap's Reboot and Foundation's base typography
style bare `h1`-`h6`, `p`, and `ul` document-wide already, so the reset this
component exists to scope is already in effect everywhere on the page —
there is nothing for a class to add.

### Fomantic: a real gap, and a different reason

Fomantic emits no class either, but not for the same reason as the two
above, and this one is a genuine limitation rather than a redundant no-op.

Fomantic does style bare `h1`-`h5` and `p` globally, so headings and
paragraphs inside a `Cf:Prose` block come out right with no wrapper class
needed. It ships **no bare `ul`, `ol`, or `table` rule at all** — that
styling lives on `.ui.list` and `.ui.table`, applied to the element itself
rather than inherited from an ancestor. A list or table inside a prose block
therefore renders with browser defaults, and no scoping class exists that
would fix it.

cf-ui does not paper over this by authoring its own Fomantic typography
reset — that would mean shipping component CSS this package has never
shipped, and guessing at a framework's type scale instead of using the
framework's own. If you need a styled list or table inside a Fomantic prose
block, reach for the framework's own element-level classes directly: use
[`<c-cf.table>`](components.md) for tables, and put `ui list` on `<ul>`/`<ol>`.

### daisyUI: requires `@tailwindcss/typography`

On daisy, `prose` maps to the `prose` class, with `prose-sm`/`prose-lg` for
`size` — but `prose` is not a daisyUI class and not a Tailwind core class.
It comes from `@tailwindcss/typography`, a separate plugin declared a
requirement for this one component on this one theme. See
[DaisyUI theme](daisyui.md) for how to add it.

Without the plugin, `prose` is simply an unrecognised class name: the block
renders unstyled, with no error and no warning. That is the same benign
class-valued failure mode `AXIS_KINDS` already tolerates for an empty axis
value — it just arrives here from a missing plugin instead of an empty prop.

## Escaping

The contract in [Escaping](escaping.md) applies unchanged: every cf-ui Jinja
Expand All @@ -348,9 +498,13 @@ governed by the caller's own escaping policy:
still the slot rather than a prop — so passing a `Markup` value is a visible,
per-call decision at the call site rather than a package default.

When Tier 2 lands, `prose`/`content` will be the one component that exists to
wrap caller-supplied HTML. It will need its own explicit statement here; the
rule above is not sufficient for it.
`prose` is the other one, and the stakes are higher: its whole purpose is to
wrap caller-supplied HTML, often multiple elements of it, rather than one
`<i>` tag. The mechanism is identical — slot-based, still inside cf-ui's own
`{% autoescape true %}` block — but the rule above is not sufficient on its
own to say what's safe to put there. See
[Escaping: the `prose` contract](escaping.md#the-prose-contract) for the
explicit statement.

## Why the classes are written out longhand

Expand Down Expand Up @@ -407,15 +561,15 @@ and commit both. A drift test fails the build otherwise.

## Not yet implemented

`badge`, `heading`, `label`, and `icon` have settled contracts above but no
templates yet — they are registered in `PRIMITIVES` and deliberately **not** in
`themes.COMPONENTS`, so referring to one raises a clear `ThemeError` rather
than a `TemplateDoesNotExist` at first render.

Tier 2 (`box`/`surface`, `prose`/`content`) and Tier 3 (`grid`) are tracked
separately. `grid` in particular is not a settled question: Bootstrap, Bulma,
Foundation, and Fomantic all ship 12-column systems with different
vocabularies, while daisyUI ships none and defers to Tailwind utilities — so a
daisy `grid` would emit raw utility classes while the other four emit framework
classes. That is a genuine asymmetry rather than a thin adapter, and it is
being decided on its own rather than inside this layer.
Every primitive registered in `PRIMITIVES` — `button`, `badge`, `heading`,
`label`, `icon`, `box`, `prose` — ships templates on all five themes in both
engines as of #54. Tier 1 (`badge`, `heading`, `label`, `icon`) landed in
#53; Tier 2 (`box`, `prose`), documented above, is #54.

What's left is Tier 3 `grid` tracked in #55, and it is not a settled
question the way Tier 1 and 2 were. Bootstrap, Bulma, Foundation, and
Fomantic all ship 12-column systems with different vocabularies, while
daisyUI ships none and defers to Tailwind utilities — so a daisy `grid`
would emit raw utility classes while the other four emit framework classes.
That is a genuine asymmetry rather than a thin adapter, and it is being
decided on its own rather than inside this layer.
Loading
Loading