diff --git a/.gitignore b/.gitignore index fd154b8..bdb4d0a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ uv.lock # mkdocs build output site/ + +# Agent worktrees, created by isolated subagent runs. +.claude/worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index aea7670..ac24059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** + `` / `` is a plain bordered container — one element, no + imposed header/body/footer, which is what separates it from `card`. + `` / `` 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 `` 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 diff --git a/docs/daisyui.md b/docs/daisyui.md index 9aad081..4e6b267 100644 --- a/docs/daisyui.md +++ b/docs/daisyui.md @@ -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` / ``](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 diff --git a/docs/escaping.md b/docs/escaping.md index b37d333..7de7509 100644 --- a/docs/escaping.md +++ b/docs/escaping.md @@ -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` / ``](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, diff --git a/docs/primitives.md b/docs/primitives.md index 9cb32b3..aa8e79d 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -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 (`

`…`

`, which screen readers navigate by) and `size` is how big it looks. Coupling them forces a @@ -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 @@ -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` / `` + +| 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 + + Something needs attention. + +``` + +```html + + Something needs attention. + +``` + +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` / `` + +| 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 + +

Release notes

+

Everything in this block gets typographic styling for free.

+
+``` + +```html + +

Release notes

+

Everything in this block gets typographic styling for free.

