Skip to content

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:

@plugin "../.venv/.../cf_ui_tailwind_plugin.mjs" {
  composition: console;
  contrastReport: true;
}

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) missing light or dark
  • 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__

WarnscontrastReport: 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)
# Same shape, same default, same escape hatch.
css = render_axis_css(my_sets, validate=False)

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:

python -m cf_ui.axes     # or: just axes
# wrote .../cf_ui_axes.css
# wrote .../cf_ui_axes.json

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.