Skip to content

feat(axes): Tailwind plugin with build-time axis validation (#7) - #10

Merged
fsecada01 merged 3 commits into
masterfrom
feat/component-framework-ui-phase-7-tailwind-plugin
Jul 29, 2026
Merged

feat(axes): Tailwind plugin with build-time axis validation (#7)#10
fsecada01 merged 3 commits into
masterfrom
feat/component-framework-ui-phase-7-tailwind-plugin

Conversation

@fsecada01

Copy link
Copy Markdown
Owner

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.py already 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, beside cf_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 made axes.py that, with a drift test on the generated CSS. So python -m cf_ui.axes now emits cf_ui_axes.json alongside cf_ui_axes.css, and the plugin reads the JSON. The .mjs carries no copy of the value sets. Two generators for one artifact is the drift #7 exists to prevent.

Scope — against the issue's checklist

  • Generate the axis CSS from a single value-set definition, replacing hand-maintained CSS. axis_definition() exports the value sets, compositions, axis attributes, Tailwind aliases, value-name pattern and contrast thresholds as JSON. The plugin's buildAxisBase() / buildAxisCss() generate from it.
  • Fail the build on an unknown axis value. 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 missing light/dark, and non-custom-property tokens.
  • Generate the @media (color-gamut: p3) layer from the same definition. Same traversal as the base rules; a value with no p3 block contributes nothing.
  • Optionally emit a contrast report. contrastReport: true warns with every accent × surface × mode and its WCAG numbers. Off by default.
  • Accept a consuming app's own value sets, per Theme composition axes — pluggable token layer #5. valueSets + valueSetsMode, mirroring merge_value_sets' extend / replace semantics.

One definition, proven rather than asserted

The JSON export only guarantees both sides read the same input. tests/unit/test_tailwind_plugin.py compares their output: it runs render_axis_css and 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:

FAILED test_the_plugin_and_axes_py_emit_the_same_base_rules
FAILED test_the_plugin_and_axes_py_emit_the_same_p3_layer

A separate drift test pins the committed JSON to axis_definition(), so editing axes.py without 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 on node --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_empty additionally asserts a pass count, so a suite that silently stopped collecting fails.

pytest tests/unit tests/integration   221 passed
pytest tests/e2e --browser chromium    17 passed, 1 skipped
node --test tests/js/**/*.test.mjs     35 passed
ruff check src tests                   All checks passed
ruff format --check src tests          56 files already formatted
prek run --all-files                   all hooks passed (idempotent on a second run)

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 note axes.py has 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.toml gains known-first-party = ["cf_ui"] — the pinned ruff hook (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 bbd0ea2 on the #6 branch; expect a trivial conflict when both land. checkbox-group.html gains a trailing newline, required by the end-of-file-fixer hook 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.md will conflict trivially with #9's Unreleased entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf

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
@fsecada01 fsecada01 added the enhancement New feature or request label Jul 29, 2026
…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
@fsecada01

Copy link
Copy Markdown
Owner Author

Adversarial review — PR #10

Run against the PR diff. Findings came from executing the plugin inside a real tailwindcss v4.3.3 build (npm i tailwindcss @tailwindcss/cli, @tailwindcss/cli -i in.css -o out.css), not from reading the code. Two real defects found and fixed in d0aa016 — both had passed the original 35-test JS suite, which is the useful part of the result.


1. The build error was silently inert on the idiomatic v4 path — FIXED

@plugin ".../cf_ui_tailwind_plugin.mjs" { composition: brutalist; }
Error: The plugin ".../cf_ui_tailwind_plugin.mjs" does not accept options

Tailwind only forwards options to a plugin marked __isOptionsFunction — the flag plugin.withOptions sets and this hand-built export did not.

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 __isOptionsFunction. Verified across all three integration paths against v4.3.3:

Path Before After
bare @plugin builds, 10 data-accent rules unchanged
@plugin { composition: console; } "does not accept options" builds
@plugin { composition: brutalist; } (unreachable) exit 1, AxisPluginError: unknown composition 'brutalist'

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 Object.prototype key bypassed validation — FIXED

Membership was tested with in, which walks the prototype chain:

cfUiAxes({ composition: { form: "toString" } })   // accepted
cfUiAxes({ composition: "hasOwnProperty" })       // accepted

"toString" in {} is true, so the value passed the check, then blockFor spread a function into {} and emitted [data-form="toString"] {} — an empty rule, no error, no styling. A hole in the exact guarantee this phase adds, reachable through toString, valueOf, constructor, hasOwnProperty, and friends.

Fix: Object.hasOwn at all five membership checks (resolveComposition ×2, validateValueSet, blockFor, contrastReport). Confirmed against a real build — composition: hasOwnProperty now exits 1.

A value set keyed on __proto__ was already rejected by the value-name pattern; I confirmed that with JSON.parse rather than an object literal, since __proto__: in a literal sets the prototype instead of creating an own key. Regression tests added for all three.

3. @plugin options are flat — documented, not a defect

Tailwind's CSS option syntax takes key/value pairs, so composition: { accent: "brand" } and custom valueSets cannot be expressed there and need the JS-config path. Correct behavior, but it was implied rather than stated; now called out in docs/tailwind-plugin.md.


Verified, not assumed

  • The plugin loads and runs in a real Tailwind v4.3.3 build. Output contains 10 data-accent rules, the --color-primary: var(--cf-accent) aliases, and the @media (color-gamut: p3) layer.
  • The unknown-value throw actually fails the build. Exit code 1, checked directly rather than through a pipe (tail was masking it in my first attempt).
  • Cross-language parity is load-bearing. I added a marker to one generated alias in the plugin; test_the_plugin_and_axes_py_emit_the_same_base_rules and ..._p3_layer both failed, then passed again on revert. The comparison is not vacuous.
  • buildAxisCss emits balanced braces (24/24) including the nested p3 layer.
  • loadDefinition() returns independent copies — mutating one call's result does not affect the next.
  • contrastReport survives malformed input — a value missing its mode key yields "token not declared" rather than throwing.
  • Black-on-white computes 21:1, so the report's arithmetic is not decorative.

Open, not blocking

  • buildAxisBase and buildAxisCss are exported and do not validate. Only the plugin path does. A consumer calling them directly gets no build error. Reasonable — they are escape hatches — but the asymmetry is undocumented.
  • axes.py still lacks the token-value injection guard the plugin now has. That is the open finding from Theme composition axes — pluggable token layer #5's review; deliberately untouched here, since changing existing validation could break current consumers.
  • The contrast report cannot check oklch() p3 values — it returns "not an sRGB hex value, cannot be checked" rather than failing. Intentional (the base sRGB declaration is what carries the contrast guarantee), but it means a p3-only regression is invisible to the report.
  • Real-Tailwind integration is not in CI. It would require npm i tailwindcss on every run. The __isOptionsFunction regression test asserts the property Tailwind reads, which is a proxy — a future Tailwind release changing that contract would not be caught here.
  • checkbox-group.html trailing newline and the ruff pin bump are incidental to Tailwind v4 plugin — build-time axis validation #7; both were required to get the repo's own hooks green.

Gates

pytest tests/unit tests/integration   221 passed
pytest tests/e2e --browser chromium    17 passed, 1 skipped
node --test tests/js/**/*.test.mjs     39 passed
ruff check src tests                   All checks passed
ruff format --check src tests          56 files already formatted
prek run --all-files                   all hooks passed (idempotent on a second run)

prek passing is now actually evidence. The hook was pinned at ruff v0.3.0 while CI resolves 0.16.x, and the two were reverting each other's fixes — first on imports (fixed by known-first-party), then on assert x, (msg) wrapping. d0aa016 bumps the pin to v0.16.0 so both run the same ruff; the repo was already formatted to 0.16, so only the two new test files moved.

@fsecada01 fsecada01 self-assigned this Jul 29, 2026
…ework-ui-phase-7-tailwind-plugin

# Conflicts:
#	CHANGELOG.md
#	src/cf_ui/templates/cotton/cf/checkbox-group.html
@fsecada01
fsecada01 merged commit 1899b83 into master Jul 29, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tailwind v4 plugin — build-time axis validation

1 participant