+
+``` + +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 +[``](components.md) for tables, and put `ui list` on `
    `/`
      `. + +### 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 @@ -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 +`` 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 @@ -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. diff --git a/src/cf_ui/primitives.py b/src/cf_ui/primitives.py index 4f6ddeb..09af7ea 100644 --- a/src/cf_ui/primitives.py +++ b/src/cf_ui/primitives.py @@ -217,12 +217,18 @@ class PrimitiveConfigError(ValueError): #: ``label`` and ``icon`` deliberately take no ``variant``: a label's colour #: belongs to the field it labels, and an icon's belongs to whatever contains #: it. Giving them one invites two sources of truth for the same colour. +#: ``box`` takes ``variant`` and nothing else. A container's size is its +#: content's business, and a "state" for a box has no meaning any framework +#: models. ``prose`` takes only ``size`` — a typographic reset has no colour, +#: and giving it one would mean colouring every nested element. PRIMITIVES: dict[str, tuple[str, ...]] = { "button": ("variant", "size", "state", "type"), "badge": ("variant", "size"), "heading": ("level", "size", "emphasis"), "label": ("size",), "icon": ("size",), + "box": ("variant",), + "prose": ("size",), } @@ -523,6 +529,140 @@ class PrimitiveConfigError(ValueError): }, } +_BOX_CLASSES: dict[str, dict[str, Any]] = { + "bulma": { + "base": "box", + # Bulma ships no colour modifier for `.box`. `has-background-*` exists + # but sets a saturated background without touching text colour, so a + # variant routed through it renders unreadable rather than themed. + # Inert here, deliberately, and documented rather than faked. + "variant": { + "primary": "", + "secondary": "", + "success": "", + "warning": "", + "danger": "", + "info": "", + "neutral": "", + }, + }, + "bootstrap": { + # Bootstrap has no box component. This is the utility composition its + # own docs reach for: `card` would be wrong, because a card imposes a + # header/body/footer structure the caller cannot opt out of. + "base": "border rounded p-3", + "variant": { + "primary": "border-primary", + "secondary": "border-secondary", + "success": "border-success", + "warning": "border-warning", + "danger": "border-danger", + "info": "border-info", + # `.border` already sets the default border colour. + "neutral": "", + }, + }, + "foundation": { + "base": "callout", + "variant": { + "primary": "primary", + "secondary": "secondary", + "success": "success", + "warning": "warning", + # Foundation spells it `alert`, and ships no `info` hue — the same + # two substitutions `_BADGE_CLASSES` makes for this theme. + "danger": "alert", + "info": "secondary", + "neutral": "", + }, + }, + "fomantic": { + "base": "ui segment", + # Real hues, *not* the literal words `primary`/`secondary`. Those two + # are Fomantic's **emphasis** variation on a segment, not colours: + # `.ui.primary.segment` renders a subdued treatment rather than a + # brand fill, so mapping the variant onto them would silently produce + # the wrong thing on the one theme where the name happens to match. + "variant": { + "primary": "blue", + "secondary": "grey", + "success": "green", + "warning": "yellow", + "danger": "red", + "info": "teal", + "neutral": "", + }, + }, + "daisy": { + # The border *colour* lives entirely in the variant, including for + # `neutral`. Splitting it — a default colour in `base` and an override + # in the variant — puts two border-color utilities of equal + # specificity on one element, and which wins is then decided by + # Tailwind's emission order rather than by this map. + "base": "rounded-box border bg-base-100 p-4", + "variant": { + "primary": "border-primary", + "secondary": "border-secondary", + "success": "border-success", + "warning": "border-warning", + # daisyUI spells it `error`. + "danger": "border-error", + "info": "border-info", + "neutral": "border-base-300", + }, + }, +} + +_PROSE_CLASSES: dict[str, dict[str, Any]] = { + "bulma": { + "base": "content", + "size": {"small": "is-small", "normal": "", "large": "is-large"}, + }, + "bootstrap": { + # Empty, and correct rather than missing. Bootstrap's Reboot styles + # bare `h1`-`h6`, `p` and `ul` globally, so the reset this component + # exists to scope is already in effect everywhere. A class here would + # have nothing to add. + "base": "", + "size": {"small": "", "normal": "", "large": ""}, + }, + "foundation": { + # Same as Bootstrap: Foundation's base typography applies to bare + # elements document-wide. + "base": "", + "size": {"small": "", "normal": "", "large": ""}, + }, + "fomantic": { + # Empty, but *not* for the same reason as the two above, and the + # difference is a real gap rather than a shrug. + # + # Fomantic does style bare `h1`-`h5` and `p` globally, so headings and + # paragraphs inside a prose block come out right with no wrapper. It + # ships no bare `ul`, `ol` or `table` rule at all, though — that + # styling lives on `.ui.list` and `.ui.table`, on the element itself. + # So a list or a table inside a prose block renders with browser + # defaults, and no scoping class exists that would fix it. + # + # Left empty on purpose. The alternative is cf-ui authoring its own + # Fomantic typography reset, which means shipping component CSS this + # package has never shipped and guessing at a framework's type scale. + # Stated in `docs/primitives.md` instead, with the workaround that + # actually works: `` inside the block. + "base": "", + "size": {"small": "", "normal": "", "large": ""}, + }, + "daisy": { + # `prose` comes from `@tailwindcss/typography` — not from daisyUI and + # not from Tailwind core. It is declared a requirement for this one + # component on this one theme (`docs/daisyui.md`). Without the plugin + # the class is simply unrecognised and the block renders unstyled, + # which is the benign class-valued failure mode rather than broken + # markup — the same reason `AXIS_KINDS` lets a class axis be empty. + "base": "prose", + "size": {"small": "prose-sm", "normal": "", "large": "prose-lg"}, + }, +} + #: Per-component maps, each keyed by theme. Adding a primitive is one entry #: here plus its templates — the assembly below and every test derive from it. _COMPONENT_CLASSES: dict[str, dict[str, dict[str, Any]]] = { @@ -531,6 +671,8 @@ class PrimitiveConfigError(ValueError): "heading": _HEADING_CLASSES, "label": _LABEL_CLASSES, "icon": _ICON_CLASSES, + "box": _BOX_CLASSES, + "prose": _PROSE_CLASSES, } diff --git a/src/cf_ui/static/cf_ui/cf_ui_primitives.json b/src/cf_ui/static/cf_ui/cf_ui_primitives.json index 3505368..f637b00 100644 --- a/src/cf_ui/static/cf_ui/cf_ui_primitives.json +++ b/src/cf_ui/static/cf_ui/cf_ui_primitives.json @@ -66,6 +66,12 @@ ], "icon": [ "size" + ], + "box": [ + "variant" + ], + "prose": [ + "size" ] }, "classes": { @@ -136,6 +142,26 @@ "normal": "", "large": "is-large" } + }, + "box": { + "base": "box", + "variant": { + "primary": "", + "secondary": "", + "success": "", + "warning": "", + "danger": "", + "info": "", + "neutral": "" + } + }, + "prose": { + "base": "content", + "size": { + "small": "is-small", + "normal": "", + "large": "is-large" + } } }, "daisy": { @@ -205,6 +231,26 @@ "normal": "", "large": "text-[1.5em]" } + }, + "box": { + "base": "rounded-box border bg-base-100 p-4", + "variant": { + "primary": "border-primary", + "secondary": "border-secondary", + "success": "border-success", + "warning": "border-warning", + "danger": "border-error", + "info": "border-info", + "neutral": "border-base-300" + } + }, + "prose": { + "base": "prose", + "size": { + "small": "prose-sm", + "normal": "", + "large": "prose-lg" + } } }, "bootstrap": { @@ -274,6 +320,26 @@ "normal": "", "large": "fs-4" } + }, + "box": { + "base": "border rounded p-3", + "variant": { + "primary": "border-primary", + "secondary": "border-secondary", + "success": "border-success", + "warning": "border-warning", + "danger": "border-danger", + "info": "border-info", + "neutral": "" + } + }, + "prose": { + "base": "", + "size": { + "small": "", + "normal": "", + "large": "" + } } }, "foundation": { @@ -343,6 +409,26 @@ "normal": "", "large": "" } + }, + "box": { + "base": "callout", + "variant": { + "primary": "primary", + "secondary": "secondary", + "success": "success", + "warning": "warning", + "danger": "alert", + "info": "secondary", + "neutral": "" + } + }, + "prose": { + "base": "", + "size": { + "small": "", + "normal": "", + "large": "" + } } }, "fomantic": { @@ -412,6 +498,26 @@ "normal": "", "large": "large" } + }, + "box": { + "base": "ui segment", + "variant": { + "primary": "blue", + "secondary": "grey", + "success": "green", + "warning": "yellow", + "danger": "red", + "info": "teal", + "neutral": "" + } + }, + "prose": { + "base": "", + "size": { + "small": "", + "normal": "", + "large": "" + } } } } diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/box.html b/src/cf_ui/templates/cotton/_themes/bootstrap/box.html new file mode 100644 index 0000000..92f8e35 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/box.html @@ -0,0 +1,7 @@ +{% comment %}Bootstrap has no box component. `card` would be wrong — it imposes a +header/body/footer structure the caller cannot opt out of — so this is the +utility composition Bootstrap's own docs reach for. `neutral` maps to nothing +because `.border` already sets the default border colour.{% endcomment %} +
      {{ slot }}
      diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/prose.html b/src/cf_ui/templates/cotton/_themes/bootstrap/prose.html new file mode 100644 index 0000000..36768e0 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/prose.html @@ -0,0 +1,5 @@ +{% comment %}No class, and that is the correct answer rather than a gap. Bootstrap's +Reboot styles bare `h1`-`h6`, `p` and `ul` globally, so the typographic reset +this component exists to scope is already in effect everywhere. `size` has no +Bootstrap counterpart and is inert.{% endcomment %} +
      {{ slot }}
      diff --git a/src/cf_ui/templates/cotton/_themes/bulma/box.html b/src/cf_ui/templates/cotton/_themes/bulma/box.html new file mode 100644 index 0000000..41bad2b --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/box.html @@ -0,0 +1,4 @@ +{% comment %}Bulma ships no colour modifier for `.box`, so `variant` is inert here. +`has-background-*` exists but sets a saturated background without touching text +colour, which would render unreadable rather than themed.{% endcomment %} +
      {{ slot }}
      diff --git a/src/cf_ui/templates/cotton/_themes/bulma/prose.html b/src/cf_ui/templates/cotton/_themes/bulma/prose.html new file mode 100644 index 0000000..6105c5e --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/prose.html @@ -0,0 +1,3 @@ +
      {{ slot }}
      diff --git a/src/cf_ui/templates/cotton/_themes/daisy/box.html b/src/cf_ui/templates/cotton/_themes/daisy/box.html new file mode 100644 index 0000000..8ccafff --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/box.html @@ -0,0 +1,8 @@ +{% comment %}The border colour lives entirely in the variant, `neutral` included. +Splitting it — a default colour in the base and an override in the variant — +puts two border-color utilities of equal specificity on one element, and which +one wins is then decided by Tailwind's emission order rather than by the class +map.{% endcomment %} +
      {{ slot }}
      diff --git a/src/cf_ui/templates/cotton/_themes/daisy/prose.html b/src/cf_ui/templates/cotton/_themes/daisy/prose.html new file mode 100644 index 0000000..ede130b --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/prose.html @@ -0,0 +1,8 @@ +{% comment %}`prose` comes from `@tailwindcss/typography` — not from daisyUI and not +from Tailwind core. It is a declared requirement for this one component on this +one theme, see docs/daisyui.md. Without the plugin the class is unrecognised and +the block renders unstyled, which is the benign failure mode rather than broken +markup.{% endcomment %} +
      {{ slot }}
      diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/box.html b/src/cf_ui/templates/cotton/_themes/fomantic/box.html new file mode 100644 index 0000000..6ad9042 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/box.html @@ -0,0 +1,8 @@ +{% comment %}Real hues, not the literal words `primary` and `secondary`. Those two are +Fomantic's *emphasis* variation on a segment rather than colours — +`.ui.primary.segment` renders a subdued treatment, not a brand fill — so +mapping the variant onto them would silently produce the wrong thing on the one +theme where the name happens to match.{% endcomment %} +
      {{ slot }}
      diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/prose.html b/src/cf_ui/templates/cotton/_themes/fomantic/prose.html new file mode 100644 index 0000000..ca919bd --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/prose.html @@ -0,0 +1,7 @@ +{% comment %}No class, but not for the same reason as Bootstrap and Foundation. +Fomantic does style bare `h1`-`h5` and `p` globally, so headings and paragraphs +come out right with no wrapper. It ships no bare `ul`, `ol` or `table` rule at +all, though — that styling lives on `.ui.list` and `.ui.table`, on the element +itself — so a list or table inside a prose block renders with browser defaults +and no scoping class fixes it. See docs/primitives.md.{% endcomment %} +
      {{ slot }}
      diff --git a/src/cf_ui/templates/cotton/_themes/foundation/box.html b/src/cf_ui/templates/cotton/_themes/foundation/box.html new file mode 100644 index 0000000..23f4cb8 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/box.html @@ -0,0 +1,6 @@ +{% comment %}Foundation spells danger `alert` and ships no `info` hue, so `info` +folds onto `secondary` — the same two substitutions the badge partial makes for +this theme.{% endcomment %} +
      {{ slot }}
      diff --git a/src/cf_ui/templates/cotton/_themes/foundation/prose.html b/src/cf_ui/templates/cotton/_themes/foundation/prose.html new file mode 100644 index 0000000..2033334 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/prose.html @@ -0,0 +1,5 @@ +{% comment %}No class, and that is the correct answer rather than a gap. Foundation's +base typography applies to bare `h1`-`h6`, `p` and `ul` document-wide, so the +reset this component exists to scope is already in effect. `size` has no +Foundation counterpart and is inert.{% endcomment %} +
      {{ slot }}
      diff --git a/src/cf_ui/templates/cotton/cf/box.html b/src/cf_ui/templates/cotton/cf/box.html new file mode 100644 index 0000000..fb70f48 --- /dev/null +++ b/src/cf_ui/templates/cotton/cf/box.html @@ -0,0 +1,4 @@ + +{% load cf_ui %} +{% cf_ui_validate "box" variant=variant %}{% cf_ui_theme_path "box" as cf_ui_partial %} +{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/prose.html b/src/cf_ui/templates/cotton/cf/prose.html new file mode 100644 index 0000000..4b76491 --- /dev/null +++ b/src/cf_ui/templates/cotton/cf/prose.html @@ -0,0 +1,4 @@ + +{% load cf_ui %} +{% cf_ui_validate "prose" size=size %}{% cf_ui_theme_path "prose" as cf_ui_partial %} +{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/jinja/bootstrap/Box.jinja b/src/cf_ui/templates/jinja/bootstrap/Box.jinja new file mode 100644 index 0000000..cc21226 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Box.jinja @@ -0,0 +1,14 @@ +{#def content="", variant="neutral", extra_class="" #} +{% autoescape true %} + {% set content = content if content is defined else "" %} + {% set variant = variant if variant is defined else "neutral" %} + {% set extra_class = extra_class if extra_class is defined else "" %} + {{ cf_ui_validate("box", variant=variant) }} + {# Bootstrap has no box component. `card` would be wrong — it imposes a + header/body/footer structure the caller cannot opt out of — so this is the + utility composition Bootstrap's own docs reach for. `neutral` maps to + nothing because `.border` already sets the default border colour. #} +
      {{ content }}
      +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/bootstrap/Prose.jinja b/src/cf_ui/templates/jinja/bootstrap/Prose.jinja new file mode 100644 index 0000000..6e007c4 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Prose.jinja @@ -0,0 +1,12 @@ +{#def content="", size="normal", extra_class="" #} +{% autoescape true %} + {% set content = content if content is defined else "" %} + {% set size = size if size is defined else "normal" %} + {% set extra_class = extra_class if extra_class is defined else "" %} + {{ cf_ui_validate("prose", size=size) }} + {# No class, and that is the correct answer rather than a gap. Bootstrap's + Reboot styles bare `h1`-`h6`, `p` and `ul` globally, so the typographic + reset this component exists to scope is already in effect everywhere. + `size` has no Bootstrap counterpart and is inert. #} +
      {{ content }}
      +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/bulma/Box.jinja b/src/cf_ui/templates/jinja/bulma/Box.jinja new file mode 100644 index 0000000..b080a1f --- /dev/null +++ b/src/cf_ui/templates/jinja/bulma/Box.jinja @@ -0,0 +1,11 @@ +{#def content="", variant="neutral", extra_class="" #} +{% autoescape true %} + {% set content = content if content is defined else "" %} + {% set variant = variant if variant is defined else "neutral" %} + {% set extra_class = extra_class if extra_class is defined else "" %} + {{ cf_ui_validate("box", variant=variant) }} + {# Bulma ships no colour modifier for `.box`, so `variant` is inert here. + `has-background-*` exists but sets a saturated background without touching + text colour, which would render unreadable rather than themed. #} +
      {{ content }}
      +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/bulma/Prose.jinja b/src/cf_ui/templates/jinja/bulma/Prose.jinja new file mode 100644 index 0000000..dfa4958 --- /dev/null +++ b/src/cf_ui/templates/jinja/bulma/Prose.jinja @@ -0,0 +1,10 @@ +{#def content="", size="normal", extra_class="" #} +{% autoescape true %} + {% set content = content if content is defined else "" %} + {% set size = size if size is defined else "normal" %} + {% set extra_class = extra_class if extra_class is defined else "" %} + {{ cf_ui_validate("prose", size=size) }} +
      {{ content }}
      +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/daisy/Box.jinja b/src/cf_ui/templates/jinja/daisy/Box.jinja new file mode 100644 index 0000000..f9aa123 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Box.jinja @@ -0,0 +1,15 @@ +{#def content="", variant="neutral", extra_class="" #} +{% autoescape true %} + {% set content = content if content is defined else "" %} + {% set variant = variant if variant is defined else "neutral" %} + {% set extra_class = extra_class if extra_class is defined else "" %} + {{ cf_ui_validate("box", variant=variant) }} + {# The border colour lives entirely in the variant, `neutral` included. + Splitting it — a default colour in the base and an override in the variant — + puts two border-color utilities of equal specificity on one element, and + which one wins is then decided by Tailwind's emission order rather than by + the class map. #} +
      {{ content }}
      +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/daisy/Prose.jinja b/src/cf_ui/templates/jinja/daisy/Prose.jinja new file mode 100644 index 0000000..e694e59 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Prose.jinja @@ -0,0 +1,15 @@ +{#def content="", size="normal", extra_class="" #} +{% autoescape true %} + {% set content = content if content is defined else "" %} + {% set size = size if size is defined else "normal" %} + {% set extra_class = extra_class if extra_class is defined else "" %} + {{ cf_ui_validate("prose", size=size) }} + {# `prose` comes from `@tailwindcss/typography` — not from daisyUI and not + from Tailwind core. It is a declared requirement for this one component on + this one theme, see docs/daisyui.md. Without the plugin the class is + unrecognised and the block renders unstyled, which is the benign failure + mode rather than broken markup. #} +
      {{ content }}
      +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/fomantic/Box.jinja b/src/cf_ui/templates/jinja/fomantic/Box.jinja new file mode 100644 index 0000000..192ae65 --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/Box.jinja @@ -0,0 +1,15 @@ +{#def content="", variant="neutral", extra_class="" #} +{% autoescape true %} + {% set content = content if content is defined else "" %} + {% set variant = variant if variant is defined else "neutral" %} + {% set extra_class = extra_class if extra_class is defined else "" %} + {{ cf_ui_validate("box", variant=variant) }} + {# Real hues, not the literal words `primary` and `secondary`. Those two are + Fomantic's *emphasis* variation on a segment rather than colours — + `.ui.primary.segment` renders a subdued treatment, not a brand fill — so + mapping the variant onto them would silently produce the wrong thing on the + one theme where the name happens to match. #} +
      {{ content }}
      +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/fomantic/Prose.jinja b/src/cf_ui/templates/jinja/fomantic/Prose.jinja new file mode 100644 index 0000000..cfb2d11 --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/Prose.jinja @@ -0,0 +1,15 @@ +{#def content="", size="normal", extra_class="" #} +{% autoescape true %} + {% set content = content if content is defined else "" %} + {% set size = size if size is defined else "normal" %} + {% set extra_class = extra_class if extra_class is defined else "" %} + {{ cf_ui_validate("prose", size=size) }} + {# No class, but not for the same reason as Bootstrap and Foundation. + Fomantic does style bare `h1`-`h5` and `p` globally, so headings and + paragraphs come out right with no wrapper. It ships no bare `ul`, `ol` or + `table` rule at all, though — that styling lives on `.ui.list` and + `.ui.table`, on the element itself — so a list or table inside a prose block + renders with browser defaults and no scoping class fixes it. See + docs/primitives.md. #} +
      {{ content }}
      +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/foundation/Box.jinja b/src/cf_ui/templates/jinja/foundation/Box.jinja new file mode 100644 index 0000000..fddff84 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Box.jinja @@ -0,0 +1,13 @@ +{#def content="", variant="neutral", extra_class="" #} +{% autoescape true %} + {% set content = content if content is defined else "" %} + {% set variant = variant if variant is defined else "neutral" %} + {% set extra_class = extra_class if extra_class is defined else "" %} + {{ cf_ui_validate("box", variant=variant) }} + {# Foundation spells danger `alert` and ships no `info` hue, so `info` folds + onto `secondary` — the same two substitutions the badge template makes for + this theme. #} +
      {{ content }}
      +{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/foundation/Prose.jinja b/src/cf_ui/templates/jinja/foundation/Prose.jinja new file mode 100644 index 0000000..0df3d01 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Prose.jinja @@ -0,0 +1,12 @@ +{#def content="", size="normal", extra_class="" #} +{% autoescape true %} + {% set content = content if content is defined else "" %} + {% set size = size if size is defined else "normal" %} + {% set extra_class = extra_class if extra_class is defined else "" %} + {{ cf_ui_validate("prose", size=size) }} + {# No class, and that is the correct answer rather than a gap. Foundation's + base typography applies to bare `h1`-`h6`, `p` and `ul` document-wide, so + the reset this component exists to scope is already in effect. `size` has + no Foundation counterpart and is inert. #} +
      {{ content }}
      +{% endautoescape %} diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py index 971485e..b205427 100644 --- a/src/cf_ui/themes.py +++ b/src/cf_ui/themes.py @@ -50,6 +50,7 @@ #: primitives that actually ship. COMPONENTS = ( "badge", + "box", "breadcrumb", "button", "card", @@ -64,6 +65,7 @@ "pagination", "panel", "progress", + "prose", "select", "table", "tabs", diff --git a/tests/unit/test_primitives.py b/tests/unit/test_primitives.py index f00c164..72a88ec 100644 --- a/tests/unit/test_primitives.py +++ b/tests/unit/test_primitives.py @@ -48,10 +48,22 @@ JINJA_DIR = REPO_ROOT / "src" / "cf_ui" / "templates" / "jinja" COTTON_THEME_DIR = REPO_ROOT / "src" / "cf_ui" / "templates" / "cotton" / "_themes" -#: 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 = ("badge", "button", "heading", "icon", "label") +#: Primitives that actually ship templates — every test below that reads a +#: file off disk is parametrized over this. +#: +#: Derived, not listed. It used to be a hand-written tuple, and that made it a +#: silent-coverage trap: adding a primitive to :data:`PRIMITIVES` and to +#: ``themes.COMPONENTS`` without also editing this line left its templates +#: with *zero* parity and guard coverage, and nothing failed to say so. A +#: primitive is implemented exactly when it is registered for rendering, so +#: that is now what the name means. Get it wrong in the other direction — +#: registered but no templates — and the parity tests fail loudly instead, +#: which is the outcome that was missing. +#: +#: ``test_a_contract_only_primitive_is_not_registered_yet`` covers the +#: complement: a settled contract with no templates yet must stay out of +#: ``themes.COMPONENTS``. +IMPLEMENTED = tuple(sorted(set(PRIMITIVES) & set(COMPONENTS))) # ── The vocabularies are closed ─────────────────────────────────────────── @@ -434,6 +446,13 @@ def test_the_templates_introduce_no_class_the_map_does_not_know(theme: str, comp "badge": set(), "heading": set(), "icon": set(), + # Both empty, and they should stay that way. Every class these two emit — + # Bootstrap's `border rounded p-3`, daisy's `rounded-box border + # bg-base-100 p-4` — is a *mapped* class, sitting in the component's + # ``base`` where the parity test can see it. A utility that lands here + # instead is one the map does not document. + "box": set(), + "prose": 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. diff --git a/tests/unit/test_theme_dispatch.py b/tests/unit/test_theme_dispatch.py index 10523a6..b45d8ba 100644 --- a/tests/unit/test_theme_dispatch.py +++ b/tests/unit/test_theme_dispatch.py @@ -12,16 +12,20 @@ all 14 components at once, with no template edits in the consuming app. """ +import json from pathlib import Path import pytest +from cf_ui.primitives import CLASSES + TEMPLATES_DIR = Path(__file__).parent.parent.parent / "src" / "cf_ui" / "templates" COTTON_DIR = TEMPLATES_DIR / "cotton" # Cotton file stems (hyphenated), as used by . COMPONENT_STEMS = [ "badge", + "box", "breadcrumb", "button", "card", @@ -36,6 +40,7 @@ "pagination", "panel", "progress", + "prose", "select", "table", "tabs", @@ -268,9 +273,37 @@ def test_switching_the_setting_switches_the_rendered_markup(settings, stem): assert html.strip(), f"{theme} render produced nothing" rendered[theme] = html - assert len(set(rendered.values())) == len(IMPLEMENTED_THEMES), ( - f"{stem} rendered identically under two themes: {sorted(rendered)}" - ) + # Compare collapsed markup, not raw output. A `{% comment %}` block leaves + # its own blank lines behind, so two partials emitting byte-identical + # markup still landed in different buckets purely on how many newlines + # their rationale comments happened to produce — which made this + # assertion pass on whitespace noise. Verified by mutation: gutting a + # partial's whole class chain used to leave this test green. + by_output: dict[str, list[str]] = {} + for theme, html in rendered.items(): + by_output.setdefault(" ".join(html.split()), []).append(theme) + + # Usually every theme renders differently, and for anything with a + # per-theme partial and no class map that is the whole assertion. A + # primitive is allowed to coincide, but only when its class map *proves* + # the themes agree — `prose` is empty on Bootstrap, Foundation and + # Fomantic because all three style typography globally, so "emit no class" + # is the correct answer three times over and three correct answers render + # alike. Deriving the exemption from `CLASSES` rather than listing theme + # names keeps it honest: two themes whose maps differ must still diverge, + # so a partial that silently forgot to apply an axis still fails here. + for group in by_output.values(): + if len(group) == 1: + continue + assert stem in CLASSES[group[0]], ( + f"{stem} rendered identically under {sorted(group)} — it has no " + f"class map, so there is nothing that could make that correct" + ) + signatures = {json.dumps(CLASSES[theme][stem], sort_keys=True) for theme in group} + assert len(signatures) == 1, ( + f"{stem} rendered identically under {sorted(group)} despite those " + f"themes mapping different classes — the partial is dropping an axis" + ) def test_dispatch_passes_the_slot_through_to_the_partial(settings):