Spec-first rewrite of the plugin's internals, targeting CSS Values and Units Module Level 4 §10. The public plugin API (options, plugin shape) is preserved; every consumer on v10.x should upgrade without code changes.
Current state (v10.1.1) has known gaps that the spec closes:
- No
min()/max()/clamp()support — treated as opaque blobs. - Typed division (
calc(100vw / 1px)) throws "Cannot divide by 'px', number expected". - No folding of
pi/e/infinity/NaNinto arithmetic. - JISON-generated LR parser is hard to extend: every new math function requires editing the grammar and regenerating.
- Ad-hoc reducer reinvents a weaker version of the spec's simplification algorithm, which means every edge case is discovered by users instead of derived from the standard.
v11 replaces the parser + reducer + stringifier with direct transcriptions of the spec. The existing 190 regression tests become the contract we ship against.
- Relative color math (
lch(from X l c calc(h + 80))) — channel-keyword scope is out-of-band; requires caller context the plugin doesn't have at transform time. calc-size()— separate, evolving spec; continue to leave as-is.- Property-context percentage resolution — a PostCSS transform has no property-type info; spec explicitly allows plugins to leave percentages unresolved.
- Transpiling modern math functions to legacy
calc()— a separate concern; belongs in a companion plugin if wanted.
Five modules, each one-to-one with a spec section. Every module file starts with the exact spec URL it implements, so a contributor (or reviewer) can check conformance section-by-section.
| Module | Spec section | Purpose |
|---|---|---|
tokenizer.ts |
§10.1 | Numbers, dimensions (incl. 1px-2 single-token rule), idents, punct |
parser.ts |
§10.1 | Pratt parser + parselet registry → AST |
type.ts |
§10.2 | Calculation-type arithmetic (unit × power maps) |
simplify.ts |
§10.10 | Post-order simplification |
serialize.ts |
§10.12 | AST → CSS text with correct calc() wrapping |
Done (on master, in playground/pratt/):
- Pratt parser skeleton in TypeScript, parselet registries for prefix + infix.
- Tokenizer for numbers / idents / punct.
- Evaluator harness (number-only) proving the parser works for
min/max/clamp/pow/trig/stepped-value/etc. as generic call nodes. - 48 tests covering precedence, associativity, unary stacking, function calls, and negative-syntax / pinned-behavior cases.
tsxruntime + stricttsconfig.jsonfor the playground;pnpm test:prattruns the TS suite directly.
The playground has validated the parser design on a clean slate. What remains is dimensions, the type system, simplification, and serialization — the three load-bearing spec pieces plus tokenizer extension.
Each release is scoped to ship on its own. Earlier releases do not block on later ones.
Goal: spec-correct parser + simplification core with min/max/clamp + typed division. Ships 80% of real-world value.
Scope:
- Replace
parser.jisonwith the Pratt parser from the playground, promoted intosrc/. - Drop
jison-ghodevDep; deleteparser.jison; removebuildscript. - Dimension token rule including spec-required
1px-2single-token behavior. - Calculation type system (§10.2).
simplify()core covering:- Nested
calc()flattening (inner calc → bare parens). - Like-term combination across
+/-. - Numeric folding for resolvable subtrees.
- Unit conversion within a family (port
src/lib/convertUnit.js). - Opaque-leaf preservation:
var(),env(),attr(), and unknown functions are AST leaves that simplification flows around.
- Nested
min(),max(),clamp()as first-class AST nodes with spec-correct reduction.- Typed division:
<length> / <length> → <number>,<time> / <time> → <number>, etc. - Serializer matching current output shape for all v10.x regression cases.
Test coverage:
| Suite | File | Count (estimate) |
|---|---|---|
| Regression (preserves v10 behavior) | test/index.js |
190 (existing) |
min / max / clamp reduction |
test/v11-min-max-clamp.test.ts |
~30 |
| Typed division and unit arithmetic | test/v11-typed-arithmetic.test.ts |
~15 |
Opaque-leaf preservation (var(), env(), unknown fns) |
test/v11-opaque.test.ts |
~15 |
| Type-checking error cases (invalid mixes) | test/v11-type-errors.test.ts |
~10 |
| WPT conformance subset | test/wpt/calc-basic.test.ts |
~25 |
Acceptance: all 190 existing + ~95 new ≈ ~285 tests green.
Goal: spec coverage for the rounding / modulo family (§10.8).
Scope:
round([<rounding-strategy>,]? A, B?)withnearest | up | down | to-zero; defaultnearest; ifBis omitted, rounds to1inA's type.mod(A, B)— result sign follows divisor.rem(A, B)— result sign follows dividend.abs(A)— preserves type.sign(A)— returns<number>regardless of input type.- Parser: one bespoke parselet for
roundto peek for a keyword first-argument.
Test coverage:
| Suite | File | Count (estimate) |
|---|---|---|
round with all four strategies × (A) / (A,B) × resolvable/opaque |
test/v11-round.test.ts |
~20 |
mod / rem sign behavior and opaque passthrough |
test/v11-mod-rem.test.ts |
~12 |
abs / sign incl. type preservation and sign-return-type |
test/v11-abs-sign.test.ts |
~10 |
WPT conformance subset (round, mod, rem) |
test/wpt/round-mod-rem.test.ts |
~25 |
Acceptance: adds ~67 tests, cumulative ~352.
Goal: spec coverage for trig (§10.8) including angle-unit coercion.
Scope:
sin(A),cos(A),tan(A)— accept<angle>(deg/rad/grad/turn) or bare<number>(treated as radians per spec).asin(A),acos(A),atan(A)— return<angle>in degrees.atan2(Y, X)— returns<angle>in degrees; accepts any matching types.- Angle-unit normalization inside trig argument reduction.
- Calc keyword
pifolds into trig arguments (sin(pi / 2)→1).
Test coverage:
| Suite | File | Count (estimate) |
|---|---|---|
sin / cos / tan × (deg, rad, grad, turn, bare number) |
test/v11-trig.test.ts |
~25 |
asin / acos / atan — return type is degrees |
test/v11-trig-inverse.test.ts |
~12 |
atan2 two-argument type matching |
test/v11-atan2.test.ts |
~8 |
Trig with pi folding |
test/v11-trig-pi.test.ts |
~10 |
| WPT conformance subset (trig) | test/wpt/trig.test.ts |
~25 |
Acceptance: adds ~80 tests, cumulative ~432.
Goal: complete the math-function set (§10.8) and pin spec-correct serialization of degenerate numbers (§10.9, §10.12).
Scope:
pow(A, B),sqrt(A),hypot(A, B, …).log(A)(natural log),log(A, B)(log base B),exp(A).- Unit handling:
pow/sqrt/hypotresolve to bare numbers where required by spec; preserve through opaque subtrees. - Full
<calc-keyword>arithmetic:e,pi,infinity,-infinity,NaNfold per IEEE-754. - Degenerate-numeric serialization:
calc(1px / 0)→calc(infinity * 1px);calc(0 / 0 * 1px)→calc(NaN * 1px). - Case-sensitivity:
NaNis case-sensitive (spec); all other keywords are case-insensitive.
Test coverage:
| Suite | File | Count (estimate) |
|---|---|---|
pow / sqrt / hypot with type handling |
test/v11-exponential.test.ts |
~18 |
log / exp incl. two-arg log(a, b) |
test/v11-log-exp.test.ts |
~10 |
| Calc-keyword folding in arithmetic | test/v11-calc-keywords.test.ts |
~15 |
| Degenerate numeric serialization (infinity, NaN) | test/v11-degenerate.test.ts |
~12 |
| WPT conformance subset (exp family, keywords) | test/wpt/exp-keywords.test.ts |
~25 |
Acceptance: adds ~80 tests, cumulative ~512.
Three tiers, each preserving a distinct guarantee:
- Regression —
test/index.js(190 tests). The v10.x contract. Only ever extended, never deleted. A new v11 implementation is considered correct only if every one of these passes unchanged. - Spec —
test/v11-*.test.ts. One file per feature cluster. Every test has a// §10.xcomment citing the spec paragraph it exercises. This makes correctness reviewable section-by-section. - Conformance —
test/wpt/*.test.ts. Cribbed from web-platform-testscss/css-values/for each version's scope. Test names mirror upstream so drift is detectable. Keeps us honest about cross-implementation interop.
All tiers run under pnpm test via node --test (and tsx for the TS suites).
| Version | New tests (est.) | Cumulative |
|---|---|---|
| baseline | 190 | 190 |
| v11.0 | ~95 | ~285 |
| v11.1 | ~67 | ~352 |
| v11.2 | ~80 | ~432 |
| v11.3 | ~80 | ~512 |
Counts are forecasts, not commitments — they guide scope, not effort estimates.
- Strict whitespace around
+/-. Spec requires it; current plugin is permissive. Leaning strict, with astrict: falseescape hatch for legacy inputs. - Output-format opt-out. Do we add
spec: 'css-values-4'(default true) so projects that pattern-match on current serialization can stay on the old format for one major? Probably yes at low cost. - Deprecation of
mediaQueries/selectorsoptions. Carry forward unchanged — no reason to remove.
- Plugin name, export, and
pluginCreator.postcss = true. - Options surface (
precision,preserve,warnWhenCannotResolve,mediaQueries,selectors). peerDependenciesonpostcss ^8.- Semver: v11 means internal-breaking + output-difference in edge cases (typed division, degenerate numerics). No API-breaking changes for normal consumers.