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/.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/CHANGELOG.md b/CHANGELOG.md index a7f643f..9e875ed 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` - **DaisyUI theme (#6)** — all 14 components in both template sets (`jinja/daisy/*.jinja` and `cotton/_themes/daisy/*.html`), covered by the full three-tier suite including Playwright E2E in `js_on` and `js_off` @@ -18,6 +36,20 @@ coexistence while migrating off another framework - `python -m cf_ui.themes` prints the absolute Tailwind content globs for the installed package, so consumers do not hand-write a site-packages path +- 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. +- `cf_ui/axes.py` — single source of truth for axis definitions, named + compositions, value-set merging, CSS generation, and WCAG contrast checking +- `static/cf_ui/cf_ui_axes.css` — generated from `axes.py` via `python -m cf_ui.axes`, + delivered by the existing asset tags; a unit test fails on drift +- `CF_UI_COMPOSITION`, `CF_UI_AXIS_VALUES`, `CF_UI_AXIS_VALUES_MODE` Django settings, + validated at startup by `CfUiConfig.ready()` +- `{% cf_ui_root_attrs %}` template tag and `cf_ui_root_attrs()` Jinja macro — + one setting in, five attributes out +- `composition=` / `value_sets=` / `value_sets_mode=` on both `install_cf_ui()` + functions, registering the Jinja globals the macros delegate to +- `docs/theming.md` — axis reference, custom value sets, and the contrast requirement ### Changed - E2E harness is parameterized by theme: `make_app(theme)` for the FastAPI @@ -27,6 +59,11 @@ documented one ### 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 - Every DaisyUI variant class is written out in full (`{% if type == 'danger' %}alert-error{% endif %}`, never `alert-{{ type }}`) because Tailwind's scanner reads source text and cannot see a class assembled @@ -45,21 +82,6 @@ `@supports (color: oklch(...))`, which is a no-op in every current browser - Density drives Tailwind v4's `--spacing`; accent aliases to `--color-primary*` -- 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. -- `cf_ui/axes.py` — single source of truth for axis definitions, named - compositions, value-set merging, CSS generation, and WCAG contrast checking -- `static/cf_ui/cf_ui_axes.css` — generated from `axes.py` via `python -m cf_ui.axes`, - delivered by the existing asset tags; a unit test fails on drift -- `CF_UI_COMPOSITION`, `CF_UI_AXIS_VALUES`, `CF_UI_AXIS_VALUES_MODE` Django settings, - validated at startup by `CfUiConfig.ready()` -- `{% cf_ui_root_attrs %}` template tag and `cf_ui_root_attrs()` Jinja macro — - one setting in, five attributes out -- `composition=` / `value_sets=` / `value_sets_mode=` on both `install_cf_ui()` - functions, registering the Jinja globals the macros delegate to -- `docs/theming.md` — axis reference, custom value sets, and the contrast requirement - ## [0.1.1] — 2026-04-28 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 1a2457a..171d9d9 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 @@ -46,7 +48,10 @@ src/cf_ui/ │ ├── cotton/cf/ # 14 public wrappers — , props + dispatch only │ └── cotton/_themes// # 14 theme partials (*.html), included by the wrappers └── 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. @@ -68,6 +73,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 3ee754a..ec73091 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,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..b846793 --- /dev/null +++ b/docs/tailwind-plugin.md @@ -0,0 +1,200 @@ +# 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"; +``` + +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"; + +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 +- a value or composition name that exists only on `Object.prototype` + (`toString`, `hasOwnProperty`, …), and a value set keyed on `__proto__` + +**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/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..51813ef --- /dev/null +++ b/src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs @@ -0,0 +1,421 @@ +/** + * 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) => !Object.hasOwn(tokens, mode)); + 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 (!Object.hasOwn(definition.compositions, composition)) { + 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 (!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}`, + ); + } + } + + 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 (Object.hasOwn(tokens, name)) 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 (!Object.hasOwn(tokens, foreground) || !Object.hasOwn(tokens, background)) { + 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. + * + * `__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 new file mode 100644 index 0000000..1a73195 --- /dev/null +++ b/tests/js/plugin.test.mjs @@ -0,0 +1,363 @@ +/** + * 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 })); + } + }); + + // 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", () => { + 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"); + }); + + // 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 + // 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}" + )