fix(axes): enforce every guarantee the axis layer states (#20) - #26
Conversation
Four findings from the reviews of #5 and #7, consolidated because they touch the same two files and the cross-language parity test binds them together. They are the same mistake four times: a property the package states and does not enforce. Token values are now validated, not just token names. A value containing ';', '{', '}', '<', or a comment delimiter closed its own declaration and wrote rules the app never authored; '</style>' escaped the style element entirely. That mattered most for the deployments cf-ui targets, where value sets come from a database column or an admin form. The plugin already rejected these — the two generators now agree, and a test asserts it. The --cf-spacing -> --spacing alias is dropped. --color-primary* is a name cf-ui owns in a Tailwind context; --spacing is the root of Tailwind v4's entire spacing scale, so data-density was silently rescaling every p-4 and gap-2 in the consuming app by +-20%, including for Bulma consumers using none of it. Opt back in with @theme { --spacing: var(--cf-spacing); }. The wide-gamut lightness invariant is enforced rather than assumed. The contrast gate is computed against the sRGB base and cannot measure oklch(); "the p3 layer holds the same lightness" was the convention carrying that result to wide-gamut displays, held by hand. A typo or a monitor-driven tweak shipped below AA with every test green, invisible to anyone reviewing on an sRGB display. p3_lightness_failures() / p3LightnessFailures() convert each base to OKLab and fail CI beyond 0.5 percentage points — a tolerance set from the shipped data, which sits within 0.05pp. buildAxisBase and buildAxisCss validate by default. They were exported escape hatches that generated CSS with the build error switched off. The opt-out is now explicit, and the parity test passes it deliberately so it still compares raw generation rather than narrowing to input the validator already approved. Also adds .gitattributes pinning the generated artifacts to LF: the generator writes newline="\n" while autocrlf wants CRLF, so regenerating left a phantom zero-line diff and "is the tree clean" meant nothing. BREAKING: token-value validation, the dropped alias, and validating exports are each breaking for anyone relying on the gaps. Migration notes in CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
Adversarial review — PR #26Scope: 11 files, +924/−27. Four consolidated findings (#11, #12, #13, #16) plus a OverviewThe PR does what it says. The four items really are one mistake repeated, and grouping them was the right call — they touch the same two functions in What is good here, specifically:
Three findings below. None is a blocker on its own; the first is the one I would want addressed. 1. The Python generators still emit unvalidated CSS — the exact asymmetry §4 just closed on the JS sideConfirmed by execution, on this branch: >>> from cf_ui.axes import style_element
>>> style_element({'form': {'evil': {'--cf-radius': '0; } body { display: none'}}})
'<style>\n... [data-form="evil"] {\n --cf-radius: 0; } body { display: none;\n}\n</style>'§4's whole argument is that an exported function which generates CSS with the build error switched off contradicts the guarantee the package ships on. That argument was applied to
So after this PR the two generators disagree about the thing the PR is about: JS validates at the generator by default, Python validates only at Severity is bounded, and the PR is not wrong to have shipped: every documented path merges first. I traced them — Suggestion: give the Python side the same treatment — 2.
|
1. The Python generators emitted unvalidated CSS — the same asymmetry §4
closed on the JS side, left open on this one. style_element() is the sink
the token-value gate exists to protect, and calling it directly rendered
an injection payload verbatim:
style_element({'form': {'evil': {'--cf-radius': '0; } body { x: y'}}})
-> '<style>... --cf-radius: 0; } body { x: y; ...</style>'
render_axis_css, custom_axis_css, and style_element now validate by
default and take an explicit validate=False, matching buildAxisBase and
buildAxisCss. The per-axis checks merge_value_sets did inline are
extracted to _validate_value_sets, mirroring validateSets in the plugin,
so there is one implementation rather than two that can drift.
Not exploitable through any documented path — axis_value_sets() and
build_axis_globals() both route through merge_value_sets. This was a
latent sharp edge on a public API, and it made the two generators
disagree about the thing this PR is about.
The parity test now passes validate=False on the Python side too, so both
halves skip the gate and the comparison stays symmetric.
2. CHANGELOG contradicted itself inside [Unreleased]: the #5 notes still
said density drives Tailwind's --spacing while the new Changed block said
the alias was gone. Both would have shipped in the same release notes.
The alias never reached a tagged version, so the line is amended rather
than left to argue with itself.
3. test_regenerating_the_artifacts_is_a_no_op could not fail for the reason
its name gives. read_text() applies universal-newline translation, so a
CRLF file on disk — the exact state .gitattributes prevents — compared
equal. Switched to read_bytes() with an explicit CRLF assertion, and
mutation-tested: writing CRLF to cf_ui_axes.css now fails the test, where
before it passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
All three findings fixed —
|
| Gate | Result |
|---|---|
pytest tests/unit tests/integration |
462 passed (was 453) |
node --test tests/js/**/*.test.mjs |
65 passed, 0 failed |
pytest tests/e2e --browser chromium |
42 passed, 2 skipped |
ruff check src tests / format --check |
passed (CI's exact scope) |
prek run --all-files |
passed twice, idempotent |
python -m cf_ui.axes on a clean tree |
no diff |
The minor notes from the review (cross-language float formatting, validateSets duplication, double validation in createPlugin, the unreachable negative-cube-root branch) were left alone as flagged — none is actionable without costing more clarity than it buys.
Closes #20. Phase 1 of epic #19.
Four findings from the reviews of #5 and #7. They were consolidated into one phase because they touch the same two files —
axes.pyandcf_ui_tailwind_plugin.mjs— and the cross-language parity test binds them together; splitting them meant three rounds of conflicts in the same functions.They are also the same mistake four times: the axis layer stated properties it did not enforce.
§1 — Validate axis token values (was #11, security)
_validate_tokenschecked that a token name started with--and nothing about the value._blockemittedf"{name}: {value};"verbatim, andstyle_element()wraps that in a<style>element injected into the page.;,{,},/*,*/, or<in_validate_tokensUNSAFE_VALUEso the two generators agree, with a test asserting they reject the same inputs--cf-font-displaycarries commas and quotes and survivesCHANGELOG.mdnotes it as a breaking changeTwo things beyond the bare regex, both needed for "the two generators agree" to be true rather than approximate:
<was added to the plugin too. The original JS rule was/[;{}]|\/\*|\*\//.<only matters on the Python side (</style>escapes the element), but the parity test compares rejection sets, so both carry the identical rule.The p3 block now goes through the same gate — it reaches the same stylesheet and was previously unvalidated on the Python side.
§2 — Drop the
--spacingalias (was #12)Decided in the epic: dropped, not made theme-aware.
"--cf-spacing": "--spacing"from_ALIASESaliasesfrom the JSON, so it followed automatically and the parity test agreesdocs/theming.mdCHANGELOG.mdbreaking note with the migration--color-primaryis a name cf-ui effectively owns in a Tailwind context.--spacingis the root of Tailwind v4's entire spacing scale, sodata-density="compact"was rescaling everyp-4,gap-2, andm-8in the consuming app by ±20% — and a Bulma consumer who also ran Tailwind got that with no cf-ui component using it.test_density_drives_tailwind_spacing_base_unitencoded the old behaviour and is inverted, not deleted — the axis token itself must still be emitted.§3 — Enforce the p3 lightness invariant (was #13)
Decided in the epic: assert the lightness invariant rather than run full WCAG over oklch.
oklab_lightness()/oklch_lightness()in Python,oklabLightness()/oklchLightness()in the pluginp3_lightness_failures()/p3LightnessFailures()assert each override matches its base's L within tolerancetest_shipped_p3_overrides_hold_the_lightness_invariant)warnContrastnow has awarnP3companion undercontrastReport: truedocs/theming.mdupdated to state what is enforcedThe tolerance is set from data, not guessed. I measured every shipped override against its base before writing the test:
The worst deviation is pure rounding from one-decimal authoring.
P3_LIGHTNESS_TOLERANCE = 0.005(0.5pp) is ~10× that — loose enough not to be brittle, tight enough that a meaningful error (which moves several points) cannot pass. A test asserts the rounding case stays legal so the gate does not get tightened into flakiness later.Failures are collected, not raised, for four distinct causes: drift beyond tolerance, an override of a token the base never declared, a p3 value that is not
oklch(), and a base that is not sRGB hex. The last two are flagged rather than skipped — silently skipping unparseable values is how the gate went blind in the first place.§4 —
buildAxisBase/buildAxisCssvalidate (was #16)Took option 1 from the original ticket: validate by default, opt out explicitly.
buildAxisBase(valueSets, definition, { validate: false })docs/tailwind-plugin.md, not just the plugin factoryOn the parity test specifically: passing
{ validate: false }there is deliberate. Comparing only validated input would quietly narrow the test from "the two generators agree" to "they agree on input the validator already approved" — and the unvalidated path is exactly the one a consumer reaches through the exported function.§5 — Generated artifacts stop re-dirtying the tree
.gitattributespinscf_ui_axes.css,cf_ui_axes.json, and the plugin to LFpython -m cf_ui.axeson the committed tree leavesgit statuscleanVerification
pytest tests/unit tests/integrationnode --test tests/js/**/*.test.mjspytest tests/e2e --browser chromiumruff check src testsruff format --check src testsprek run --all-filespython -m cf_ui.axeson a clean treeThe JS suite grew 39 → 65. Cross-language parity now covers three properties, not one: identical emitted rules, identical rejection of unsafe values (and identical acceptance of legitimate ones, so the rule cannot degenerate into rejecting everything), and byte-identical p3 failure messages.
Follow-up, not in this diff
Running
ruff format .unscoped reformats Python snippets inside markdown, and one such rewrite is wrong — it turns anINSTALLED_APPSline inprompts/migrate-django.mdinto a tuple:Neither CI (
ruff format --check src tests) norjust formatnor the prek hooks touch those files, so nothing is broken today — it is a trap for anyone who runs ruff unscoped. I hit it during this phase and reverted the three affected files rather than folding them in. Worth adocs/promptsexclusion in[tool.ruff], as its own ticket.