From a606a99e9388c5f5a0d161bab685d93cfe98ec0a Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Wed, 29 Jul 2026 10:04:44 -0400 Subject: [PATCH 1/2] feat(axes): Tailwind plugin with build-time axis validation (#7) 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 Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf --- .github/workflows/ci.yml | 8 + CHANGELOG.md | 23 + CLAUDE.md | 20 +- README.md | 20 + docs/tailwind-plugin.md | 187 ++++++++ justfile | 9 + pyproject.toml | 8 + src/cf_ui/axes.py | 60 ++- src/cf_ui/static/cf_ui/cf_ui_axes.json | 239 ++++++++++ .../static/cf_ui/cf_ui_tailwind_plugin.mjs | 411 ++++++++++++++++++ .../templates/cotton/cf/checkbox-group.html | 2 +- tests/js/plugin.test.mjs | 335 ++++++++++++++ tests/unit/test_axis_definition.py | 117 +++++ tests/unit/test_tailwind_plugin.py | 187 ++++++++ 14 files changed, 1619 insertions(+), 7 deletions(-) create mode 100644 docs/tailwind-plugin.md create mode 100644 src/cf_ui/static/cf_ui/cf_ui_axes.json create mode 100644 src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs create mode 100644 tests/js/plugin.test.mjs create mode 100644 tests/unit/test_axis_definition.py create mode 100644 tests/unit/test_tailwind_plugin.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c39d986..cbe55b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,14 @@ jobs: - name: Format check run: ruff format --check src tests + # Run directly, not only through the pytest wrapper: that wrapper skips + # when node is absent, and a skip is not a pass. This step has no such + # escape hatch, so the plugin's own suite is always exercised in CI. + - name: Tailwind plugin tests (node) + run: | + node --version + node --test --test-reporter=spec "tests/js/**/*.test.mjs" + - name: Unit tests run: pytest tests/unit/ -q --tb=short diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fb5c7..9ad527d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ ## [Unreleased] ### Added +- Tailwind plugin with build-time axis validation (#7): an unknown axis value now + **fails the CSS build** instead of silently producing an unstyled element +- `static/cf_ui/cf_ui_tailwind_plugin.mjs` — vendored into the wheel, not published + to npm, so its version can never skew from the definition it enforces. Imports + nothing but Node builtins and exports Tailwind's `{ handler, config }` shape, + usable via `@plugin` or a JS config +- `cf_ui.axes.axis_definition()` and `static/cf_ui/cf_ui_axes.json` — the axis + definition as data, so the plugin can read it from inside a CSS build where + Python is not. `python -m cf_ui.axes` now writes both generated files +- The `@media (color-gamut: p3)` layer is generated by the plugin from the same + definition rather than kept in sync by hand +- Optional contrast report (`contrastReport: true`) over every accent × surface × + mode; warns rather than failing, so cf-ui does not decide when a consuming app + may compile +- Consumer value sets accepted by the plugin with the same `extend` / `replace` + semantics as `merge_value_sets` (#5) +- `tests/js/` — a `node --test` tier, run directly in CI and wrapped by pytest +- `docs/tailwind-plugin.md`; `just test-js` and `just axes` - Theme composition axes (#5): five orthogonal style axes — accent, surface, form, density, type — each keyed on a data attribute and carrying a closed set of named values. `data-theme` remains the light/dark switch and is not an axis. @@ -19,6 +37,11 @@ - `docs/theming.md` — axis reference, custom value sets, and the contrast requirement ### Technical Notes +- `axes.py` remains the single source of truth. The plugin holds no copy of the + value sets, and a parity test compares both generators' output declaration by + declaration — selectors, custom properties, values, and the p3 layer +- Token values containing `;`, `{`, `}`, or a comment delimiter are rejected by the + plugin: they close their own declaration and write rules the app never authored - Light and dark are declared independently per value, never derived by inversion; light is the unqualified selector, dark is `[data-theme="dark"][data-*="..."]` - Base declarations are sRGB hex (what the contrast gate is computed against); diff --git a/CLAUDE.md b/CLAUDE.md index c7aea90..c0ea350 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,8 @@ uv pip install -e ".[dev]" # install with all dev deps playwright install chromium # install E2E browser just test # unit tests only +just test-js # Tailwind plugin suite (node --test) +just axes # regenerate cf_ui_axes.css + cf_ui_axes.json just test-integration # integration tests (real HTTP) just test-e2e # E2E Playwright (requires chromium) just test-all # full suite @@ -44,7 +46,10 @@ src/cf_ui/ │ ├── jinja/bulma/ # 14 JinjaX component templates (*.jinja) │ └── cotton/bulma/cf/ # 14 django-cotton component templates (*.html) └── static/cf_ui/ - └── cf_ui_alpine.js # Alpine named components + $cf global store + ├── cf_ui_alpine.js # Alpine named components + $cf global store + ├── cf_ui_axes.css # GENERATED from axes.py + ├── cf_ui_axes.json # GENERATED from axes.py — read by the plugin below + └── cf_ui_tailwind_plugin.mjs # Tailwind plugin: build-time axis validation ``` Templates live **inside** the Python package so hatchling includes them automatically. @@ -66,6 +71,19 @@ Templates live **inside** the Python package so hatchling includes them automati **Django AppConfig:** - Register as `"cf_ui.django.CfUiConfig"` (full class path), NOT `"cf_ui.django"` — `default_app_config` is removed in Django 4.2+ +**Theme axes / Tailwind plugin:** +- `axes.py` is the single source of truth; `cf_ui_axes.css` **and** `cf_ui_axes.json` + are build products of it. Edit `axes.py`, run `just axes`, commit both — drift + tests fail otherwise +- The plugin holds no copy of the value sets, and a parity test compares its output + against `render_axis_css` declaration by declaration. Never add value data to the + `.mjs` +- The `.mjs` is vendored, so it may import **only** Node builtins — a bare + `tailwindcss/plugin` import will not resolve from site-packages. It exports + Tailwind's `{ handler, config }` shape by hand instead +- `node --test` runs as its own CI step because the pytest wrapper skips when node + is absent, and a skip is not a pass + **Alpine.js:** - `cf_ui_alpine.js` must load BEFORE the Alpine CDN (both use `defer` — DOM order guarantees execution sequence) - Modal control uses `cf-modal-open` / `cf-modal-close` custom events dispatched to the element by ID — NOT `_x_dataStack` (private API) diff --git a/README.md b/README.md index 5f05792..eb7dc32 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,26 @@ Apps supply their own value sets via `CF_UI_AXIS_VALUES` (`value_sets=` for Jinj → Full guide: [`docs/theming.md`](docs/theming.md) +### Build-time validation (Tailwind) + +The closed value sets are only a promise until something enforces them. +`data-accent="hotpink"` produces no error on its own — it produces an element +with no accent. cf-ui ships a Tailwind plugin that **fails the build** on an +unknown axis value, generates the axis CSS and its `@media (color-gamut: p3)` +layer from the same definition, and can warn with WCAG numbers for every +accent × surface × mode. + +```css +@import "tailwindcss"; +@plugin "../.venv/lib/python3.12/site-packages/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs"; +``` + +The plugin is **vendored in the wheel, not published to npm** — a separate npm +version could disagree with the installed package about what a valid value is, +which is the exact failure it exists to prevent. + +→ Full guide: [`docs/tailwind-plugin.md`](docs/tailwind-plugin.md) + --- ## Alpine.js Integration diff --git a/docs/tailwind-plugin.md b/docs/tailwind-plugin.md new file mode 100644 index 0000000..0fd9de8 --- /dev/null +++ b/docs/tailwind-plugin.md @@ -0,0 +1,187 @@ +# 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: + +```css +@import "tailwindcss"; +@plugin "../.venv/lib/python3.12/site-packages/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs"; +``` + +Tailwind with a JS config, when you need options: + +```js +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: + +```bash +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](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 + +**Warns** — `contrastReport: true`: + +- a combination below WCAG AA + +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 +``` + +## 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: + +```bash +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 + +```js +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: + +```js +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 + +```bash +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. diff --git a/justfile b/justfile index 1e1940f..d7f86eb 100644 --- a/justfile +++ b/justfile @@ -18,6 +18,15 @@ lint-fix: test: pytest tests/unit -q --tb=short +# The Tailwind plugin's own suite. `just test` runs it too, via a pytest +# wrapper that skips when node is missing; this recipe never skips. +test-js: + node --test --test-reporter=spec "tests/js/**/*.test.mjs" + +# Rebuild cf_ui_axes.css and cf_ui_axes.json from axes.py. +axes: + python -m cf_ui.axes + test-integration: pytest tests/integration -q --tb=short diff --git a/pyproject.toml b/pyproject.toml index 0e5e41c..29e0fdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,14 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] +[tool.ruff.lint.isort] +# Stated explicitly because the pinned pre-commit hook (ruff v0.3.0) and the +# ruff CI resolves from `dev` (0.16.x) infer this differently for a src/ +# layout: the older version treats `cf_ui` as third-party, the newer as +# first-party, so each reverts the other's import fix and `prek` can pass +# while CI fails I001 on the same file. +known-first-party = ["cf_ui"] + [tool.ruff.format] quote-style = "double" indent-style = "space" diff --git a/src/cf_ui/axes.py b/src/cf_ui/axes.py index 0f95164..0ae4847 100644 --- a/src/cf_ui/axes.py +++ b/src/cf_ui/axes.py @@ -12,11 +12,14 @@ ``data-theme`` stays the light/dark mode switch and is deliberately **not** an axis: mode and identity are separate concerns. -This module is the single source of truth. ``static/cf_ui/cf_ui_axes.css`` is -generated from it — regenerate with:: +This module is the single source of truth. Both ``static/cf_ui/cf_ui_axes.css`` +and ``static/cf_ui/cf_ui_axes.json`` are generated from it — regenerate with:: python -m cf_ui.axes +The JSON is what the Tailwind plugin reads; see :func:`axis_definition`. There +is deliberately no second generator on the JavaScript side. + The shipped value set is deliberately neutral. A UI kit that hardcodes one organization's brand color is a UI kit nobody else adopts; consuming apps inject their own sets via :func:`merge_value_sets`. @@ -24,6 +27,7 @@ from __future__ import annotations +import json import re from collections.abc import Mapping from copy import deepcopy @@ -34,11 +38,13 @@ "AXES", "AXIS_ATTRS", "AXIS_CSS_PATH", + "AXIS_DEFINITION_PATH", "DEFAULT_COMPOSITIONS", "DEFAULT_VALUE_SETS", "MODES", "MODE_KEYED_AXES", "AxisConfigError", + "axis_definition", "build_axis_globals", "contrast_failures", "contrast_ratio", @@ -67,6 +73,12 @@ class AxisConfigError(ValueError): AXIS_CSS_PATH = Path(__file__).parent / "static" / "cf_ui" / "cf_ui_axes.css" +#: The same definition, as data. The Tailwind plugin runs inside the CSS build +#: where Python is not, so it cannot import this module — it reads this file +#: instead. Generated by the same command as the stylesheet, and sitting beside +#: the plugin that consumes it. +AXIS_DEFINITION_PATH = Path(__file__).parent / "static" / "cf_ui" / "cf_ui_axes.json" + #: Tailwind v4 reads its theme from custom properties in these namespaces, so #: emitting the aliases lets a Tailwind-based theme pick the axes up without #: per-component work. Harmless when no Tailwind is present. @@ -596,11 +608,49 @@ def contrast_failures( return failures -def _regenerate() -> Path: +# --------------------------------------------------------------------------- +# Cross-language export +# --------------------------------------------------------------------------- + + +def axis_definition() -> dict[str, Any]: + """Export the axis definition as plain JSON-serializable data. + + The Tailwind plugin has to validate axis values and generate the axis CSS + from the *same* definition this module renders ``cf_ui_axes.css`` from, + but it runs inside the CSS build, where Python is not. Rather than keeping + a second copy of the value sets in JavaScript, the plugin reads this. + + Keys are camelCase because the only consumer is JavaScript. + + Returns: + A deep copy — callers may mutate the result freely. + """ + return { + "axes": list(AXES), + "modeKeyedAxes": list(MODE_KEYED_AXES), + "modes": list(MODES), + "axisAttrs": dict(AXIS_ATTRS), + "aliases": dict(_ALIASES), + "valueNamePattern": _VALUE_NAME.pattern, + "valueSets": deepcopy(DEFAULT_VALUE_SETS), + "compositions": deepcopy(DEFAULT_COMPOSITIONS), + "contrastPairs": [ + {"foreground": foreground, "background": background, "minimum": minimum} + for foreground, background, minimum in _CONTRAST_PAIRS + ], + } + + +def _regenerate() -> list[Path]: AXIS_CSS_PATH.parent.mkdir(parents=True, exist_ok=True) AXIS_CSS_PATH.write_text(render_axis_css(DEFAULT_VALUE_SETS), encoding="utf-8", newline="\n") - return AXIS_CSS_PATH + AXIS_DEFINITION_PATH.write_text( + json.dumps(axis_definition(), indent=2) + "\n", encoding="utf-8", newline="\n" + ) + return [AXIS_CSS_PATH, AXIS_DEFINITION_PATH] if __name__ == "__main__": # pragma: no cover - print(f"wrote {_regenerate()}") + for path in _regenerate(): + print(f"wrote {path}") diff --git a/src/cf_ui/static/cf_ui/cf_ui_axes.json b/src/cf_ui/static/cf_ui/cf_ui_axes.json new file mode 100644 index 0000000..d46e71f --- /dev/null +++ b/src/cf_ui/static/cf_ui/cf_ui_axes.json @@ -0,0 +1,239 @@ +{ + "axes": [ + "accent", + "surface", + "form", + "density", + "type" + ], + "modeKeyedAxes": [ + "accent", + "surface" + ], + "modes": [ + "light", + "dark" + ], + "axisAttrs": { + "accent": "data-accent", + "surface": "data-surface", + "form": "data-form", + "density": "data-density", + "type": "data-type" + }, + "aliases": { + "--cf-accent": "--color-primary", + "--cf-accent-content": "--color-primary-content", + "--cf-accent-strong": "--color-primary-strong", + "--cf-spacing": "--spacing" + }, + "valueNamePattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "valueSets": { + "accent": { + "slate": { + "light": { + "--cf-accent": "#475569", + "--cf-accent-content": "#ffffff", + "--cf-accent-strong": "#334155" + }, + "dark": { + "--cf-accent": "#94a3b8", + "--cf-accent-content": "#0f172a", + "--cf-accent-strong": "#cbd5e1" + } + }, + "azure": { + "light": { + "--cf-accent": "#0369a1", + "--cf-accent-content": "#ffffff", + "--cf-accent-strong": "#075985" + }, + "dark": { + "--cf-accent": "#38bdf8", + "--cf-accent-content": "#082f49", + "--cf-accent-strong": "#7dd3fc" + }, + "p3": { + "light": { + "--cf-accent": "oklch(50.0% 0.137 242.7)", + "--cf-accent-strong": "oklch(44.3% 0.115 240.8)" + }, + "dark": { + "--cf-accent": "oklch(75.4% 0.160 232.7)", + "--cf-accent-strong": "oklch(82.8% 0.116 230.3)" + } + } + }, + "jade": { + "light": { + "--cf-accent": "#047857", + "--cf-accent-content": "#ffffff", + "--cf-accent-strong": "#065f46" + }, + "dark": { + "--cf-accent": "#34d399", + "--cf-accent-content": "#022c22", + "--cf-accent-strong": "#6ee7b7" + }, + "p3": { + "light": { + "--cf-accent": "oklch(50.8% 0.121 165.6)", + "--cf-accent-strong": "oklch(43.2% 0.099 166.9)" + }, + "dark": { + "--cf-accent": "oklch(77.3% 0.177 163.2)", + "--cf-accent-strong": "oklch(84.5% 0.149 165.0)" + } + } + } + }, + "surface": { + "plain": { + "light": { + "--cf-ground": "#ffffff", + "--cf-lifted": "#f8fafc", + "--cf-text": "#0f172a", + "--cf-text-muted": "#475569", + "--cf-border": "#e2e8f0" + }, + "dark": { + "--cf-ground": "#0b1120", + "--cf-lifted": "#172033", + "--cf-text": "#f1f5f9", + "--cf-text-muted": "#94a3b8", + "--cf-border": "#1e293b" + } + }, + "muted": { + "light": { + "--cf-ground": "#f1f5f9", + "--cf-lifted": "#ffffff", + "--cf-text": "#111827", + "--cf-text-muted": "#475569", + "--cf-border": "#cbd5e1" + }, + "dark": { + "--cf-ground": "#111827", + "--cf-lifted": "#1f2937", + "--cf-text": "#f9fafb", + "--cf-text-muted": "#9ca3af", + "--cf-border": "#374151" + } + } + }, + "form": { + "sharp": { + "--cf-radius": "0", + "--cf-border-width": "1px", + "--cf-shadow": "none" + }, + "soft": { + "--cf-radius": "0.375rem", + "--cf-border-width": "1px", + "--cf-shadow": "0 1px 2px rgb(0 0 0 / 0.08)" + }, + "round": { + "--cf-radius": "0.75rem", + "--cf-border-width": "0", + "--cf-shadow": "0 2px 8px rgb(0 0 0 / 0.12)" + } + }, + "density": { + "compact": { + "--cf-spacing": "0.2rem", + "--cf-line-height": "1.4", + "--cf-control-height": "2rem" + }, + "regular": { + "--cf-spacing": "0.25rem", + "--cf-line-height": "1.5", + "--cf-control-height": "2.5rem" + }, + "roomy": { + "--cf-spacing": "0.3rem", + "--cf-line-height": "1.65", + "--cf-control-height": "3rem" + } + }, + "type": { + "system": { + "--cf-font-display": "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", + "--cf-font-body": "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", + "--cf-font-mono": "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + "--cf-scale-ratio": "1.25" + }, + "humanist": { + "--cf-font-display": "Seravek, 'Gill Sans Nova', Ubuntu, Calibri, sans-serif", + "--cf-font-body": "Seravek, 'Gill Sans Nova', Ubuntu, Calibri, sans-serif", + "--cf-font-mono": "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + "--cf-scale-ratio": "1.2" + }, + "mono": { + "--cf-font-display": "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + "--cf-font-body": "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + "--cf-font-mono": "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + "--cf-scale-ratio": "1.2" + } + } + }, + "compositions": { + "default": { + "accent": "slate", + "surface": "plain", + "form": "soft", + "density": "regular", + "type": "system" + }, + "editorial": { + "accent": "jade", + "surface": "muted", + "form": "sharp", + "density": "roomy", + "type": "humanist" + }, + "console": { + "accent": "azure", + "surface": "plain", + "form": "sharp", + "density": "compact", + "type": "mono" + } + }, + "contrastPairs": [ + { + "foreground": "--cf-text", + "background": "--cf-ground", + "minimum": 4.5 + }, + { + "foreground": "--cf-text", + "background": "--cf-lifted", + "minimum": 4.5 + }, + { + "foreground": "--cf-text-muted", + "background": "--cf-ground", + "minimum": 4.5 + }, + { + "foreground": "--cf-accent-content", + "background": "--cf-accent", + "minimum": 4.5 + }, + { + "foreground": "--cf-accent-strong", + "background": "--cf-ground", + "minimum": 4.5 + }, + { + "foreground": "--cf-accent-strong", + "background": "--cf-lifted", + "minimum": 4.5 + }, + { + "foreground": "--cf-accent", + "background": "--cf-ground", + "minimum": 3.0 + } + ] +} diff --git a/src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs b/src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs new file mode 100644 index 0000000..8734435 --- /dev/null +++ b/src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs @@ -0,0 +1,411 @@ +/** + * cf-ui theme composition axes — Tailwind plugin. + * + * Validates axis values at build time and generates the axis CSS from the + * definition exported by `cf_ui/axes.py`. The point is the throw: an unknown + * axis value stops the build. Without it, `data-accent="hotpink"` is not an + * error, it is an element with no accent — discovered in review, or in + * production, instead of at build time. + * + * VENDORED FILE. This ships inside the Python package and is loaded from + * site-packages, so it deliberately imports nothing but Node builtins — a bare + * `tailwindcss/plugin` import would not resolve from where this file lives. + * The exported shape is Tailwind's own `{ handler, config }` plugin object, + * which is exactly what `tailwindcss/plugin` constructs. + * + * GENERATED INPUT, HAND-WRITTEN CODE: `cf_ui_axes.json` is a build product of + * `axes.py` (`python -m cf_ui.axes`). This file must never carry its own copy + * of the value sets — that is the drift #7 exists to prevent. + * + * Usage (Tailwind v4, CSS-first): + * + * @import "tailwindcss"; + * @plugin "./path/to/site-packages/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs"; + * + * Usage (JS config, with options): + * + * import cfUiAxes from "./.../cf_ui_tailwind_plugin.mjs"; + * export default { + * plugins: [cfUiAxes({ composition: "console", contrastReport: true })], + * }; + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const DEFINITION_URL = new URL("./cf_ui_axes.json", import.meta.url); + +/** Raised for an unknown axis, value, composition, or malformed value set. */ +export class AxisPluginError extends Error { + constructor(message) { + super(`cf-ui: ${message}`); + this.name = "AxisPluginError"; + } +} + +let _definitionText = null; + +/** + * Read the axis definition generated by `python -m cf_ui.axes`. + * + * Returns a fresh deep copy each call, so a caller merging its own value sets + * can never corrupt the shipped ones for the next build in the same process. + */ +export function loadDefinition() { + if (_definitionText === null) { + _definitionText = readFileSync(fileURLToPath(DEFINITION_URL), "utf-8"); + } + return JSON.parse(_definitionText); +} + +// --- validation ------------------------------------------------------------ + +/** + * Token values are interpolated straight into generated CSS. A value carrying + * `;` or a brace closes its own declaration and writes rules the app never + * asked for, so it is rejected rather than escaped — there is no legitimate + * axis token that needs one. + */ +const UNSAFE_VALUE = /[;{}]|\/\*|\*\//; + +function validateTokens(axis, name, tokens) { + if (tokens === null || typeof tokens !== "object" || Array.isArray(tokens)) { + throw new AxisPluginError(`axis value ${axis}/${name} must be a mapping of custom properties`); + } + for (const [token, value] of Object.entries(tokens)) { + if (!token.startsWith("--")) { + throw new AxisPluginError( + `axis value ${axis}/${name} declares '${token}': axis tokens must be CSS custom ` + + `properties (a name starting with '--')`, + ); + } + if (typeof value !== "string") { + throw new AxisPluginError(`axis value ${axis}/${name} declares a non-string ${token}`); + } + if (UNSAFE_VALUE.test(value)) { + throw new AxisPluginError( + `axis value ${axis}/${name} declares ${token} with an unsafe value: a token may not ` + + `contain ';', '{', '}', or a comment delimiter`, + ); + } + } +} + +function validateValueSet(definition, axis, name, tokens) { + if (!new RegExp(definition.valueNamePattern).test(name)) { + throw new AxisPluginError( + `invalid axis value name '${name}' for axis '${axis}': expected lowercase words ` + + `separated by hyphens`, + ); + } + if (tokens === null || typeof tokens !== "object" || Array.isArray(tokens)) { + throw new AxisPluginError(`axis value ${axis}/${name} must be a mapping of custom properties`); + } + + if (definition.modeKeyedAxes.includes(axis)) { + const missing = definition.modes.filter((mode) => !(mode in tokens)); + if (missing.length) { + throw new AxisPluginError( + `axis value ${axis}/${name} is missing ${missing.join(", ")} — light and dark must ` + + `each be declared explicitly, never derived by inversion`, + ); + } + for (const mode of definition.modes) { + validateTokens(axis, name, tokens[mode]); + } + if (tokens.p3) { + for (const mode of definition.modes) { + if (tokens.p3[mode]) validateTokens(axis, name, tokens.p3[mode]); + } + } + } else { + validateTokens(axis, name, tokens); + } +} + +/** + * Combine an app's own value sets with the shipped ones. + * + * Mirrors `cf_ui.axes.merge_value_sets`: `"extend"` adds to (or overrides + * individual values in) the shipped set; `"replace"` discards the shipped + * values for each axis supplied, leaving untouched axes at their defaults. + */ +export function mergeValueSets(definition, custom, mode = "extend") { + if (mode !== "extend" && mode !== "replace") { + throw new AxisPluginError(`unknown value-set mode '${mode}': expected 'extend' or 'replace'`); + } + + const merged = structuredClone(definition.valueSets); + if (!custom) return merged; + + for (const [axis, values] of Object.entries(custom)) { + if (!definition.axes.includes(axis)) { + throw new AxisPluginError( + `unknown axis '${axis}': valid axes are ${definition.axes.join(", ")}`, + ); + } + if (values === null || typeof values !== "object") { + throw new AxisPluginError(`axis '${axis}' must map value names to tokens`); + } + for (const [name, tokens] of Object.entries(values)) { + validateValueSet(definition, axis, name, tokens); + } + merged[axis] = + mode === "replace" + ? structuredClone(values) + : { ...merged[axis], ...structuredClone(values) }; + } + + return merged; +} + +/** + * Resolve one config value into a value for each axis, failing the build on + * anything the value sets do not declare. This is the feature. + */ +export function resolveComposition(definition, composition, valueSets) { + const base = definition.compositions.default; + let chosen; + + if (composition === null || composition === undefined) { + chosen = { ...base }; + } else if (typeof composition === "string") { + if (!(composition in definition.compositions)) { + const known = Object.keys(definition.compositions).sort().join(", "); + throw new AxisPluginError( + `unknown composition '${composition}': known compositions ${known}`, + ); + } + chosen = { ...definition.compositions[composition] }; + } else if (typeof composition === "object" && !Array.isArray(composition)) { + const unknown = Object.keys(composition).filter((axis) => !definition.axes.includes(axis)); + if (unknown.length) { + throw new AxisPluginError( + `unknown axis ${unknown.map((a) => `'${a}'`).join(", ")}: valid axes are ` + + `${definition.axes.join(", ")}`, + ); + } + chosen = { ...base, ...composition }; + } else { + throw new AxisPluginError( + `composition must be a name, a mapping of axes, or null — got ${typeof composition}`, + ); + } + + for (const axis of definition.axes) { + const value = chosen[axis]; + const available = valueSets[axis] ?? {}; + if (!(value in available)) { + const valid = Object.keys(available).sort().join(", ") || "(none)"; + throw new AxisPluginError( + `unknown value '${value}' for axis '${axis}': valid values are ${valid}`, + ); + } + } + + return chosen; +} + +// --- CSS generation -------------------------------------------------------- + +function selectorFor(definition, axis, value, mode) { + const attr = `[${definition.axisAttrs[axis]}="${value}"]`; + return mode === "dark" ? `[data-theme="dark"]${attr}` : attr; +} + +/** + * One rule body: the declared tokens, then the Tailwind theme aliases for + * whichever of them are present. Mirrors `cf_ui.axes._block` exactly — the + * parity test in `tests/unit/test_tailwind_plugin.py` compares the output of + * the two generators declaration by declaration. + */ +function blockFor(definition, tokens) { + const block = { ...tokens }; + for (const [name, alias] of Object.entries(definition.aliases)) { + if (name in tokens) block[alias] = `var(${name})`; + } + return block; +} + +/** + * Build the axis rules as a Tailwind `addBase` object, wide-gamut layer + * included. + */ +export function buildAxisBase(valueSets, definition) { + const def = definition ?? loadDefinition(); + const sets = valueSets ?? def.valueSets; + const base = {}; + + for (const axis of def.axes) { + for (const [value, tokens] of Object.entries(sets[axis] ?? {})) { + if (def.modeKeyedAxes.includes(axis)) { + for (const mode of def.modes) { + base[selectorFor(def, axis, value, mode)] = blockFor(def, tokens[mode]); + } + } else { + base[selectorFor(def, axis, value)] = blockFor(def, tokens); + } + } + } + + // Generated from the same definition rather than hand-maintained alongside + // it — keeping the two in sync by hand is what #7 removes. + const p3 = {}; + for (const axis of def.axes) { + for (const [value, tokens] of Object.entries(sets[axis] ?? {})) { + const gamut = tokens && typeof tokens === "object" ? tokens.p3 : null; + if (!gamut) continue; + for (const mode of def.modes) { + if (gamut[mode]) { + p3[selectorFor(def, axis, value, mode)] = blockFor(def, gamut[mode]); + } + } + } + } + if (Object.keys(p3).length) { + base["@media (color-gamut: p3)"] = p3; + } + + return base; +} + +/** The same rules as CSS text, for writing to a file or eyeballing a build. */ +export function buildAxisCss(valueSets, definition) { + const base = buildAxisBase(valueSets, definition); + const render = (rules, indent) => + Object.entries(rules) + .map(([selector, block]) => { + const body = Object.entries(block) + .map(([name, value]) => `${indent} ${name}: ${value};`) + .join("\n"); + return `${indent}${selector} {\n${body}\n${indent}}\n`; + }) + .join("\n"); + + const { "@media (color-gamut: p3)": media, ...flat } = base; + let css = render(flat, ""); + if (media) css += `\n@media (color-gamut: p3) {\n${render(media, " ")}}\n`; + return css; +} + +// --- contrast -------------------------------------------------------------- + +function relativeLuminance(color) { + let value = String(color).trim().replace(/^#/, ""); + if (value.length === 3) value = [...value].map((char) => char + char).join(""); + if (!/^[0-9a-fA-F]{6}$/.test(value)) return null; + + const channels = [0, 2, 4].map((index) => { + const channel = parseInt(value.slice(index, index + 2), 16) / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +/** WCAG 2.1 relative-luminance contrast ratio between two sRGB hex colors. */ +export function contrastRatio(foreground, background) { + const first = relativeLuminance(foreground); + const second = relativeLuminance(background); + if (first === null || second === null) return null; + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); +} + +/** + * Every accent x surface x mode combination, with its WCAG AA failures. + * + * Advisory by design: this warns, it does not fail the build. The build error + * is reserved for an unknown axis value, which is unambiguously a mistake. A + * contrast number below AA may be a deliberate choice in a value set the + * package did not ship, and turning it into a hard error would mean cf-ui + * decides when a consuming app is allowed to compile. + */ +export function contrastReport(valueSets, contrastPairs, modes = ["light", "dark"]) { + const rows = []; + for (const accent of Object.keys(valueSets.accent ?? {})) { + for (const surface of Object.keys(valueSets.surface ?? {})) { + for (const mode of modes) { + const tokens = { + ...(valueSets.surface[surface][mode] ?? {}), + ...(valueSets.accent[accent][mode] ?? {}), + }; + const failures = []; + for (const { foreground, background, minimum } of contrastPairs) { + if (!(foreground in tokens) || !(background in tokens)) { + failures.push(`${foreground} on ${background}: token not declared`); + continue; + } + const ratio = contrastRatio(tokens[foreground], tokens[background]); + if (ratio === null) { + failures.push( + `${foreground} on ${background}: not an sRGB hex value, cannot be checked`, + ); + } else if (ratio < minimum) { + failures.push(`${foreground} on ${background}: ${ratio.toFixed(2)} < ${minimum}`); + } + } + rows.push({ accent, surface, mode, failures }); + } + } + } + return rows; +} + +function warnContrast(rows) { + const failing = rows.filter((row) => row.failures.length); + if (!failing.length) { + console.warn(`cf-ui: contrast report — ${rows.length} combinations checked, all pass WCAG AA`); + return; + } + const lines = failing.map( + (row) => ` ${row.accent} x ${row.surface} (${row.mode}):\n ${row.failures.join("\n ")}`, + ); + console.warn( + `cf-ui: contrast report — ${failing.length} of ${rows.length} combinations fail ` + + `WCAG AA:\n${lines.join("\n")}`, + ); +} + +// --- plugin ---------------------------------------------------------------- + +/** + * @param {object} [options] + * @param {object} [options.definition] Override the shipped definition. + * @param {object} [options.valueSets] The app's own `{axis: {name: tokens}}`. + * @param {string} [options.valueSetsMode] `"extend"` (default) or `"replace"`. + * @param {string|object|null} [options.composition] Validated, and it is the + * validation that matters — an unknown value throws here. + * @param {boolean} [options.contrastReport] Warn with the full accent x + * surface x mode report. Off by default. + */ +function createPlugin(options = {}) { + const definition = options.definition ?? loadDefinition(); + const valueSets = mergeValueSets( + definition, + options.valueSets, + options.valueSetsMode ?? "extend", + ); + + // Eager, so the build fails at the point of misconfiguration rather than + // whenever Tailwind happens to invoke the handler. + resolveComposition(definition, options.composition ?? null, valueSets); + + if (options.contrastReport) { + warnContrast(contrastReport(valueSets, definition.contrastPairs, definition.modes)); + } + + const base = buildAxisBase(valueSets, definition); + return { + handler: ({ addBase }) => addBase(base), + config: {}, + }; +} + +/** + * Callable as a factory (`cfUiAxes({...})`) and usable directly as a plugin + * (`@plugin "./cf_ui_tailwind_plugin.mjs"`), which is the shape Tailwind's own + * `plugin.withOptions` produces. + */ +const cfUiAxes = (options = {}) => createPlugin(options); +Object.assign(cfUiAxes, createPlugin({})); + +export default cfUiAxes; diff --git a/src/cf_ui/templates/cotton/cf/checkbox-group.html b/src/cf_ui/templates/cotton/cf/checkbox-group.html index 4725c12..4e4e02d 100644 --- a/src/cf_ui/templates/cotton/cf/checkbox-group.html +++ b/src/cf_ui/templates/cotton/cf/checkbox-group.html @@ -13,4 +13,4 @@ {% endfor %} {% if error %}

{{ error }}

{% endif %} - \ No newline at end of file + diff --git a/tests/js/plugin.test.mjs b/tests/js/plugin.test.mjs new file mode 100644 index 0000000..0eb4479 --- /dev/null +++ b/tests/js/plugin.test.mjs @@ -0,0 +1,335 @@ +/** + * Tailwind plugin tests (issue #7) — `node --test tests/js/`. + * + * The headline behavior is the throw: an unknown axis value has to stop the + * CSS build, not warn and carry on. Everything else here exists to make sure + * the throw is reachable in a real build and that nothing else regressed into + * a silent no-op. + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +import cfUiAxes, { + AxisPluginError, + buildAxisBase, + buildAxisCss, + contrastReport, + loadDefinition, + mergeValueSets, + resolveComposition, +} from "../../src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs"; + +const DEFINITION = JSON.parse( + readFileSync( + fileURLToPath(new URL("../../src/cf_ui/static/cf_ui/cf_ui_axes.json", import.meta.url)), + "utf-8", + ), +); + +/** Collect what the plugin hands Tailwind, without importing Tailwind. */ +function runHandler(plugin) { + const collected = []; + const handler = typeof plugin === "function" ? plugin.handler : plugin.handler; + handler({ addBase: (styles) => collected.push(styles) }); + assert.equal(collected.length, 1, "expected exactly one addBase call"); + return collected[0]; +} + +describe("the build error", () => { + it("throws on an unknown axis value", () => { + assert.throws(() => cfUiAxes({ composition: { accent: "hotpink" } }), AxisPluginError); + }); + + it("names the axis and the valid values in the message", () => { + try { + cfUiAxes({ composition: { accent: "hotpink" } }); + assert.fail("expected a throw"); + } catch (error) { + assert.match(error.message, /hotpink/); + assert.match(error.message, /accent/); + assert.match(error.message, /azure/, "the message must list what IS valid"); + } + }); + + it("throws on an unknown axis name", () => { + assert.throws(() => cfUiAxes({ composition: { flavor: "vanilla" } }), /flavor/); + }); + + it("throws on an unknown named composition", () => { + assert.throws(() => cfUiAxes({ composition: "brutalist" }), /brutalist/); + }); + + it("throws eagerly, before Tailwind ever calls the handler", () => { + // A throw from inside the handler would still fail the build, but only + // once Tailwind gets that far. Failing in the factory keeps the error at + // the point of configuration, where the mistake actually is. + assert.throws(() => cfUiAxes({ composition: { accent: "hotpink" } })); + }); + + it("accepts every shipped composition", () => { + for (const name of Object.keys(DEFINITION.compositions)) { + assert.doesNotThrow(() => cfUiAxes({ composition: name })); + } + }); +}); + +describe("plugin shape", () => { + it("exposes a handler when imported directly with no options", () => { + assert.equal(typeof cfUiAxes.handler, "function"); + }); + + it("is also callable as a factory", () => { + const plugin = cfUiAxes({ composition: "console" }); + assert.equal(typeof plugin.handler, "function"); + }); + + it("carries a config object, per Tailwind's plugin contract", () => { + assert.equal(typeof cfUiAxes({}).config, "object"); + }); + + it("needs no tailwindcss import to construct", () => { + // The file is vendored into consuming apps; a bare import of + // `tailwindcss/plugin` would make it unloadable outside a resolved + // node_modules tree, which is exactly where a vendored file lives. + const source = readFileSync( + fileURLToPath( + new URL("../../src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs", import.meta.url), + ), + "utf-8", + ); + assert.doesNotMatch(source, /from\s+["']tailwindcss/); + }); +}); + +describe("generated CSS", () => { + const base = runHandler(cfUiAxes({})); + + it("emits a block per axis value", () => { + assert.ok(base['[data-accent="azure"]'], "no azure block"); + assert.ok(base['[data-surface="plain"]'], "no plain surface block"); + assert.ok(base['[data-form="soft"]'], "no soft form block"); + }); + + it("emits dark declarations under a data-theme selector", () => { + const dark = base['[data-theme="dark"][data-accent="azure"]']; + assert.ok(dark, "no dark azure block"); + assert.notEqual(dark["--cf-accent"], base['[data-accent="azure"]']["--cf-accent"]); + }); + + it("emits the Tailwind theme aliases alongside the cf tokens", () => { + assert.equal(base['[data-accent="azure"]']["--color-primary"], "var(--cf-accent)"); + }); + + it("emits the wide-gamut layer from the same definition", () => { + const media = base["@media (color-gamut: p3)"]; + assert.ok(media, "no p3 layer"); + assert.match(media['[data-accent="azure"]']["--cf-accent"], /^oklch\(/); + }); + + it("omits a p3 block for a value that declares none", () => { + const media = base["@media (color-gamut: p3)"]; + assert.equal(media['[data-accent="slate"]'], undefined); + }); + + it("serializes to CSS text as well, for inspection", () => { + const css = buildAxisCss(); + assert.match(css, /\[data-accent="azure"\]\s*\{/); + assert.match(css, /@media \(color-gamut: p3\)/); + }); +}); + +describe("consumer value sets", () => { + const brand = { + 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", + }, + }, + }, + }; + + it("accepts an app's own value set and lets the composition use it", () => { + const base = runHandler(cfUiAxes({ valueSets: brand, composition: { accent: "brand" } })); + assert.equal(base['[data-accent="brand"]']["--cf-accent"], "#7c3aed"); + }); + + it("extends by default — the shipped values survive", () => { + const base = runHandler(cfUiAxes({ valueSets: brand })); + assert.ok(base['[data-accent="azure"]'], "extend mode dropped a shipped value"); + }); + + it("replace mode drops the shipped values for that axis only", () => { + const options = { valueSets: brand, valueSetsMode: "replace", composition: { accent: "brand" } }; + const base = runHandler(cfUiAxes(options)); + assert.equal(base['[data-accent="azure"]'], undefined); + assert.ok(base['[data-surface="plain"]'], "replace leaked into an untouched axis"); + }); + + it("makes a shipped value unknown after replace — so the build still fails", () => { + assert.throws( + () => cfUiAxes({ valueSets: brand, valueSetsMode: "replace", composition: "console" }), + /azure/, + ); + }); + + it("rejects a mode-keyed value missing a mode", () => { + const half = { accent: { half: { light: { "--cf-accent": "#000000" } } } }; + assert.throws(() => cfUiAxes({ valueSets: half }), /dark/); + }); + + it("rejects a token that is not a custom property", () => { + const bad = { form: { edgy: { radius: "0" } } }; + assert.throws(() => cfUiAxes({ valueSets: bad }), /--/); + }); + + it("rejects an invalid value name", () => { + const bad = { form: { "Not Valid": { "--cf-radius": "0" } } }; + assert.throws(() => cfUiAxes({ valueSets: bad }), /Not Valid/); + }); + + it("rejects an unknown axis in a value set", () => { + assert.throws(() => cfUiAxes({ valueSets: { flavor: { vanilla: {} } } }), /flavor/); + }); + + it("rejects a token value that would break out of its declaration", () => { + // Consumer value sets are interpolated straight into generated CSS. A + // value carrying `;` or `}` writes rules the app never declared. + const bad = { form: { evil: { "--cf-radius": "0; } body { display: none" } } }; + assert.throws(() => cfUiAxes({ valueSets: bad }), /--cf-radius/); + }); + + it("does not mutate the shipped definition", () => { + // A composition is required here: `replace` has just removed the default + // composition's `slate`, and the plugin is right to reject that. + cfUiAxes({ valueSets: brand, valueSetsMode: "replace", composition: { accent: "brand" } }); + const fresh = loadDefinition(); + assert.ok(fresh.valueSets.accent.azure, "the shipped definition was mutated"); + }); +}); + +describe("contrast report", () => { + it("covers every accent x surface x mode", () => { + const rows = contrastReport(DEFINITION.valueSets, DEFINITION.contrastPairs); + const accents = Object.keys(DEFINITION.valueSets.accent).length; + const surfaces = Object.keys(DEFINITION.valueSets.surface).length; + assert.equal(rows.length, accents * surfaces * DEFINITION.modes.length); + }); + + it("reports the shipped set as clean", () => { + const rows = contrastReport(DEFINITION.valueSets, DEFINITION.contrastPairs); + assert.deepEqual( + rows.filter((row) => row.failures.length), + [], + ); + }); + + it("flags a combination that fails AA", () => { + const sets = mergeValueSets(DEFINITION, { + accent: { + faint: { + light: { + "--cf-accent": "#eeeeee", + "--cf-accent-content": "#ffffff", + "--cf-accent-strong": "#f5f5f5", + }, + dark: { + "--cf-accent": "#eeeeee", + "--cf-accent-content": "#ffffff", + "--cf-accent-strong": "#f5f5f5", + }, + }, + }, + }); + const rows = contrastReport(sets, DEFINITION.contrastPairs); + const faint = rows.filter((row) => row.accent === "faint" && row.failures.length); + assert.ok(faint.length, "an unreadable accent produced no failures"); + assert.match(faint[0].failures[0], /--cf-accent-content on --cf-accent/); + }); + + it("warns rather than throwing — the build error is for unknown values", () => { + const warnings = []; + const original = console.warn; + console.warn = (message) => warnings.push(String(message)); + try { + const faint = { + accent: { + faint: { + light: { + "--cf-accent": "#eeeeee", + "--cf-accent-content": "#ffffff", + "--cf-accent-strong": "#f5f5f5", + }, + dark: { + "--cf-accent": "#eeeeee", + "--cf-accent-content": "#ffffff", + "--cf-accent-strong": "#f5f5f5", + }, + }, + }, + }; + assert.doesNotThrow(() => cfUiAxes({ valueSets: faint, contrastReport: true })); + assert.ok( + warnings.some((line) => /faint/.test(line)), + "the failing combination was never surfaced", + ); + } finally { + console.warn = original; + } + }); + + it("is off by default, so an existing build does not start shouting", () => { + const warnings = []; + const original = console.warn; + console.warn = (message) => warnings.push(String(message)); + try { + cfUiAxes({}); + assert.deepEqual(warnings, []); + } finally { + console.warn = original; + } + }); + + it("computes a known WCAG ratio correctly", () => { + // Black on white is 21:1 by definition; if this drifts the whole report + // is decorative. + const rows = contrastReport( + { + accent: { a: { light: { "--cf-fg": "#000000" }, dark: { "--cf-fg": "#000000" } } }, + surface: { s: { light: { "--cf-bg": "#ffffff" }, dark: { "--cf-bg": "#ffffff" } } }, + }, + [{ foreground: "--cf-fg", background: "--cf-bg", minimum: 21 }], + ); + assert.deepEqual(rows[0].failures, []); + }); +}); + +describe("resolveComposition", () => { + it("defaults to the shipped default composition", () => { + const resolved = resolveComposition(DEFINITION, null, DEFINITION.valueSets); + assert.deepEqual(resolved, DEFINITION.compositions.default); + }); + + it("layers a partial mapping over the default", () => { + const resolved = resolveComposition(DEFINITION, { accent: "azure" }, DEFINITION.valueSets); + assert.equal(resolved.accent, "azure"); + assert.equal(resolved.surface, DEFINITION.compositions.default.surface); + }); +}); + +describe("buildAxisBase", () => { + it("is exported for consumers not using the plugin form", () => { + const base = buildAxisBase(DEFINITION.valueSets); + assert.ok(base['[data-accent="slate"]']); + }); +}); diff --git a/tests/unit/test_axis_definition.py b/tests/unit/test_axis_definition.py new file mode 100644 index 0000000..aab2558 --- /dev/null +++ b/tests/unit/test_axis_definition.py @@ -0,0 +1,117 @@ +"""The exported axis definition (issue #7). + +The Tailwind plugin runs inside the CSS build, where Python is not. It cannot +import ``cf_ui.axes``, so the definition has to cross the language boundary as +data. These tests pin that boundary: the export is complete enough for the +plugin to do its job, it is genuinely JSON-serializable, and the committed +artifact has not drifted from the module that generates it. + +``axes.py`` stays the single source of truth. The JSON is a build product of +it, exactly as ``cf_ui_axes.css`` is — there is no second generator. +""" + +from __future__ import annotations + +import json + +import pytest + +from cf_ui.axes import ( + AXES, + AXIS_ATTRS, + AXIS_DEFINITION_PATH, + DEFAULT_COMPOSITIONS, + DEFAULT_VALUE_SETS, + MODE_KEYED_AXES, + MODES, + axis_definition, +) + + +@pytest.fixture(scope="module") +def definition() -> dict: + return axis_definition() + + +# --- shape ----------------------------------------------------------------- + + +def test_definition_is_json_serializable(definition): + """A tuple or a Path in here would blow up at generation time, not here.""" + assert json.loads(json.dumps(definition)) == definition + + +def test_definition_carries_every_axis(definition): + assert definition["axes"] == list(AXES) + assert definition["modeKeyedAxes"] == list(MODE_KEYED_AXES) + assert definition["modes"] == list(MODES) + + +def test_definition_carries_the_data_attribute_names(definition): + """The plugin builds selectors from these; it must not re-derive them.""" + assert definition["axisAttrs"] == dict(AXIS_ATTRS) + + +def test_definition_carries_the_tailwind_aliases(definition): + """Without the aliases the generated CSS would not feed Tailwind's theme.""" + assert definition["aliases"]["--cf-accent"] == "--color-primary" + + +def test_definition_carries_every_shipped_value(definition): + for axis in AXES: + assert set(definition["valueSets"][axis]) == set(DEFAULT_VALUE_SETS[axis]) + + +def test_definition_carries_the_named_compositions(definition): + assert definition["compositions"] == DEFAULT_COMPOSITIONS + + +def test_definition_carries_the_contrast_pairs(definition): + """The contrast report is generated in JS, from the same thresholds.""" + pairs = definition["contrastPairs"] + assert pairs, "no contrast pairs exported" + for pair in pairs: + assert set(pair) == {"foreground", "background", "minimum"} + assert pair["foreground"].startswith("--") + assert isinstance(pair["minimum"], float) + assert {"foreground": "--cf-text", "background": "--cf-ground", "minimum": 4.5} in pairs + + +def test_definition_preserves_the_p3_blocks(definition): + """The p3 layer is generated from this, so it has to survive the export.""" + assert definition["valueSets"]["accent"]["azure"]["p3"]["light"]["--cf-accent"].startswith( + "oklch(" + ) + + +def test_definition_is_a_copy_not_a_live_reference(definition): + """Mutating the export must not corrupt the module's own value sets.""" + export = axis_definition() + export["valueSets"]["accent"].pop("slate", None) + assert "slate" in DEFAULT_VALUE_SETS["accent"] + assert "slate" in axis_definition()["valueSets"]["accent"] + + +# --- the committed artifact ------------------------------------------------ + + +def test_the_definition_file_is_committed(): + assert AXIS_DEFINITION_PATH.is_file(), ( + f"{AXIS_DEFINITION_PATH} is missing — regenerate with `python -m cf_ui.axes`" + ) + + +def test_the_definition_file_has_not_drifted(definition): + """The same guard cf_ui_axes.css has: edit axes.py, regenerate, commit.""" + on_disk = json.loads(AXIS_DEFINITION_PATH.read_text(encoding="utf-8")) + assert on_disk == definition, ( + "cf_ui_axes.json is stale — regenerate with `python -m cf_ui.axes`" + ) + + +def test_the_definition_file_ships_in_the_package(): + """It sits beside the plugin that reads it, inside the installed package.""" + from cf_ui.axes import AXIS_CSS_PATH + + assert AXIS_DEFINITION_PATH.parent == AXIS_CSS_PATH.parent + assert AXIS_DEFINITION_PATH.name == "cf_ui_axes.json" diff --git a/tests/unit/test_tailwind_plugin.py b/tests/unit/test_tailwind_plugin.py new file mode 100644 index 0000000..9f29d38 --- /dev/null +++ b/tests/unit/test_tailwind_plugin.py @@ -0,0 +1,187 @@ +"""The Python half of the Tailwind plugin's guarantees (issue #7). + +Two jobs: + +1. Run the plugin's own ``node --test`` suite, so a Python-only ``pytest`` run + still exercises the JS. CI runs ``node --test`` directly as well — the + skip below is a local convenience, not the only path. +2. **Prove the two generators agree.** ``axes.py`` renders ``cf_ui_axes.css``; + the plugin renders the same rules inside the CSS build. #7 asks for one + source of truth, and the JSON export only guarantees they read the same + *input*. This compares their *output*, declaration by declaration, which is + what would actually break a consuming app if it drifted. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +from fnmatch import fnmatch +from pathlib import Path + +import pytest + +from cf_ui.axes import DEFAULT_VALUE_SETS, render_axis_css + +REPO_ROOT = Path(__file__).resolve().parents[2] +# A glob, not a directory: node >=25 reads a bare directory as a module to run. +# The TAP reporter is requested explicitly so the pass count below is parseable +# regardless of node's default reporter for the current version and TTY state. +JS_TEST_GLOB = "tests/js/**/*.test.mjs" +PLUGIN_PATH = REPO_ROOT / "src" / "cf_ui" / "static" / "cf_ui" / "cf_ui_tailwind_plugin.mjs" +DEFINITION_PATH = REPO_ROOT / "src" / "cf_ui" / "static" / "cf_ui" / "cf_ui_axes.json" + +NODE = shutil.which("node") +requires_node = pytest.mark.skipif( + NODE is None, + reason="node is not installed — CI runs `node --test tests/js/` as its own step", +) + + +def _run_node(script: str) -> str: + """Evaluate a module-scoped snippet against the plugin and return stdout.""" + result = subprocess.run( # noqa: S603 + [NODE, "--input-type=module", "-e", script], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + if result.returncode != 0: + raise AssertionError(f"node failed:\n{result.stdout}\n{result.stderr}") + return result.stdout + + +# --- the JS suite ---------------------------------------------------------- + + +@requires_node +def test_the_node_test_suite_passes(): + result = subprocess.run( # noqa: S603 + [NODE, "--test", "--test-reporter=tap", JS_TEST_GLOB], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + sys.stdout.write(result.stdout) + sys.stdout.write(result.stderr) + assert result.returncode == 0, "node --test tests/js/ failed" + + +@requires_node +def test_the_node_suite_is_not_empty(): + """A passing run of zero tests is the failure mode this guards against.""" + result = subprocess.run( # noqa: S603 + [NODE, "--test", "--test-reporter=tap", JS_TEST_GLOB], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + match = re.search(r"^# pass (\d+)$", result.stdout, re.MULTILINE) + assert match, f"could not read a pass count from node's output:\n{result.stdout}" + assert int(match.group(1)) > 20, "the JS suite shrank unexpectedly" + + +# --- cross-language parity ------------------------------------------------- + +_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL) +_RULE = re.compile(r"([^{}]+?)\{([^{}]*)\}", re.DOTALL) + + +def _declarations(css: str) -> dict[str, dict[str, str]]: + """Parse flat CSS into ``{selector: {property: value}}``.""" + parsed: dict[str, dict[str, str]] = {} + for selector, body in _RULE.findall(css): + tokens = {} + for line in body.split(";"): + if ":" not in line: + continue + name, _, value = line.partition(":") + tokens[name.strip()] = value.strip() + if tokens: + parsed[selector.strip()] = tokens + return parsed + + +def _python_rules() -> tuple[dict, dict]: + """Split the generated stylesheet into its base rules and its p3 layer.""" + css = _COMMENT.sub("", render_axis_css(DEFAULT_VALUE_SETS, banner=False)) + marker = "@media (color-gamut: p3) {" + base_css, _, media_css = css.partition(marker) + return _declarations(base_css), _declarations(media_css) + + +def _js_rules() -> tuple[dict, dict]: + # A file:// URI, not a path: node's ESM loader reads a bare `C:/...` as an + # unsupported URL scheme. + script = f""" + import {{ buildAxisBase }} from {json.dumps(PLUGIN_PATH.as_uri())}; + process.stdout.write(JSON.stringify(buildAxisBase())); + """ + base = json.loads(_run_node(script)) + media = base.pop("@media (color-gamut: p3)", {}) + return base, media + + +def test_the_meta_parser_actually_parses(): + """A comparison built on a parser that returns {} would pass vacuously.""" + parsed = _declarations('[data-accent="x"] {\n --cf-accent: #fff;\n}\n') + assert parsed == {'[data-accent="x"]': {"--cf-accent": "#fff"}} + + +@requires_node +def test_the_plugin_and_axes_py_emit_the_same_base_rules(): + python_base, _ = _python_rules() + js_base, _ = _js_rules() + assert js_base == python_base + + +@requires_node +def test_the_plugin_and_axes_py_emit_the_same_p3_layer(): + _, python_media = _python_rules() + _, js_media = _js_rules() + assert js_media == python_media + + +@requires_node +def test_the_parity_check_has_something_to_compare(): + """Guards the two tests above against both sides being empty.""" + python_base, python_media = _python_rules() + assert len(python_base) > 10, python_base + assert python_media, "no p3 layer parsed out of the generated stylesheet" + + +# --- the vendored artifact ------------------------------------------------- + + +def test_the_plugin_ships_inside_the_package(): + assert PLUGIN_PATH.is_file() + + +def test_the_wheel_ships_the_vendored_artifacts(): + """Vendoring is the distribution decision, so the include glob is load-bearing. + + A consuming app points its Tailwind config at these files inside + site-packages. Narrowing this glob to, say, ``*.js`` would ship a wheel + whose documented plugin path does not exist. + """ + import tomllib + + config = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + patterns = config["tool"]["hatch"]["build"]["include"] + for artifact in (PLUGIN_PATH, DEFINITION_PATH): + relative = artifact.relative_to(REPO_ROOT).as_posix() + assert any(fnmatch(relative, pattern) for pattern in patterns), ( + f"{relative} is not covered by the wheel include patterns {patterns}" + ) + + +def test_the_plugin_is_dependency_free(): + """Vendored means vendored: no bare imports to resolve in the consumer.""" + source = PLUGIN_PATH.read_text(encoding="utf-8") + bare = re.findall(r"""^\s*import\s+.*?from\s+["']([^."'][^"']*)["']""", source, re.MULTILINE) + assert all(name.startswith("node:") for name in bare), ( + f"plugin imports non-builtin modules: {bare}" + ) From d0aa016b43a3d5b377703f919467812e3f0b0f7a Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Wed, 29 Jul 2026 10:10:58 -0400 Subject: [PATCH 2/2] fix(axes): close two build-validation gaps found reviewing against real 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 Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf --- .pre-commit-config.yaml | 7 ++++- docs/tailwind-plugin.md | 15 +++++++++- .../static/cf_ui/cf_ui_tailwind_plugin.mjs | 20 +++++++++---- tests/js/plugin.test.mjs | 28 +++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5885d4e..d572d9d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,8 +7,13 @@ repos: - id: check-yaml - id: check-merge-conflict + # Keep this in step with the `ruff` that `dev` resolves to (pyproject: + # `ruff>=0.1`), which is what CI runs. When the two drifted, v0.3.0 and + # 0.16.x disagreed about first-party imports and about `assert x, (msg)` + # wrapping, and each reverted the other's fix — so `prek` could pass while + # CI failed on the very same file. - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.0 + rev: v0.16.0 hooks: - id: ruff args: [--fix] diff --git a/docs/tailwind-plugin.md b/docs/tailwind-plugin.md index 0fd9de8..b846793 100644 --- a/docs/tailwind-plugin.md +++ b/docs/tailwind-plugin.md @@ -36,7 +36,18 @@ Tailwind v4, CSS-first: @plugin "../.venv/lib/python3.12/site-packages/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs"; ``` -Tailwind with a JS config, when you need options: +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: + +```css +@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: ```js import cfUiAxes from "../.venv/lib/python3.12/site-packages/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs"; @@ -79,6 +90,8 @@ Tailwind never tree-shakes; the two concerns are separate. - 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 value or composition name that exists only on `Object.prototype` + (`toString`, `hasOwnProperty`, …), and a value set keyed on `__proto__` **Warns** — `contrastReport: true`: diff --git a/src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs b/src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs index 8734435..51813ef 100644 --- a/src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs +++ b/src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs @@ -103,7 +103,7 @@ function validateValueSet(definition, axis, name, tokens) { } if (definition.modeKeyedAxes.includes(axis)) { - const missing = definition.modes.filter((mode) => !(mode in tokens)); + const missing = definition.modes.filter((mode) => !Object.hasOwn(tokens, mode)); if (missing.length) { throw new AxisPluginError( `axis value ${axis}/${name} is missing ${missing.join(", ")} — light and dark must ` + @@ -170,7 +170,7 @@ export function resolveComposition(definition, composition, valueSets) { if (composition === null || composition === undefined) { chosen = { ...base }; } else if (typeof composition === "string") { - if (!(composition in definition.compositions)) { + if (!Object.hasOwn(definition.compositions, composition)) { const known = Object.keys(definition.compositions).sort().join(", "); throw new AxisPluginError( `unknown composition '${composition}': known compositions ${known}`, @@ -195,7 +195,7 @@ export function resolveComposition(definition, composition, valueSets) { for (const axis of definition.axes) { const value = chosen[axis]; const available = valueSets[axis] ?? {}; - if (!(value in available)) { + if (!Object.hasOwn(available, value)) { const valid = Object.keys(available).sort().join(", ") || "(none)"; throw new AxisPluginError( `unknown value '${value}' for axis '${axis}': valid values are ${valid}`, @@ -222,7 +222,7 @@ function selectorFor(definition, axis, value, mode) { function blockFor(definition, tokens) { const block = { ...tokens }; for (const [name, alias] of Object.entries(definition.aliases)) { - if (name in tokens) block[alias] = `var(${name})`; + if (Object.hasOwn(tokens, name)) block[alias] = `var(${name})`; } return block; } @@ -330,7 +330,7 @@ export function contrastReport(valueSets, contrastPairs, modes = ["light", "dark }; const failures = []; for (const { foreground, background, minimum } of contrastPairs) { - if (!(foreground in tokens) || !(background in tokens)) { + if (!Object.hasOwn(tokens, foreground) || !Object.hasOwn(tokens, background)) { failures.push(`${foreground} on ${background}: token not declared`); continue; } @@ -404,8 +404,18 @@ function createPlugin(options = {}) { * Callable as a factory (`cfUiAxes({...})`) and usable directly as a plugin * (`@plugin "./cf_ui_tailwind_plugin.mjs"`), which is the shape Tailwind's own * `plugin.withOptions` produces. + * + * `__isOptionsFunction` is what makes the CSS-first path accept options: + * + * @plugin "./cf_ui_tailwind_plugin.mjs" { composition: console; } + * + * Without the marker Tailwind rejects that with "does not accept options" — + * and a consumer who cannot pass a composition never gets theirs validated, + * leaving the plugin checking only the default composition, which always + * passes. The build error would be silently inert for the idiomatic v4 setup. */ const cfUiAxes = (options = {}) => createPlugin(options); +cfUiAxes.__isOptionsFunction = true; Object.assign(cfUiAxes, createPlugin({})); export default cfUiAxes; diff --git a/tests/js/plugin.test.mjs b/tests/js/plugin.test.mjs index 0eb4479..1a73195 100644 --- a/tests/js/plugin.test.mjs +++ b/tests/js/plugin.test.mjs @@ -74,6 +74,25 @@ describe("the build error", () => { assert.doesNotThrow(() => cfUiAxes({ composition: name })); } }); + + // Regression: membership was tested with `in`, which walks the prototype + // chain. `"toString" in {}` is true, so an Object.prototype key sailed past + // the check and generated an empty rule instead of failing the build — a + // hole in the one guarantee this plugin exists to provide. Verified against + // a real Tailwind v4 build, which now exits 1. + it("rejects an axis value that only exists on Object.prototype", () => { + assert.throws(() => cfUiAxes({ composition: { form: "toString" } }), /toString/); + }); + + it("rejects a composition name that only exists on Object.prototype", () => { + assert.throws(() => cfUiAxes({ composition: "hasOwnProperty" }), /hasOwnProperty/); + }); + + it("rejects a value set keyed on __proto__", () => { + // JSON.parse makes __proto__ a genuine own property, unlike a literal. + const hostile = JSON.parse('{"form":{"__proto__":{"--cf-radius":"0"}}}'); + assert.throws(() => cfUiAxes({ valueSets: hostile }), /__proto__/); + }); }); describe("plugin shape", () => { @@ -90,6 +109,15 @@ describe("plugin shape", () => { assert.equal(typeof cfUiAxes({}).config, "object"); }); + // Regression: without this marker Tailwind rejects `@plugin "..." { ... }` + // with "does not accept options". A CSS-first consumer then cannot pass a + // composition at all, so the plugin only ever validates the default + // composition — which always passes. The build error would be silently + // inert for the idiomatic v4 setup. Verified against Tailwind v4.3.3. + it("is marked as accepting options, so the CSS-first path can pass them", () => { + assert.equal(cfUiAxes.__isOptionsFunction, true); + }); + it("needs no tailwindcss import to construct", () => { // The file is vendored into consuming apps; a bare import of // `tailwindcss/plugin` would make it unloadable outside a resolved