feat(axes): Tailwind plugin with build-time axis validation (#7) - #10
Conversation
An unknown axis value now fails the CSS build. Before this,
data-accent="hotpink" produced no error - it produced an element with no
accent, found in review or in production rather than at build time. The
closed value sets were a documentation promise with nothing behind them.
axes.py stays the single source of truth. It now emits cf_ui_axes.json
alongside cf_ui_axes.css, and the plugin reads that JSON at build time -
it holds no copy of the value sets. A parity test runs both generators
and compares their output declaration by declaration, selectors, custom
properties, values and the p3 layer included, so the two cannot drift.
Distribution is vendored, not npm: the plugin enforces a contract defined
in axes.py, and a separate npm version could disagree with the installed
wheel about what a valid value is - the exact failure it exists to
prevent. Being vendored, it imports only Node builtins and exports
Tailwind's {handler, config} shape by hand rather than importing
tailwindcss/plugin, which would not resolve from site-packages.
The contrast report warns rather than failing. An unknown axis value is
always a mistake; a sub-AA ratio may be deliberate in a value set the
package did not ship, and making it fatal would mean cf-ui decides when a
consuming app may compile.
Also declares known-first-party = ["cf_ui"] for isort. The pinned hook
(ruff v0.3.0) and the ruff CI resolves (0.16.x) classify cf_ui
differently for a src/ layout and were reverting each other, so prek
could pass while CI failed I001. Same root fix as on the #6 branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
…al Tailwind
Both were verified against a live tailwindcss v4.3.3 build, not inferred.
1. `@plugin "..." { composition: console; }` failed with "does not accept
options". Tailwind only forwards options to a plugin marked
__isOptionsFunction, which plugin.withOptions sets and this hand-built
export did not. A CSS-first consumer therefore could not pass a
composition at all, so the plugin only ever validated the default
composition - which always passes. The build error was silently inert
for the idiomatic v4 setup, which is the exact class of failure #7
exists to remove.
2. Membership was tested with `in`, which walks the prototype chain, so
`composition: hasOwnProperty` and `{form: "toString"}` passed
validation and generated an empty rule instead of failing the build.
Replaced every such check with Object.hasOwn. A value set keyed on
__proto__ was already rejected by the value-name pattern.
The bad build now exits 1 with the intended AxisPluginError.
Also bumps ruff-pre-commit v0.3.0 -> v0.16.0 to match the ruff CI resolves
from `dev`. The two disagreed about `assert x, (msg)` wrapping as well as
first-party imports, and reverted each other on every run; the repo was
already formatted to 0.16, so only the two new test files moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
Adversarial review — PR #10Run against the PR diff. Findings came from executing the plugin inside a real 1. The build error was silently inert on the idiomatic v4 path — FIXEDTailwind only forwards options to a plugin marked The consequence is worse than a rejected option. A CSS-first consumer could not pass a composition at all, so the plugin only ever validated the default composition, which always passes. The one guarantee this PR ships would have been a no-op for anyone using the idiomatic v4 setup, while appearing to work — the CSS still generated, the build still succeeded. That is precisely the silent-success failure #7 exists to eliminate, reintroduced one level up. Fix: set
Why the tests missed it: every JS test called the factory directly, which always worked. Nothing exercised the seam where Tailwind decides how to call the plugin. Now asserted directly on the marker Tailwind reads. 2. Any
|
…ework-ui-phase-7-tailwind-plugin # Conflicts: # CHANGELOG.md # src/cf_ui/templates/cotton/cf/checkbox-group.html
Closes #7.
Ships a Tailwind plugin that fails the CSS build on an unknown axis value, and generates the axis CSS and its wide-gamut layer from the same definition
axes.pyalready owns.The two decisions #7 asked for, answered
Distribution: vendored. The plugin ships inside the wheel at
cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs, besidecf_ui_alpine.js. Not npm, not git.The reasoning is version coupling. The plugin's only job is to enforce a contract defined in
cf_ui/axes.py. An npm package introduces a second version number that can disagree with the installed wheel, and the failure mode of that disagreement is exactly the one the plugin exists to prevent: a value that validates against one definition and not the other. One artifact, one version, one definition. The cost is an uglier import path, and that trade is documented rather than hidden.It consumes
axes.py, it does not replace it. #7 asks for "a single value-set definition"; #5 already madeaxes.pythat, with a drift test on the generated CSS. Sopython -m cf_ui.axesnow emitscf_ui_axes.jsonalongsidecf_ui_axes.css, and the plugin reads the JSON. The.mjscarries no copy of the value sets. Two generators for one artifact is the drift #7 exists to prevent.Scope — against the issue's checklist
axis_definition()exports the value sets, compositions, axis attributes, Tailwind aliases, value-name pattern and contrast thresholds as JSON. The plugin'sbuildAxisBase()/buildAxisCss()generate from it.AxisPluginError, thrown from the plugin factory — before Tailwind ever invokes the handler, so the error lands at the point of misconfiguration. Also covers unknown axis names, unknown named compositions, invalid value names, mode-keyed values missinglight/dark, and non-custom-property tokens.@media (color-gamut: p3)layer from the same definition. Same traversal as the base rules; a value with nop3block contributes nothing.contrastReport: truewarns with every accent × surface × mode and its WCAG numbers. Off by default.valueSets+valueSetsMode, mirroringmerge_value_sets'extend/replacesemantics.One definition, proven rather than asserted
The JSON export only guarantees both sides read the same input.
tests/unit/test_tailwind_plugin.pycompares their output: it runsrender_axis_cssand the plugin, parses both, and asserts equality declaration by declaration — every selector, custom property, value, and the p3 layer. I mutation-tested it (added a marker to one generated alias) and confirmed it fails:A separate drift test pins the committed JSON to
axis_definition(), so editingaxes.pywithout regenerating is caught too.Contrast warns, it does not fail
Deliberate. An unknown axis value is unambiguously a mistake. A sub-AA ratio may be a considered choice in a value set the package did not ship, and making it fatal would mean cf-ui decides when a consuming app is allowed to compile. The report exists so nobody can say they never saw the numbers.
Testing
New
tests/js/tier onnode --test— 35 tests. It runs as its own CI step, not only through the pytest wrapper: that wrapper skips when node is absent, and a skip is not a pass.test_the_node_suite_is_not_emptyadditionally asserts a pass count, so a suite that silently stopped collecting fails.Two things worth flagging
Beyond the literal checklist, and why. The plugin rejects a token value containing
;,{,}, or a comment delimiter. Consumer value sets are interpolated straight into generated CSS, so such a value closes its own declaration and writes rules the app never authored. This is the generator introduced by this PR, so guarding its own input is in scope — but noteaxes.pyhas the same gap on the Python side. That is the still-open injection finding from #5's review; I have not touched it here, since changing existing validation could break current consumers and belongs in its own ticket.Two incidental changes.
pyproject.tomlgainsknown-first-party = ["cf_ui"]— the pinned ruff hook (v0.3.0) and the ruff CI resolves (0.16.x) classifycf_uidifferently for asrc/layout and were reverting each other, soprekcould pass while CI failed I001. Same root fix asbbd0ea2on the #6 branch; expect a trivial conflict when both land.checkbox-group.htmlgains a trailing newline, required by theend-of-file-fixerhook and pre-existing on master.Branching. Off
master, not off #6's branch — #7 depends on #5 (merged) and is only related to #6, so this is independent of PR #9.CHANGELOG.mdwill conflict trivially with #9's Unreleased entry.🤖 Generated with Claude Code
https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf