Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
55 changes: 54 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<style>` element `style_element()` injects, so a semicolon or brace closed
the declaration and wrote rules the app never authored — `</style>` 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
Expand Down Expand Up @@ -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

Expand Down
55 changes: 53 additions & 2 deletions docs/tailwind-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
67 changes: 58 additions & 9 deletions docs/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}
```

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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.

---

Expand Down
Loading
Loading