diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..763669a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,10 @@
+# The axis artifacts are generated by `python -m cf_ui.axes`, which writes with
+# newline="\n". Without pinning them, git's autocrlf wants CRLF in the working
+# tree and every regeneration left the file permanently "modified" with a
+# zero-line diff — which makes "is the tree clean" useless as a signal.
+src/cf_ui/static/cf_ui/cf_ui_axes.css text eol=lf
+src/cf_ui/static/cf_ui/cf_ui_axes.json text eol=lf
+
+# The vendored Tailwind plugin is hand-written, but it sits beside the two
+# above and is read by the same Node tooling — keep the whole trio consistent.
+src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs text eol=lf
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e875ed..9e700bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,58 @@
## [Unreleased]
+### Changed — BREAKING (#20)
+
+The axis layer stated four properties it did not enforce. Enforcing them is
+breaking for anyone who was relying, knowingly or not, on the gaps.
+
+- **Token *values* are now validated, not just token names.** A value containing
+ `;`, `{`, `}`, `<`, `/*`, or `*/` raises `AxisConfigError`, and a non-string
+ value is rejected outright. Values are interpolated straight into the
+ `` escaped
+ the element entirely. This mattered most for the deployments cf-ui explicitly
+ targets, where value sets come from a database column or an admin form rather
+ than a hand-written literal. The plugin already rejected these; the two
+ generators now agree, and a test asserts they reject the same inputs.
+ **Migration:** none for hand-written value sets. If a value legitimately needs
+ one of these characters, it is not an axis token.
+- **The `--cf-spacing` → `--spacing` alias is gone.** `--color-primary*` is a
+ name cf-ui owns in a Tailwind context; `--spacing` is the root of Tailwind
+ v4's entire spacing scale, so `data-density` was silently rescaling every
+ `p-4` and `gap-2` in the consuming app by ±20% — including for Bulma consumers
+ using none of it. `--cf-spacing` is still emitted.
+ **Migration:** to keep the old behavior, add `@theme { --spacing: var(--cf-spacing); }`.
+- **Every exported generator validates by default**, on both sides of the
+ language boundary. These were escape hatches that generated CSS with the gate
+ switched off, contradicting the guarantee #7 shipped on — and `style_element`
+ is the very sink the token-value gate above exists to protect, so it emitted
+ an injection payload verbatim when called directly.
+ - JavaScript: `buildAxisBase(sets, definition, { validate: false })`,
+ `buildAxisCss(...)` — same opt-out.
+ - Python: `render_axis_css(sets, banner=True, validate=True)`,
+ `custom_axis_css(sets, banner=False, validate=True)`,
+ `style_element(sets, validate=True)`.
+
+ **Migration:** code passing invalid value sets to any of these now throws.
+ That was already producing malformed CSS. The documented entry points
+ (`CF_UI_AXIS_VALUES`, the FastAPI/Litestar `value_sets=` argument) route
+ through `merge_value_sets` and are unaffected.
+
### Added
+- **The wide-gamut lightness invariant is enforced (#20).** `p3_lightness_failures()`
+ (Python) and `p3LightnessFailures()` (plugin) convert each sRGB base declaration
+ to OKLab and compare it against the `oklch()` override, failing CI beyond 0.5
+ percentage points. The contrast gate is computed against the sRGB base and
+ cannot measure `oklch()`; "the p3 layer holds the same lightness" was the
+ convention carrying that result to wide-gamut displays, held by hand. A typo
+ or a monitor-driven tweak shipped below AA with every test green, invisible to
+ anyone reviewing on an sRGB display.
+- `cf_ui.axes.oklab_lightness()` / `oklch_lightness()`, and their plugin
+ counterparts `oklabLightness()` / `oklchLightness()`
+- `.gitattributes` pinning the generated axis artifacts to LF — `python -m
+ cf_ui.axes` writes `newline="\n"`, so without it every regeneration left a
+ phantom zero-line diff and "is the tree clean" meant nothing
- 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
@@ -80,7 +131,9 @@
- Base declarations are sRGB hex (what the contrast gate is computed against);
wide-gamut chroma is layered behind `@media (color-gamut: p3)` — not
`@supports (color: oklch(...))`, which is a no-op in every current browser
-- Density drives Tailwind v4's `--spacing`; accent aliases to `--color-primary*`
+- Accent aliases to `--color-primary*` for Tailwind v4 interop. Density
+ originally aliased `--cf-spacing` to `--spacing`; that was removed before
+ release (see the breaking change above) and never shipped in a tagged version
## [0.1.1] — 2026-04-28
diff --git a/docs/tailwind-plugin.md b/docs/tailwind-plugin.md
index b846793..6205cb1 100644
--- a/docs/tailwind-plugin.md
+++ b/docs/tailwind-plugin.md
@@ -88,14 +88,16 @@ Tailwind never tree-shakes; the two concerns are separate.
- 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 token value containing `;`, `{`, `}`, `<`, or a comment delimiter — those
+ close the declaration and write rules you did not author
+- a token value that is not a string
- 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
+- a p3 override that does not hold its base declaration's lightness
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
@@ -110,6 +112,55 @@ cf-ui: contrast report — 1 of 12 combinations fail WCAG AA:
--cf-accent-strong on --cf-ground: 1.13 < 4.5
```
+## Every export, and whether it validates
+
+`createPlugin` used to be the only path that ran the gate, so the two exported
+generators were the generator with the build error switched off. Passing junk
+produced junk quietly. Since 0.2.0 **everything validates by default**, and the
+escape hatch has to be asked for.
+
+| Export | Validates | Notes |
+|---|---|---|
+| `default` (`cfUiAxes`) | yes | The plugin. Callable as a factory or used directly. |
+| `buildAxisBase(valueSets?, definition?, options?)` | yes | `{ validate: false }` skips the gate. |
+| `buildAxisCss(valueSets?, definition?, options?)` | yes | Same options as above. |
+| `mergeValueSets(definition, custom, mode?)` | yes | This *is* the gate for value sets. |
+| `resolveComposition(definition, composition, valueSets)` | yes | Throws on an unknown value — the headline feature. |
+| `loadDefinition()` | n/a | Reads `cf_ui_axes.json`; fresh deep copy per call. |
+| `contrastRatio(fg, bg)` | n/a | `null` for anything that is not sRGB hex. |
+| `contrastReport(valueSets, pairs, modes?)` | no | Pure measurement; returns rows, never throws. |
+| `oklabLightness(color)` | n/a | `null` for anything that is not sRGB hex. |
+| `oklchLightness(value)` | n/a | `null` for anything that is not `oklch(...)`. |
+| `p3LightnessFailures(valueSets?, definition?, tolerance?)` | no | Pure measurement; returns messages. |
+| `AxisPluginError` | n/a | Every throw above is one of these. |
+| `P3_LIGHTNESS_TOLERANCE` | n/a | `0.005` — 0.5 percentage points of OKLab L. |
+
+The opt-out exists for legitimate uses — writing the axis CSS to a file,
+inspecting a build, comparing the two generators — and the parity test uses it
+deliberately, so that test compares raw generation rather than narrowing to
+"they agree on input the validator already approved". Do not reach for it with
+input you did not write.
+
+```js
+// Explicit, and only ever for input you control.
+const css = buildAxisCss(mySets, undefined, { validate: false });
+```
+
+The Python generators carry the identical contract, so neither side is the lax
+one:
+
+| Python | JavaScript |
+|---|---|
+| `render_axis_css(sets, banner=True, validate=True)` | `buildAxisCss(sets, definition, { validate })` |
+| `custom_axis_css(sets, banner=False, validate=True)` | — |
+| `style_element(sets, validate=True)` | — |
+| `merge_value_sets(custom, mode)` | `mergeValueSets(definition, custom, mode)` |
+
+```python
+# Same shape, same default, same escape hatch.
+css = render_axis_css(my_sets, validate=False)
+```
+
## One definition, two generators — and a test that proves it
`cf_ui/axes.py` is still the single source of truth. It generates two
diff --git a/docs/theming.md b/docs/theming.md
index 677da0b..bc1f1f5 100644
--- a/docs/theming.md
+++ b/docs/theming.md
@@ -44,8 +44,8 @@ One setting resolves to all five axes.
```python
# settings.py
-CF_UI_COMPOSITION = "editorial" # or a per-axis mapping:
-CF_UI_COMPOSITION = {"accent": "jade"} # unnamed axes fall back to `default`
+CF_UI_COMPOSITION = "editorial" # or a per-axis mapping:
+CF_UI_COMPOSITION = {"accent": "jade"} # unnamed axes fall back to `default`
```
```django
@@ -112,8 +112,8 @@ CF_UI_AXIS_VALUES = {
}
}
}
-CF_UI_AXIS_VALUES_MODE = "extend" # default; "replace" drops the shipped
- # values for each axis you supply
+CF_UI_AXIS_VALUES_MODE = "extend" # default; "replace" drops the shipped
+# values for each axis you supply
CF_UI_COMPOSITION = {"accent": "brand"}
```
@@ -164,8 +164,12 @@ for accent in sets["accent"]:
The shipped set is held to exactly this check in `tests/unit/test_axes.py`.
-> Issue #7 will move this from a runtime check to a build-time one via a
-> Tailwind v4 plugin, so an inaccessible value set cannot compile.
+> The Tailwind plugin (#7) surfaces this at build time as well, via
+> `contrastReport: true`. It **warns** rather than failing the build: an unknown
+> axis value is always a mistake, but a ratio below AA may be a deliberate
+> choice in a value set cf-ui did not ship, and making it fatal would mean this
+> package decides when your app may compile. See
+> [the plugin docs](tailwind-plugin.md#what-fails-the-build-and-what-only-warns).
### Why `--cf-accent-strong` exists
@@ -211,6 +215,31 @@ guarantee carries over.
Near-neutral accents (`slate`) ship no p3 block; there is no wide-gamut chroma
to recover.
+### The lightness invariant is enforced, not assumed
+
+"Holds lightness constant" used to be a convention maintained by hand. It is now
+a gate: `p3_lightness_failures()` converts each sRGB base declaration to OKLab
+and compares its lightness against the `oklch()` override, and a unit test fails
+CI if any override drifts by more than **0.5 percentage points**.
+
+That tolerance is set from the shipped data — the overrides are authored to one
+decimal place, which puts them within 0.05pp of their base, so 0.5pp leaves an
+order of magnitude of headroom while still catching any drift large enough to
+change the contrast result.
+
+```python
+from cf_ui.axes import p3_lightness_failures, DEFAULT_VALUE_SETS
+
+p3_lightness_failures(DEFAULT_VALUE_SETS) # [] — shippable
+```
+
+The same check runs in the Tailwind plugin as `p3LightnessFailures()`, with
+byte-identical messages; a parity test compares the two. Because the sRGB
+contrast gate cannot measure `oklch()` directly, this invariant is what carries
+the WCAG result over to wide-gamut displays — without it, a p3 override could
+ship below AA with every test green, invisible to anyone reviewing on an sRGB
+display.
+
---
## Tailwind interop
@@ -223,10 +252,30 @@ a Tailwind-based theme picks the axes up without per-component work:
| `--cf-accent` | `--color-primary` |
| `--cf-accent-content` | `--color-primary-content` |
| `--cf-accent-strong` | `--color-primary-strong` |
-| `--cf-spacing` | `--spacing` |
-Density therefore drives Tailwind's spacing base unit, so it propagates through
-every spacing utility. The aliases are inert when no Tailwind is present.
+The aliases are inert when no Tailwind is present.
+
+### `--spacing` is not aliased
+
+`--cf-spacing` is emitted, but it is **not** wired to Tailwind's `--spacing`.
+
+`--color-primary` is a name cf-ui effectively owns in a Tailwind context.
+`--spacing` is the root of Tailwind v4's *entire* spacing scale — `p-4`, `gap-2`,
+`m-8` and every other spacing utility derive from it. Aliasing it meant
+`data-density="compact"` silently rescaled the whole consuming app by ±20%,
+scoped to an attribute the app set for cf-ui's benefit. A Bulma consumer who
+also ran Tailwind got that as pure collateral damage.
+
+Apps that *want* density to drive the Tailwind scale opt in with one line:
+
+```css
+@theme {
+ --spacing: var(--cf-spacing);
+}
+```
+
+> **Changed in 0.2.0.** This alias used to be emitted for every theme. If you
+> were relying on it, add the rule above.
---
diff --git a/src/cf_ui/axes.py b/src/cf_ui/axes.py
index 0ae4847..cfcd249 100644
--- a/src/cf_ui/axes.py
+++ b/src/cf_ui/axes.py
@@ -43,6 +43,7 @@
"DEFAULT_VALUE_SETS",
"MODES",
"MODE_KEYED_AXES",
+ "P3_LIGHTNESS_TOLERANCE",
"AxisConfigError",
"axis_definition",
"build_axis_globals",
@@ -50,6 +51,9 @@
"contrast_ratio",
"custom_axis_css",
"merge_value_sets",
+ "oklab_lightness",
+ "oklch_lightness",
+ "p3_lightness_failures",
"render_axis_css",
"resolve_composition",
"root_attrs",
@@ -82,15 +86,30 @@ class AxisConfigError(ValueError):
#: 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.
+#:
+#: ``--spacing`` is deliberately **not** here. ``--color-primary*`` is a name
+#: cf-ui effectively owns in a Tailwind context; ``--spacing`` is the root of
+#: Tailwind v4's entire spacing scale, so aliasing it made ``data-density``
+#: silently rescale every ``p-4`` and ``gap-2`` in the consuming app — including
+#: for a Bulma consumer using none of it. Apps that want that opt in with one
+#: line: ``@theme { --spacing: var(--cf-spacing); }``.
_ALIASES: dict[str, str] = {
"--cf-accent": "--color-primary",
"--cf-accent-content": "--color-primary-content",
"--cf-accent-strong": "--color-primary-strong",
- "--cf-spacing": "--spacing",
}
_VALUE_NAME = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
+#: Token values are interpolated straight into generated CSS, which
+#: :func:`style_element` injects into the page. A value carrying ``;`` or a
+#: brace closes its own declaration and writes rules the app never authored;
+#: ```` escapes the element entirely. Rejected rather than escaped —
+#: no legitimate axis token needs one, and escaping invites a second round of
+#: "but what about...". Kept identical to ``UNSAFE_VALUE`` in
+#: ``cf_ui_tailwind_plugin.mjs``; a test asserts both reject the same inputs.
+_UNSAFE_VALUE = re.compile(r"[;{}<]|/\*|\*/")
+
# ---------------------------------------------------------------------------
# Shipped value sets
@@ -304,6 +323,14 @@ def _validate_value_set(axis: str, name: str, tokens: Any) -> None:
)
for mode in MODES:
_validate_tokens(axis, name, tokens[mode])
+ # The p3 block reaches the same stylesheet, so it gets the same gate.
+ gamut = tokens.get("p3")
+ if gamut:
+ if not isinstance(gamut, Mapping):
+ raise AxisConfigError(f"axis value {axis}/{name} has a malformed 'p3' block")
+ for mode in MODES:
+ if gamut.get(mode):
+ _validate_tokens(axis, name, gamut[mode])
else:
_validate_tokens(axis, name, tokens)
@@ -311,12 +338,39 @@ def _validate_value_set(axis: str, name: str, tokens: Any) -> None:
def _validate_tokens(axis: str, name: str, tokens: Any) -> None:
if not isinstance(tokens, Mapping):
raise AxisConfigError(f"axis value {axis}/{name} must be a mapping of custom properties")
- for token in tokens:
+ for token, value in tokens.items():
if not str(token).startswith("--"):
raise AxisConfigError(
f"axis value {axis}/{name} declares {token!r}: axis tokens must be CSS "
"custom properties (a name starting with '--')"
)
+ if not isinstance(value, str):
+ raise AxisConfigError(
+ f"axis value {axis}/{name} declares a non-string {token}: axis token values "
+ "must be strings"
+ )
+ if _UNSAFE_VALUE.search(value):
+ raise AxisConfigError(
+ f"axis value {axis}/{name} declares {token} with an unsafe value: a token may "
+ "not contain ';', '{', '}', '<', or a comment delimiter"
+ )
+
+
+def _validate_value_sets(value_sets: Mapping[str, Mapping[str, Any]]) -> None:
+ """Run a whole ``{axis: {name: tokens}}`` mapping through the gate.
+
+ Mirrors ``validateSets`` in ``cf_ui_tailwind_plugin.mjs``. Factored out so
+ the generators below can apply the same checks without going through
+ :func:`merge_value_sets` — an exported function that generates CSS with the
+ validation switched off is the asymmetry this exists to prevent.
+ """
+ for axis, values in value_sets.items():
+ if axis not in AXES:
+ raise AxisConfigError(f"unknown axis {axis!r}: valid axes are {', '.join(AXES)}")
+ if not isinstance(values, Mapping):
+ raise AxisConfigError(f"axis {axis!r} must map value names to tokens")
+ for name, tokens in values.items():
+ _validate_value_set(axis, name, tokens)
def merge_value_sets(
@@ -341,13 +395,8 @@ def merge_value_sets(
if not custom:
return merged
+ _validate_value_sets(custom)
for axis, values in custom.items():
- if axis not in AXES:
- raise AxisConfigError(f"unknown axis {axis!r}: valid axes are {', '.join(AXES)}")
- if not isinstance(values, Mapping):
- raise AxisConfigError(f"axis {axis!r} must map value names to tokens")
- for name, tokens in values.items():
- _validate_value_set(axis, name, tokens)
if mode == "replace":
merged[axis] = deepcopy(dict(values))
else:
@@ -479,8 +528,20 @@ def _selector(axis: str, value: str, mode: str | None = None) -> str:
def render_axis_css(
value_sets: Mapping[str, Mapping[str, Any]],
banner: bool = True,
+ validate: bool = True,
) -> str:
- """Render axis value sets as CSS custom properties keyed on data attributes."""
+ """Render axis value sets as CSS custom properties keyed on data attributes.
+
+ Args:
+ value_sets: ``{axis: {value_name: tokens}}``.
+ banner: prepend the generated-file banner.
+ validate: on by default. ``False`` skips the gate — an explicit escape
+ hatch for inspecting output or comparing generators, never
+ something to reach for with input you did not write. Mirrors
+ ``buildAxisCss(..., { validate: false })`` in the Tailwind plugin.
+ """
+ if validate:
+ _validate_value_sets(value_sets)
parts: list[str] = [_BANNER] if banner else []
for axis in AXES:
@@ -512,12 +573,20 @@ def render_axis_css(
return "\n".join(parts)
-def custom_axis_css(value_sets: Mapping[str, Mapping[str, Any]], banner: bool = False) -> str:
+def custom_axis_css(
+ value_sets: Mapping[str, Mapping[str, Any]],
+ banner: bool = False,
+ validate: bool = True,
+) -> str:
"""Render only the values the shipped stylesheet does not already cover.
A value is emitted when it is new, or when it shadows a shipped value name
with different tokens — in which case the static asset's copy is stale.
+
+ Validates by default; see :func:`render_axis_css` for the opt-out.
"""
+ if validate:
+ _validate_value_sets(value_sets)
diff: dict[str, dict[str, Any]] = {}
for axis in AXES:
for value, tokens in value_sets.get(axis, {}).items():
@@ -525,12 +594,19 @@ def custom_axis_css(value_sets: Mapping[str, Mapping[str, Any]], banner: bool =
diff.setdefault(axis, {})[value] = tokens
if not diff:
return ""
- return render_axis_css(diff, banner=banner)
+ # Already checked above (or deliberately skipped) — do not pay for it twice.
+ return render_axis_css(diff, banner=banner, validate=False)
-def style_element(value_sets: Mapping[str, Mapping[str, Any]]) -> str:
- """Wrap :func:`custom_axis_css` in a style element, or return ``""``."""
- css = custom_axis_css(value_sets)
+def style_element(value_sets: Mapping[str, Mapping[str, Any]], validate: bool = True) -> str:
+ """Wrap :func:`custom_axis_css` in a style element, or return ``""``.
+
+ This is the injection sink the token-value gate exists for: whatever it
+ returns is marked safe and rendered into the page. It validates by default
+ for that reason — the opt-out is for callers generating output they are not
+ about to inject.
+ """
+ css = custom_axis_css(value_sets, validate=validate)
return f"" if css else ""
@@ -608,6 +684,122 @@ def contrast_failures(
return failures
+# ---------------------------------------------------------------------------
+# Wide-gamut lightness invariant
+#
+# The contrast gate above is computed against the sRGB base declarations. The
+# p3 block is what actually renders on a wide-gamut display, and it is never
+# fed through that gate — `_relative_luminance` would reject oklch outright,
+# which is why the p3 block is structured separately in the first place.
+#
+# What makes the sRGB result carry over is the design rule that a p3 override
+# extends *chroma only*, holding lightness constant. That was a convention held
+# by hand: a typo, or a tweak that looked better on someone's monitor, shipped
+# with every test green and was invisible to anyone reviewing on an sRGB
+# display. This enforces it.
+#
+# Checking the invariant beats re-running WCAG over oklch: it is a smaller and
+# more precise check, and it is the property the design actually claims.
+# ---------------------------------------------------------------------------
+
+#: Maximum allowed lightness drift between a p3 override and its sRGB base, in
+#: OKLab L (0..1). The shipped overrides are authored to one decimal place of a
+#: percentage, so they sit within 0.0005 of their base; 0.005 is an order of
+#: magnitude of headroom for that rounding while still catching any drift big
+#: enough to matter — a meaningful lightness error moves several points.
+P3_LIGHTNESS_TOLERANCE = 0.005
+
+# sRGB -> linear -> LMS -> OKLab, per Björn Ottosson's definition. Only the L
+# component is needed, so the a/b rows are omitted.
+_OKLAB_M1 = (
+ (0.4122214708, 0.5363325363, 0.0514459929),
+ (0.2119034982, 0.6806995451, 0.1073969566),
+ (0.0883024619, 0.2817188376, 0.6299787005),
+)
+_OKLAB_L = (0.2104542553, 0.7936177850, -0.0040720468)
+
+_OKLCH = re.compile(r"^\s*oklch\(\s*([0-9]*\.?[0-9]+)(%?)\s", re.IGNORECASE)
+
+
+def _linear_channel(channel: float) -> float:
+ return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
+
+
+def oklab_lightness(color: str) -> float:
+ """OKLab lightness (0..1) of an sRGB hex color.
+
+ This is the same quantity ``oklch()`` states as its first component, which
+ is what makes the two directly comparable.
+ """
+ value = str(color).strip().lstrip("#")
+ if len(value) == 3:
+ value = "".join(char * 2 for char in value)
+ if len(value) != 6 or not all(char in "0123456789abcdefABCDEF" for char in value):
+ raise AxisConfigError(
+ f"cannot measure lightness of {color!r}: axis base declarations must be sRGB hex"
+ )
+ rgb = [_linear_channel(int(value[index : index + 2], 16) / 255) for index in (0, 2, 4)]
+ cones = [sum(row[i] * rgb[i] for i in range(3)) for row in _OKLAB_M1]
+ roots = [cone ** (1 / 3) for cone in cones]
+ return sum(_OKLAB_L[i] * roots[i] for i in range(3))
+
+
+def oklch_lightness(value: str) -> float:
+ """Lightness (0..1) declared by an ``oklch(...)`` value."""
+ match = _OKLCH.match(str(value))
+ if not match:
+ raise AxisConfigError(f"not an oklch() value: {value!r}")
+ number = float(match.group(1))
+ return number / 100 if match.group(2) else number
+
+
+def p3_lightness_failures(
+ value_sets: Mapping[str, Mapping[str, Any]],
+ tolerance: float = P3_LIGHTNESS_TOLERANCE,
+) -> list[str]:
+ """Return every p3 override that does not hold its base declaration's lightness.
+
+ An empty list means the wide-gamut layer preserves the contrast guarantee
+ the sRGB base was measured for.
+
+ The message format is mirrored exactly by ``p3LightnessFailures`` in
+ ``cf_ui_tailwind_plugin.mjs`` — a parity test compares the two outputs.
+ """
+ failures: list[str] = []
+ limit = tolerance * 100
+
+ for axis in AXES:
+ for name, tokens in value_sets.get(axis, {}).items():
+ gamut = tokens.get("p3") if isinstance(tokens, Mapping) else None
+ if not gamut:
+ continue
+ for mode in MODES:
+ overrides = gamut.get(mode) or {}
+ base = tokens.get(mode) or {}
+ for token, override in overrides.items():
+ where = f"{axis}/{name}/{mode}: {token}"
+ if token not in base:
+ failures.append(f"{where} has a p3 override but no base declaration")
+ continue
+ try:
+ actual = oklch_lightness(override) * 100
+ except AxisConfigError:
+ failures.append(f"{where} p3 override is not an oklch() value: {override}")
+ continue
+ try:
+ expected = oklab_lightness(base[token]) * 100
+ except AxisConfigError:
+ failures.append(f"{where} base declaration is not sRGB hex: {base[token]}")
+ continue
+ drift = abs(actual - expected)
+ if drift > limit:
+ failures.append(
+ f"{where} p3 lightness {actual:.2f}% deviates from base "
+ f"{expected:.2f}% by {drift:.2f}pp (tolerance {limit:.2f}pp)"
+ )
+ return failures
+
+
# ---------------------------------------------------------------------------
# Cross-language export
# ---------------------------------------------------------------------------
diff --git a/src/cf_ui/static/cf_ui/cf_ui_axes.css b/src/cf_ui/static/cf_ui/cf_ui_axes.css
index 4c580bd..abaf1b4 100644
--- a/src/cf_ui/static/cf_ui/cf_ui_axes.css
+++ b/src/cf_ui/static/cf_ui/cf_ui_axes.css
@@ -124,21 +124,18 @@
--cf-spacing: 0.2rem;
--cf-line-height: 1.4;
--cf-control-height: 2rem;
- --spacing: var(--cf-spacing);
}
[data-density="regular"] {
--cf-spacing: 0.25rem;
--cf-line-height: 1.5;
--cf-control-height: 2.5rem;
- --spacing: var(--cf-spacing);
}
[data-density="roomy"] {
--cf-spacing: 0.3rem;
--cf-line-height: 1.65;
--cf-control-height: 3rem;
- --spacing: var(--cf-spacing);
}
/* --- type ------------------------------------------------ */
diff --git a/src/cf_ui/static/cf_ui/cf_ui_axes.json b/src/cf_ui/static/cf_ui/cf_ui_axes.json
index d46e71f..95c7ebe 100644
--- a/src/cf_ui/static/cf_ui/cf_ui_axes.json
+++ b/src/cf_ui/static/cf_ui/cf_ui_axes.json
@@ -24,8 +24,7 @@
"aliases": {
"--cf-accent": "--color-primary",
"--cf-accent-content": "--color-primary-content",
- "--cf-accent-strong": "--color-primary-strong",
- "--cf-spacing": "--spacing"
+ "--cf-accent-strong": "--color-primary-strong"
},
"valueNamePattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"valueSets": {
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 51813ef..c429199 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
@@ -65,8 +65,13 @@ export function loadDefinition() {
* `;` 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.
+ *
+ * `<` joined the set in #20: on the Python side the same tokens reach a
+ * `` escapes the element entirely. Both
+ * generators carry the identical rule, and a parity test asserts they reject
+ * the same inputs.
*/
-const UNSAFE_VALUE = /[;{}]|\/\*|\*\//;
+const UNSAFE_VALUE = /[;{}<]|\/\*|\*\//;
function validateTokens(axis, name, tokens) {
if (tokens === null || typeof tokens !== "object" || Array.isArray(tokens)) {
@@ -85,7 +90,7 @@ function validateTokens(axis, name, tokens) {
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`,
+ `contain ';', '{', '}', '<', or a comment delimiter`,
);
}
}
@@ -227,13 +232,45 @@ function blockFor(definition, tokens) {
return block;
}
+/**
+ * Run every supplied value set through the same gate `mergeValueSets` applies.
+ *
+ * `createPlugin` used to be the only path that validated, so the two exported
+ * generators below were the generator with the build error switched off — junk
+ * in produced junk quietly, contradicting the promise #7 shipped on. They now
+ * validate by default and the escape hatch has to be asked for (#20 §4).
+ */
+function validateSets(definition, valueSets) {
+ for (const [axis, values] of Object.entries(valueSets)) {
+ 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);
+ }
+ }
+}
+
/**
* Build the axis rules as a Tailwind `addBase` object, wide-gamut layer
* included.
+ *
+ * @param {object} [valueSets] Defaults to the shipped sets.
+ * @param {object} [definition] Defaults to the shipped definition.
+ * @param {object} [options]
+ * @param {boolean} [options.validate] On by default. `false` skips the gate —
+ * an explicit escape hatch for inspecting a build or comparing generators,
+ * never something to reach for with untrusted input.
*/
-export function buildAxisBase(valueSets, definition) {
+export function buildAxisBase(valueSets, definition, options = {}) {
const def = definition ?? loadDefinition();
const sets = valueSets ?? def.valueSets;
+ if (options.validate !== false) validateSets(def, sets);
const base = {};
for (const axis of def.axes) {
@@ -269,9 +306,12 @@ export function buildAxisBase(valueSets, definition) {
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);
+/**
+ * The same rules as CSS text, for writing to a file or eyeballing a build.
+ * Validates by default; see {@link buildAxisBase} for the opt-out.
+ */
+export function buildAxisCss(valueSets, definition, options = {}) {
+ const base = buildAxisBase(valueSets, definition, options);
const render = (rules, indent) =>
Object.entries(rules)
.map(([selector, block]) => {
@@ -318,6 +358,12 @@ export function contrastRatio(foreground, background) {
* 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.
+ *
+ * Measures the sRGB base declarations only — `oklch()` returns "cannot be
+ * checked" here by design, because a ratio is not the right question for the
+ * p3 layer. What that layer has to hold is its base's *lightness*, which is
+ * what {@link p3LightnessFailures} checks; the two together cover the wide
+ * gamut. This function does not validate and never throws.
*/
export function contrastReport(valueSets, contrastPairs, modes = ["light", "dark"]) {
const rows = [];
@@ -350,6 +396,109 @@ export function contrastReport(valueSets, contrastPairs, modes = ["light", "dark
return rows;
}
+// --- wide-gamut lightness invariant ----------------------------------------
+//
+// `contrastReport` above measures the sRGB base declarations and, for anything
+// else, said "not an sRGB hex value, cannot be checked" and moved on. The p3
+// block is what actually renders on a wide-gamut display, so that shrug was
+// the whole wide-gamut layer going unchecked.
+//
+// What carries the sRGB result over is the design rule that a p3 override
+// extends chroma only, holding lightness constant. This enforces it, mirroring
+// `cf_ui.axes.p3_lightness_failures` message for message — a parity test in
+// `tests/unit/test_tailwind_plugin.py` compares the two outputs directly.
+
+/** @see cf_ui.axes.P3_LIGHTNESS_TOLERANCE — keep the two in step. */
+export const P3_LIGHTNESS_TOLERANCE = 0.005;
+
+const OKLAB_M1 = [
+ [0.4122214708, 0.5363325363, 0.0514459929],
+ [0.2119034982, 0.6806995451, 0.1073969566],
+ [0.0883024619, 0.2817188376, 0.6299787005],
+];
+const OKLAB_L = [0.2104542553, 0.793617785, -0.0040720468];
+const OKLCH = /^\s*oklch\(\s*([0-9]*\.?[0-9]+)(%?)\s/i;
+
+/** OKLab lightness (0..1) of an sRGB hex color, or null if it is not one. */
+export function oklabLightness(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 rgb = [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;
+ });
+ const roots = OKLAB_M1.map((row) => Math.cbrt(row.reduce((sum, k, i) => sum + k * rgb[i], 0)));
+ return OKLAB_L.reduce((sum, k, i) => sum + k * roots[i], 0);
+}
+
+/** Lightness (0..1) declared by an `oklch(...)` value, or null. */
+export function oklchLightness(value) {
+ const match = OKLCH.exec(String(value));
+ if (!match) return null;
+ const number = Number.parseFloat(match[1]);
+ return match[2] ? number / 100 : number;
+}
+
+/**
+ * Every p3 override that does not hold its base declaration's lightness.
+ *
+ * An empty array means the wide-gamut layer preserves the contrast guarantee
+ * the sRGB base was measured for.
+ */
+export function p3LightnessFailures(valueSets, definition, tolerance = P3_LIGHTNESS_TOLERANCE) {
+ const def = definition ?? loadDefinition();
+ const sets = valueSets ?? def.valueSets;
+ const failures = [];
+ const limit = tolerance * 100;
+
+ for (const axis of def.axes) {
+ for (const [name, tokens] of Object.entries(sets[axis] ?? {})) {
+ const gamut = tokens && typeof tokens === "object" ? tokens.p3 : null;
+ if (!gamut) continue;
+ for (const mode of def.modes) {
+ const overrides = gamut[mode] ?? {};
+ const base = tokens[mode] ?? {};
+ for (const [token, override] of Object.entries(overrides)) {
+ const where = `${axis}/${name}/${mode}: ${token}`;
+ if (!Object.hasOwn(base, token)) {
+ failures.push(`${where} has a p3 override but no base declaration`);
+ continue;
+ }
+ const actual = oklchLightness(override);
+ if (actual === null) {
+ failures.push(`${where} p3 override is not an oklch() value: ${override}`);
+ continue;
+ }
+ const expected = oklabLightness(base[token]);
+ if (expected === null) {
+ failures.push(`${where} base declaration is not sRGB hex: ${base[token]}`);
+ continue;
+ }
+ const drift = Math.abs(actual * 100 - expected * 100);
+ if (drift > limit) {
+ failures.push(
+ `${where} p3 lightness ${(actual * 100).toFixed(2)}% deviates from base ` +
+ `${(expected * 100).toFixed(2)}% by ${drift.toFixed(2)}pp ` +
+ `(tolerance ${limit.toFixed(2)}pp)`,
+ );
+ }
+ }
+ }
+ }
+ }
+ return failures;
+}
+
+function warnP3(failures) {
+ if (!failures.length) return;
+ console.warn(
+ `cf-ui: ${failures.length} wide-gamut override(s) do not hold their base lightness, so ` +
+ `the sRGB contrast result does not carry over:\n ${failures.join("\n ")}`,
+ );
+}
+
function warnContrast(rows) {
const failing = rows.filter((row) => row.failures.length);
if (!failing.length) {
@@ -391,6 +540,10 @@ function createPlugin(options = {}) {
if (options.contrastReport) {
warnContrast(contrastReport(valueSets, definition.contrastPairs, definition.modes));
+ // The sRGB rows above are only half the picture: on a wide-gamut display
+ // the p3 layer is what renders, and it inherits the contrast result only
+ // while it holds the base lightness.
+ warnP3(p3LightnessFailures(valueSets, definition));
}
const base = buildAxisBase(valueSets, definition);
diff --git a/tests/js/plugin.test.mjs b/tests/js/plugin.test.mjs
index 1a73195..8409971 100644
--- a/tests/js/plugin.test.mjs
+++ b/tests/js/plugin.test.mjs
@@ -19,6 +19,8 @@ import cfUiAxes, {
contrastReport,
loadDefinition,
mergeValueSets,
+ oklabLightness,
+ p3LightnessFailures,
resolveComposition,
} from "../../src/cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs";
@@ -361,3 +363,136 @@ describe("buildAxisBase", () => {
assert.ok(base['[data-accent="slate"]']);
});
});
+
+// --- issue #20 --------------------------------------------------------------
+
+describe("unsafe token values (#20 §1)", () => {
+ const UNSAFE = [
+ "0; } body { display: none",
+ "red; color: blue",
+ "}",
+ "{",
+ "red /* c",
+ "red */",
+ "",
+ "<",
+ ];
+
+ for (const value of UNSAFE) {
+ it(`rejects ${JSON.stringify(value)}`, () => {
+ assert.throws(
+ () => mergeValueSets(DEFINITION, { form: { probe: { "--cf-radius": value } } }),
+ /unsafe value/,
+ );
+ });
+ }
+
+ it("rejects '<' so a value cannot escape the " } } }),
+ /unsafe value/,
+ );
+ });
+
+ it("still accepts a shadow value carrying slashes and parentheses", () => {
+ const merged = mergeValueSets(DEFINITION, {
+ form: { probe: { "--cf-radius": "0 1px 2px rgb(0 0 0 / 0.08)" } },
+ });
+ assert.equal(merged.form.probe["--cf-radius"], "0 1px 2px rgb(0 0 0 / 0.08)");
+ });
+});
+
+describe("the exported generators validate by default (#20 §4)", () => {
+ const BAD_NAME = { form: { probe: { "cf-radius": "0" } } };
+ const BAD_VALUE = { form: { probe: { "--cf-radius": "0; } html {" } } };
+
+ it("buildAxisBase rejects a token name that is not a custom property", () => {
+ assert.throws(() => buildAxisBase(BAD_NAME), /custom propert/);
+ });
+
+ it("buildAxisBase rejects an unsafe token value", () => {
+ assert.throws(() => buildAxisBase(BAD_VALUE), /unsafe value/);
+ });
+
+ it("buildAxisCss rejects the same input", () => {
+ assert.throws(() => buildAxisCss(BAD_VALUE), /unsafe value/);
+ });
+
+ it("buildAxisBase rejects an invalid axis value name", () => {
+ assert.throws(() => buildAxisBase({ form: { "Not Valid": { "--cf-radius": "0" } } }), /Not Valid/);
+ });
+
+ it("the opt-out is explicit, and still generates", () => {
+ const base = buildAxisBase(BAD_VALUE, undefined, { validate: false });
+ assert.equal(base['[data-form="probe"]']["--cf-radius"], "0; } html {");
+ });
+
+ it("the opt-out does not change what valid input generates", () => {
+ const checked = buildAxisBase(DEFINITION.valueSets, DEFINITION);
+ const unchecked = buildAxisBase(DEFINITION.valueSets, DEFINITION, { validate: false });
+ assert.deepEqual(unchecked, checked);
+ });
+
+ it("validates the shipped defaults without complaint", () => {
+ assert.ok(buildAxisBase()['[data-accent="slate"]']);
+ });
+});
+
+describe("the p3 lightness invariant (#20 §3)", () => {
+ it("passes for the shipped value sets", () => {
+ assert.deepEqual(p3LightnessFailures(DEFINITION.valueSets, DEFINITION), []);
+ });
+
+ it("flags an override whose lightness drifts from the base", () => {
+ const drifted = structuredClone(DEFINITION.valueSets);
+ drifted.accent.azure.p3.light["--cf-accent"] = "oklch(72.0% 0.137 242.7)";
+ const failures = p3LightnessFailures(drifted, DEFINITION);
+ assert.equal(failures.length, 1);
+ assert.match(failures[0], /azure/);
+ assert.match(failures[0], /--cf-accent/);
+ });
+
+ it("tolerates the rounding in an authored one-decimal value", () => {
+ const rounded = structuredClone(DEFINITION.valueSets);
+ // The measured base is 49.998%; the authored 50.0% must stay legal.
+ rounded.accent.azure.p3.light["--cf-accent"] = "oklch(50.0% 0.137 242.7)";
+ assert.deepEqual(p3LightnessFailures(rounded, DEFINITION), []);
+ });
+
+ it("flags a p3 override of a token the base never declared", () => {
+ const orphan = structuredClone(DEFINITION.valueSets);
+ orphan.accent.azure.p3.light["--cf-nonexistent"] = "oklch(50% 0.1 240)";
+ assert.match(p3LightnessFailures(orphan, DEFINITION).join("\n"), /--cf-nonexistent/);
+ });
+
+ it("flags a p3 value that is not oklch rather than skipping it", () => {
+ const bad = structuredClone(DEFINITION.valueSets);
+ bad.accent.azure.p3.light["--cf-accent"] = "#0369a1";
+ assert.equal(p3LightnessFailures(bad, DEFINITION).length, 1);
+ });
+
+ it("measures oklab lightness against known anchors", () => {
+ assert.ok(Math.abs(oklabLightness("#000000") - 0) < 0.001);
+ assert.ok(Math.abs(oklabLightness("#ffffff") - 1) < 0.001);
+ assert.ok(Math.abs(oklabLightness("#0369a1") - 0.5) < 0.002);
+ });
+});
+
+describe("the --spacing alias is gone (#20 §2)", () => {
+ it("no longer aliases Tailwind's spacing base unit", () => {
+ assert.equal(DEFINITION.aliases["--cf-spacing"], undefined);
+ const base = buildAxisBase();
+ assert.equal(base['[data-density="compact"]']["--spacing"], undefined);
+ });
+
+ it("still emits the cf-ui token itself", () => {
+ const base = buildAxisBase();
+ assert.equal(base['[data-density="compact"]']["--cf-spacing"], "0.2rem");
+ });
+
+ it("keeps the namespaced color aliases", () => {
+ const base = buildAxisBase();
+ assert.equal(base['[data-accent="azure"]']["--color-primary"], "var(--cf-accent)");
+ });
+});
diff --git a/tests/unit/test_axes.py b/tests/unit/test_axes.py
index a9bc2f8..bfb6fe0 100644
--- a/tests/unit/test_axes.py
+++ b/tests/unit/test_axes.py
@@ -88,11 +88,19 @@ def test_p3_layer_is_layered_over_an_srgb_base_declaration():
assert "oklch(" in p3
-def test_density_drives_tailwind_spacing_base_unit():
+def test_density_does_not_alias_tailwinds_spacing_base_unit():
+ """#20: --spacing is Tailwind's whole spacing scale, not a name cf-ui owns.
+
+ Aliasing it meant `data-density` silently rescaled every `p-4` and `gap-2`
+ in the consuming app, including for Bulma consumers who use none of it.
+ `--cf-spacing` is still emitted; wiring it up is now the app's explicit
+ one-line opt-in.
+ """
from cf_ui.axes import AXIS_CSS_PATH
css = AXIS_CSS_PATH.read_text(encoding="utf-8")
- assert "--spacing:" in css
+ assert "--cf-spacing:" in css, "the axis token itself must still be emitted"
+ assert "--spacing:" not in css.replace("--cf-spacing:", "")
def test_accent_exposes_a_strong_token_for_small_text():
@@ -409,3 +417,237 @@ def test_light_mode_css_is_the_unqualified_declaration():
css = AXIS_CSS_PATH.read_text(encoding="utf-8")
assert '[data-theme="light"]' not in css
+
+
+# --------------------------------------------------------------------------
+# Issue #20 §1: token *values* are validated, not just token names.
+#
+# Values are interpolated straight into a " escapes the element entirely.
+# --------------------------------------------------------------------------
+
+UNSAFE_VALUES = [
+ pytest.param("0; } body { display: none", id="semicolon-and-braces"),
+ pytest.param("red; color: blue", id="semicolon"),
+ pytest.param("}", id="close-brace"),
+ pytest.param("{", id="open-brace"),
+ pytest.param("red /* c", id="comment-open"),
+ pytest.param("red */", id="comment-close"),
+ pytest.param("", id="style-escape"),
+ pytest.param("<", id="angle-bracket"),
+]
+
+
+@pytest.mark.parametrize("value", UNSAFE_VALUES)
+def test_unsafe_token_values_are_rejected(value):
+ from cf_ui.axes import AxisConfigError, merge_value_sets
+
+ with pytest.raises(AxisConfigError, match="unsafe value"):
+ merge_value_sets({"form": {"evil": {"--cf-radius": value}}})
+
+
+@pytest.mark.parametrize("value", UNSAFE_VALUES)
+def test_unsafe_token_values_are_rejected_inside_a_mode_block(value):
+ """Mode-keyed axes validate through a different branch — cover it too."""
+ from cf_ui.axes import AxisConfigError, merge_value_sets
+
+ payload = {
+ "accent": {
+ "evil": {
+ "light": {"--cf-accent": value},
+ "dark": {"--cf-accent": "#ffffff"},
+ }
+ }
+ }
+ with pytest.raises(AxisConfigError, match="unsafe value"):
+ merge_value_sets(payload)
+
+
+def test_unsafe_token_values_are_rejected_inside_a_p3_block():
+ """The p3 block reaches the same stylesheet, so it needs the same gate."""
+ from cf_ui.axes import AxisConfigError, merge_value_sets
+
+ payload = {
+ "accent": {
+ "evil": {
+ "light": {"--cf-accent": "#0369a1"},
+ "dark": {"--cf-accent": "#38bdf8"},
+ "p3": {"light": {"--cf-accent": "oklch(50% 0.1 240); } html { x: y"}},
+ }
+ }
+ }
+ with pytest.raises(AxisConfigError, match="unsafe value"):
+ merge_value_sets(payload)
+
+
+def test_a_non_string_token_value_is_rejected():
+ """Mirrors the plugin, which rejects anything that is not a string."""
+ from cf_ui.axes import AxisConfigError, merge_value_sets
+
+ with pytest.raises(AxisConfigError, match="non-string"):
+ merge_value_sets({"form": {"odd": {"--cf-radius": 0}}})
+
+
+@pytest.mark.parametrize(
+ "value",
+ ["0", "0.375rem", "none", "0 1px 2px rgb(0 0 0 / 0.08)", "#ffffff", "1.25"],
+)
+def test_legitimate_token_values_are_still_accepted(value):
+ from cf_ui.axes import merge_value_sets
+
+ merged = merge_value_sets({"form": {"fine": {"--cf-radius": value}}})
+ assert merged["form"]["fine"]["--cf-radius"] == value
+
+
+def test_no_shipped_value_trips_the_new_rule():
+ """Font stacks carry commas and quotes — they must survive the gate."""
+ from cf_ui.axes import DEFAULT_VALUE_SETS, merge_value_sets
+
+ # Re-validating the shipped sets through the public entry point is the
+ # point: a rule that rejected our own defaults would be caught here.
+ merged = merge_value_sets(DEFAULT_VALUE_SETS)
+ assert "Segoe UI" in merged["type"]["system"]["--cf-font-display"]
+
+
+def test_the_injection_payload_never_reaches_a_style_element():
+ """End-to-end: the escape hatch this closes is style_element()."""
+ from cf_ui.axes import AxisConfigError, merge_value_sets
+
+ with pytest.raises(AxisConfigError):
+ merge_value_sets({"form": {"evil": {"--cf-radius": "0; } body { display: none"}}})
+
+
+# --------------------------------------------------------------------------
+# Issue #20 §4: the exported generators validate too, not just merge_value_sets.
+#
+# An exported function that generates CSS with the gate switched off is the
+# generator with the build error removed. The plugin's buildAxisBase/buildAxisCss
+# were fixed for exactly this; these are their Python counterparts, and
+# style_element() is the sink the whole §1 gate exists to protect.
+# --------------------------------------------------------------------------
+
+EVIL_SET = {"form": {"evil": {"--cf-radius": "0; } body { display: none"}}}
+
+
+@pytest.mark.parametrize("generator", ["render_axis_css", "custom_axis_css", "style_element"])
+def test_the_exported_generators_validate_by_default(generator):
+ import cf_ui.axes as axes
+
+ with pytest.raises(axes.AxisConfigError, match="unsafe value"):
+ getattr(axes, generator)(EVIL_SET)
+
+
+@pytest.mark.parametrize("generator", ["render_axis_css", "custom_axis_css", "style_element"])
+def test_the_generators_take_an_explicit_opt_out(generator):
+ """The escape hatch stays — you just have to ask for it."""
+ import cf_ui.axes as axes
+
+ output = getattr(axes, generator)(EVIL_SET, validate=False)
+ assert "display: none" in output
+
+
+def test_style_element_no_longer_emits_an_injection_payload():
+ """The concrete regression: this used to render the payload into the page."""
+ from cf_ui.axes import AxisConfigError, style_element
+
+ with pytest.raises(AxisConfigError):
+ style_element(EVIL_SET)
+
+
+def test_the_opt_out_does_not_change_what_valid_input_produces():
+ """Guards against validation quietly altering the generated CSS."""
+ from cf_ui.axes import DEFAULT_VALUE_SETS, render_axis_css
+
+ assert render_axis_css(DEFAULT_VALUE_SETS) == render_axis_css(
+ DEFAULT_VALUE_SETS, validate=False
+ )
+
+
+def test_the_generators_reject_an_unknown_axis():
+ from cf_ui.axes import AxisConfigError, render_axis_css
+
+ with pytest.raises(AxisConfigError, match="flavour"):
+ render_axis_css({"flavour": {"vanilla": {"--cf-radius": "0"}}})
+
+
+# --------------------------------------------------------------------------
+# Issue #20 §3: the p3 layer holds the base declaration's lightness.
+#
+# The contrast gate is computed against the sRGB base. The docs claim the p3
+# override is "layered over that base at the same lightness, so the contrast
+# guarantee still holds" — that was a convention held by hand until now.
+# --------------------------------------------------------------------------
+
+
+def test_oklab_lightness_matches_known_reference_values():
+ """Guards the conversion itself — a wrong L would make the gate vacuous."""
+ from cf_ui.axes import oklab_lightness
+
+ assert oklab_lightness("#000000") == pytest.approx(0.0, abs=0.001)
+ assert oklab_lightness("#ffffff") == pytest.approx(1.0, abs=0.001)
+ # Authored p3 override for azure/light is oklch(50.0% ...)
+ assert oklab_lightness("#0369a1") == pytest.approx(0.500, abs=0.002)
+
+
+def test_oklch_lightness_parses_the_authored_form():
+ from cf_ui.axes import oklch_lightness
+
+ assert oklch_lightness("oklch(50.0% 0.137 242.7)") == pytest.approx(0.50, abs=0.0001)
+ assert oklch_lightness("oklch(82.8% 0.116 230.3)") == pytest.approx(0.828, abs=0.0001)
+
+
+def test_shipped_p3_overrides_hold_the_lightness_invariant():
+ """This is the guarantee the docs already claim. Now it fails CI."""
+ from cf_ui.axes import DEFAULT_VALUE_SETS, p3_lightness_failures
+
+ assert p3_lightness_failures(DEFAULT_VALUE_SETS) == []
+
+
+def test_a_p3_override_with_a_different_lightness_is_flagged():
+ """The mutation the gate exists to catch."""
+ from copy import deepcopy
+
+ from cf_ui.axes import DEFAULT_VALUE_SETS, p3_lightness_failures
+
+ drifted = deepcopy(DEFAULT_VALUE_SETS)
+ drifted["accent"]["azure"]["p3"]["light"]["--cf-accent"] = "oklch(72.0% 0.137 242.7)"
+ failures = p3_lightness_failures(drifted)
+ assert failures, "a 22-point lightness shift must be flagged"
+ assert "--cf-accent" in failures[0]
+ assert "azure" in failures[0]
+
+
+def test_the_lightness_gate_tolerates_authored_rounding():
+ """Values are authored to one decimal place; the gate must not be brittle."""
+ from copy import deepcopy
+
+ from cf_ui.axes import DEFAULT_VALUE_SETS, p3_lightness_failures
+
+ rounded = deepcopy(DEFAULT_VALUE_SETS)
+ # azure/light base measures 49.998%; the authored 50.0% must stay legal.
+ rounded["accent"]["azure"]["p3"]["light"]["--cf-accent"] = "oklch(50.0% 0.137 242.7)"
+ assert p3_lightness_failures(rounded) == []
+
+
+def test_a_p3_override_of_an_undeclared_token_is_flagged():
+ """A p3 block may only refine tokens the base declares."""
+ from copy import deepcopy
+
+ from cf_ui.axes import DEFAULT_VALUE_SETS, p3_lightness_failures
+
+ orphan = deepcopy(DEFAULT_VALUE_SETS)
+ orphan["accent"]["azure"]["p3"]["light"]["--cf-nonexistent"] = "oklch(50% 0.1 240)"
+ failures = p3_lightness_failures(orphan)
+ assert any("--cf-nonexistent" in failure for failure in failures)
+
+
+def test_a_non_oklch_p3_value_is_flagged_rather_than_skipped():
+ """Silently skipping unparseable values is how the gate went blind before."""
+ from copy import deepcopy
+
+ from cf_ui.axes import DEFAULT_VALUE_SETS, p3_lightness_failures
+
+ bad = deepcopy(DEFAULT_VALUE_SETS)
+ bad["accent"]["azure"]["p3"]["light"]["--cf-accent"] = "#0369a1"
+ assert p3_lightness_failures(bad) != []
diff --git a/tests/unit/test_tailwind_plugin.py b/tests/unit/test_tailwind_plugin.py
index 9f29d38..92d49f6 100644
--- a/tests/unit/test_tailwind_plugin.py
+++ b/tests/unit/test_tailwind_plugin.py
@@ -19,6 +19,7 @@
import shutil
import subprocess
import sys
+from copy import deepcopy
from fnmatch import fnmatch
from pathlib import Path
@@ -106,8 +107,14 @@ def _declarations(css: str) -> dict[str, dict[str, str]]:
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))
+ """Split the generated stylesheet into its base rules and its p3 layer.
+
+ ``validate=False`` for the same reason the JS side passes it: comparing only
+ validated input would narrow this from "the two generators agree" to "they
+ agree on input the validator already approved". Both sides must skip it, or
+ the comparison is no longer symmetric.
+ """
+ css = _COMMENT.sub("", render_axis_css(DEFAULT_VALUE_SETS, banner=False, validate=False))
marker = "@media (color-gamut: p3) {"
base_css, _, media_css = css.partition(marker)
return _declarations(base_css), _declarations(media_css)
@@ -116,9 +123,17 @@ def _python_rules() -> tuple[dict, dict]:
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.
+ #
+ # `validate: false` is passed deliberately. buildAxisBase validates by
+ # default since #20, and comparing only *validated* input would quietly
+ # narrow this from "the two generators agree" to "they agree on input the
+ # validator already approved" — the unchecked generation path is exactly
+ # the one a consumer reaches through the exported function.
script = f"""
import {{ buildAxisBase }} from {json.dumps(PLUGIN_PATH.as_uri())};
- process.stdout.write(JSON.stringify(buildAxisBase()));
+ process.stdout.write(
+ JSON.stringify(buildAxisBase(undefined, undefined, {{ validate: false }})),
+ );
"""
base = json.loads(_run_node(script))
media = base.pop("@media (color-gamut: p3)", {})
@@ -185,3 +200,141 @@ def test_the_plugin_is_dependency_free():
assert all(name.startswith("node:") for name in bare), (
f"plugin imports non-builtin modules: {bare}"
)
+
+
+# --- cross-language validation parity (issue #20) ---------------------------
+#
+# #20 §1 closed an asymmetry: the JS generator rejected unsafe token values and
+# the Python one did not. These assert the two now reject the *same* inputs,
+# which is the property that keeps them from drifting apart again.
+
+UNSAFE = [
+ "0; } body { display: none",
+ "red; color: blue",
+ "}",
+ "{",
+ "red /* c",
+ "red */",
+ "",
+ "<",
+]
+SAFE = ["0", "0.375rem", "none", "0 1px 2px rgb(0 0 0 / 0.08)", "#ffffff"]
+
+
+def _python_rejects(value: str) -> bool:
+ from cf_ui.axes import AxisConfigError, merge_value_sets
+
+ try:
+ merge_value_sets({"form": {"probe": {"--cf-radius": value}}})
+ except AxisConfigError:
+ return True
+ return False
+
+
+def _js_rejects(values: list[str]) -> list[bool]:
+ """One node process for the whole batch — spawning eight is needlessly slow."""
+ script = f"""
+ import {{ mergeValueSets, loadDefinition }} from {json.dumps(PLUGIN_PATH.as_uri())};
+ const definition = loadDefinition();
+ const results = {json.dumps(values)}.map((value) => {{
+ try {{
+ mergeValueSets(definition, {{ form: {{ probe: {{ "--cf-radius": value }} }} }});
+ return false;
+ }} catch {{
+ return true;
+ }}
+ }});
+ process.stdout.write(JSON.stringify(results));
+ """
+ return json.loads(_run_node(script))
+
+
+@requires_node
+def test_both_generators_reject_the_same_unsafe_values():
+ js = _js_rejects(UNSAFE)
+ python = [_python_rejects(value) for value in UNSAFE]
+ assert python == [True] * len(UNSAFE), f"python accepted: {UNSAFE}"
+ assert js == python, dict(zip(UNSAFE, zip(python, js, strict=True), strict=True))
+
+
+@requires_node
+def test_both_generators_accept_the_same_legitimate_values():
+ """Guards the test above against a rule that simply rejects everything."""
+ js = _js_rejects(SAFE)
+ python = [_python_rejects(value) for value in SAFE]
+ assert python == [False] * len(SAFE), f"python rejected a legal value: {SAFE}"
+ assert js == python, dict(zip(SAFE, zip(python, js, strict=True), strict=True))
+
+
+@requires_node
+def test_both_generators_report_the_same_p3_lightness_failures():
+ """#20 §3 — the invariant is checked on both sides, in the same words."""
+ from cf_ui.axes import DEFAULT_VALUE_SETS, p3_lightness_failures
+
+ script = f"""
+ import {{ p3LightnessFailures, loadDefinition }} from {json.dumps(PLUGIN_PATH.as_uri())};
+ const definition = loadDefinition();
+ const drifted = structuredClone(definition.valueSets);
+ drifted.accent.azure.p3.light["--cf-accent"] = "oklch(72.0% 0.137 242.7)";
+ process.stdout.write(JSON.stringify({{
+ clean: p3LightnessFailures(definition.valueSets, definition),
+ drifted: p3LightnessFailures(drifted, definition),
+ }}));
+ """
+ js = json.loads(_run_node(script))
+
+ drifted = deepcopy(DEFAULT_VALUE_SETS)
+ drifted["accent"]["azure"]["p3"]["light"]["--cf-accent"] = "oklch(72.0% 0.137 242.7)"
+
+ assert js["clean"] == p3_lightness_failures(DEFAULT_VALUE_SETS) == []
+ assert js["drifted"] == p3_lightness_failures(drifted)
+ assert js["drifted"], "the drifted fixture must actually fail, or this is vacuous"
+
+
+# --- generated artifacts stay clean ----------------------------------------
+
+
+def test_generated_artifacts_are_pinned_to_lf():
+ """`python -m cf_ui.axes` writes LF; without this git shows a phantom diff.
+
+ The generator writes ``newline="\\n"`` while git's autocrlf wants CRLF, so
+ regenerating left the tree permanently "modified" with a zero-line diff —
+ which makes "is the tree clean" useless as a signal for every phase that
+ touches these files.
+ """
+ attributes = REPO_ROOT / ".gitattributes"
+ assert attributes.is_file(), "a .gitattributes is needed to pin the generated artifacts"
+ text = attributes.read_text(encoding="utf-8")
+ for name in ("cf_ui_axes.css", "cf_ui_axes.json"):
+ assert name in text, f"{name} is not pinned in .gitattributes"
+ assert "text eol=lf" in text
+
+
+def test_regenerating_the_artifacts_is_a_no_op():
+ """The checked-in artifacts match what the generator produces, byte for byte.
+
+ Deliberately ``read_bytes``, not ``read_text``: the latter applies universal
+ newline translation, so a CRLF file on disk — the exact state
+ ``.gitattributes`` exists to prevent — would still compare equal, and this
+ test would assert content only while reading as though it covered the line
+ endings its name refers to.
+ """
+ import json as _json
+
+ from cf_ui.axes import (
+ AXIS_CSS_PATH,
+ AXIS_DEFINITION_PATH,
+ DEFAULT_VALUE_SETS,
+ axis_definition,
+ render_axis_css,
+ )
+
+ css = AXIS_CSS_PATH.read_bytes()
+ definition = AXIS_DEFINITION_PATH.read_bytes()
+
+ assert b"\r\n" not in css, "cf_ui_axes.css is CRLF on disk; the generator writes LF"
+ assert b"\r\n" not in definition, "cf_ui_axes.json is CRLF on disk; the generator writes LF"
+
+ assert css == render_axis_css(DEFAULT_VALUE_SETS).encode("utf-8")
+ expected = _json.dumps(axis_definition(), indent=2) + "\n"
+ assert definition == expected.encode("utf-8")