The Tailwind plugin¶
cf-ui ships a Tailwind plugin that validates axis values at build time and
generates the axis CSS. The validation is the point: without it,
data-accent="hotpink" is not an error, it is an element with no accent —
found in review, or in production, rather than at build time.
src/cf_ui/static/cf_ui/
├── cf_ui_axes.json # generated by `python -m cf_ui.axes`
└── cf_ui_tailwind_plugin.mjs # reads the JSON above
Distribution: vendored¶
The plugin is not published to npm and is not installed from git. It ships
inside the Python wheel, beside cf_ui_alpine.js, and consuming apps point
their Tailwind build at it in site-packages.
The reasoning is version coupling. The plugin's whole job is to enforce a
contract defined in cf_ui/axes.py; an npm package would introduce a second
version number that can disagree with the installed wheel, and the failure mode
of that disagreement is precisely the one this plugin exists to prevent —
values that validate against one definition and not the other. Shipping the
plugin in the wheel makes the two impossible to skew: there is one artifact,
one version, one definition.
The cost is an uglier import path. That is the trade being made deliberately.
Wiring it up¶
Tailwind v4, CSS-first:
@import "tailwindcss";
@plugin "../.venv/lib/python3.12/site-packages/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs";
Options can be passed on the CSS path too, but @plugin options are flat
key/value pairs — good for a named composition or a boolean:
A per-axis map ({ accent: "brand" }) or a custom valueSets cannot be
expressed in that syntax. Use the JS config for those:
import cfUiAxes from "../.venv/lib/python3.12/site-packages/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs";
export default {
plugins: [cfUiAxes({ composition: "console", contrastReport: true })],
};
Ask Python where the file is rather than hardcoding a virtualenv layout:
python -c "from cf_ui.axes import AXIS_DEFINITION_PATH as p; print(p.parent / 'cf_ui_tailwind_plugin.mjs')"
You still need cf_ui.themes.tailwind_content_globs() in your content /
@source config so the component classes survive tree-shaking — see
daisyui.md. The plugin generates custom properties, which
Tailwind never tree-shakes; the two concerns are separate.
Options¶
| Option | Default | Meaning |
|---|---|---|
composition |
null |
A named composition, a partial {axis: value} map, or null for the default. Validated — an unknown value throws. |
valueSets |
null |
Your app's own {axis: {name: tokens}}. |
valueSetsMode |
"extend" |
"extend" adds to the shipped set; "replace" discards the shipped values for each axis you supply. |
contrastReport |
false |
Warn with every accent × surface × mode and its WCAG AA numbers. |
definition |
shipped JSON | Override the axis definition entirely. Mostly for tests. |
What fails the build, and what only warns¶
Fails — these are unambiguously mistakes:
- an axis value that no value set declares
- an axis name that does not exist
- an unknown named composition
- a value name that is not lowercase-and-hyphens
- a mode-keyed value (
accent,surface) missinglightordark - a token that is not a CSS custom property
- a token value containing
;,{,},<, or a comment delimiter — those close the declaration and write rules you did not author - a token value that is not a string
- a value or composition name that exists only on
Object.prototype(toString,hasOwnProperty, …), and a value set keyed on__proto__
Warns — contrastReport: true:
- a combination below WCAG AA
- a p3 override that does not hold its base declaration's lightness
Contrast is advisory on purpose. An unknown axis value is always wrong. A contrast number below AA may be a deliberate choice in a value set the package did not ship, and making it fatal would mean cf-ui decides when your app is allowed to compile. The report exists so nobody can say they never saw the numbers.
cf-ui: contrast report — 1 of 12 combinations fail WCAG AA:
faint x plain (light):
--cf-accent-content on --cf-accent: 1.09 < 4.5
--cf-accent-strong on --cf-ground: 1.13 < 4.5
Every export, and whether it validates¶
createPlugin used to be the only path that ran the gate, so the two exported
generators were the generator with the build error switched off. Passing junk
produced junk quietly. Since 0.2.0 everything validates by default, and the
escape hatch has to be asked for.
| Export | Validates | Notes |
|---|---|---|
default (cfUiAxes) |
yes | The plugin. Callable as a factory or used directly. |
buildAxisBase(valueSets?, definition?, options?) |
yes | { validate: false } skips the gate. |
buildAxisCss(valueSets?, definition?, options?) |
yes | Same options as above. |
mergeValueSets(definition, custom, mode?) |
yes | This is the gate for value sets. |
resolveComposition(definition, composition, valueSets) |
yes | Throws on an unknown value — the headline feature. |
loadDefinition() |
n/a | Reads cf_ui_axes.json; fresh deep copy per call. |
contrastRatio(fg, bg) |
n/a | null for anything that is not sRGB hex. |
contrastReport(valueSets, pairs, modes?) |
no | Pure measurement; returns rows, never throws. |
oklabLightness(color) |
n/a | null for anything that is not sRGB hex. |
oklchLightness(value) |
n/a | null for anything that is not oklch(...). |
p3LightnessFailures(valueSets?, definition?, tolerance?) |
no | Pure measurement; returns messages. |
AxisPluginError |
n/a | Every throw above is one of these. |
P3_LIGHTNESS_TOLERANCE |
n/a | 0.005 — 0.5 percentage points of OKLab L. |
The opt-out exists for legitimate uses — writing the axis CSS to a file, inspecting a build, comparing the two generators — and the parity test uses it deliberately, so that test compares raw generation rather than narrowing to "they agree on input the validator already approved". Do not reach for it with input you did not write.
// Explicit, and only ever for input you control.
const css = buildAxisCss(mySets, undefined, { validate: false });
The Python generators carry the identical contract, so neither side is the lax one:
| Python | JavaScript |
|---|---|
render_axis_css(sets, banner=True, validate=True) |
buildAxisCss(sets, definition, { validate }) |
custom_axis_css(sets, banner=False, validate=True) |
— |
style_element(sets, validate=True) |
— |
merge_value_sets(custom, mode) |
mergeValueSets(definition, custom, mode) |
One definition, two generators — and a test that proves it¶
cf_ui/axes.py is still the single source of truth. It generates two
artifacts:
The plugin holds no copy of the value sets. It reads cf_ui_axes.json at
build time, which is why it does not need Python and why it cannot drift from
it.
That is guaranteed rather than asserted.
tests/unit/test_tailwind_plugin.py runs both generators and compares their
output declaration by declaration — every selector, every custom property,
every value, plus the @media (color-gamut: p3) layer. If render_axis_css
and the plugin ever disagree, those tests fail. A separate drift test pins the
committed JSON to axis_definition(), so editing axes.py without
regenerating is also caught.
If you add an axis value, edit axes.py, run python -m cf_ui.axes, and
commit both generated files.
Custom value sets¶
cfUiAxes({
valueSets: {
accent: {
brand: {
light: {
"--cf-accent": "#7c3aed",
"--cf-accent-content": "#ffffff",
"--cf-accent-strong": "#5b21b6",
},
dark: {
"--cf-accent": "#c4b5fd",
"--cf-accent-content": "#2e1065",
"--cf-accent-strong": "#ddd6fe",
},
},
},
},
composition: { accent: "brand" },
contrastReport: true,
});
light and dark must each be declared. Neither is derived from the other by
inversion — an inverted palette is a second theme nobody designed.
Base declarations must be sRGB hex, because that is what the contrast numbers
are computed against. Wide-gamut chroma goes in an optional p3 block at the
same lightness, and the plugin generates the @media (color-gamut: p3) layer
from it:
brand: {
light: { "--cf-accent": "#7c3aed", /* ... */ },
dark: { "--cf-accent": "#c4b5fd", /* ... */ },
p3: {
light: { "--cf-accent": "oklch(51.6% 0.246 293.9)" },
dark: { "--cf-accent": "oklch(81.1% 0.111 293.6)" },
},
}
The axis value sets are a public API¶
Consuming apps that expose theming to their own end users — white-label or multi-tenant products — cannot rely on whoever picks a value having read this page. That is why the check is a build error and not a runtime warning.
It also means the shipped value names are treated as public API: additive changes only, and a deprecation path for removals.
Testing the plugin¶
just test-js # node --test, never skips
just test # pytest; runs the JS suite too, skipping if node is absent
CI runs node --test as its own step so the JS suite can never be silently
skipped there.