package cascade
Install
dune-project
Dependency
Authors
Maintainers
Sources
sha256=3315f94068943b3655bf530c1d82b68655524ff3601efedfbaf0a7f886fbf5a1
sha512=0f187c8e17fcbddaa0ad2760930956451062e476b678e1e60daf6ad9e7414fad99a2d70534dfdf8bca0a1092e374d009051f06b0ce9da33f2742cdde0187e75c
doc/CHANGES.html
1.2.0
Most entries below are defect fixes, and the largest group of them has one cause. Readers, printers and optimizer passes each walked the statement tree with their own match ending in a catch-all case, so an at-rule added to the AST later was skipped by all of them instead of causing a compile error. They now share a single exhaustive walk, so adding a statement kind after this release breaks the build at every place that has to decide about it.
Correctness was checked against a browser rather than against cascade. The suite now renders a sheet and its optimised form in headless Chrome, over a document derived from the sheet's own selectors, and compares the two pages pixel for pixel. It also replays 2960 recorded minification cases carrying six minifiers' answers. Several of the fixes below are cases where Chrome disagreed with the CSS cascade emitted, not readings of the spec.
Upgrading from 1.1.0. Many of the fixes below change the CSS cascade emits for input 1.1.0 already accepted, and enough of them are miscompiles that a page can render differently. If you shipped minified output built with 1.1.0, re-run it and compare: cascade diff --diff=canonical old.css new.css exits 1 and prints the difference wherever the two are not equivalent. Library callers should start at ### Breaking, where the public module set and the parser entry points both moved.
Breaking
- A calculation whose sum has no consistent type is dropped, so
width: calc(sqrt(4) - 1px),border-width: abs(-1)andwidth: calc(sign(-1px))are refused where they were written back ascalc(2 - 1px)and the like, which cascade's own reader also refuses. CSS Values 4 sec. 10.9 fails the whole calculation when adding the types of a+or-fails; an angle-valued call still adds to an angle, sorotate: calc(atan(1) + 10deg)reads (#1151) - A percentage slot refuses a length-valued math call, so
scale: abs(-1px),font-stretch: hypot(3px, 4px)andtext-size-adjust: round(1.5px, 1px)are dropped, andfont-stretch: calc(inherit)no longer prints a liveinherit. A keyword is not a<calc-value>there either, socalc(normal)andcalc(none)are dropped (#1153) - A math function reads wherever the grammar allows its type, with no
calc()wrapper needed, sofont-weight: calc(400),zoom: calc(.5),grid-row-start: calc(2) center,width: abs(-1px),opacity: pow(2, 3)andborder-top-width: hypot(3px, 4px)read where they were dropped. CSS Values 4 sec. 10.6 givesabs()the type of its input andsign()a<number>whatever goes in, sowidth: sign(-1px)is dropped whereopacity: sign(-1px)reads, andround(),mod()andrem()take the dimension arguments sec. 10.9 grants them, soborder-top-width: round(1.5px, 1px)reads where onlywidthdid. Thirteen readers that admitted no math at all now do, among themstroke-width,initial-letter,font-size-adjust,baseline-shift,columns,font-stretchandinterest-delay, and the@font-facedescriptorsascent-override,descent-override,line-gap-overrideandsize-adjust, which hold the percentage the call resolves to.line-height: min(120%, 1px)reads too: a comparison there answers a<length-percentage>that resolves only at used-value time, soCascade.Properties.line_heightgainsMin,MaxandClampbesideCalc.Cascade.Properties.font_weight,webkit_line_clamp,zoom,border_image_slice_itemandshape_image_thresholdgainCalc, andgrid_linegainsCalc_name(#1072, #1086, #1124, #1125, #1126, #1145, #1148, #1161, #1164, #1174) - A math function takes only the operands CSS Values 4 sec. 10.8 grants, so
width: calc(inherit),height: calc(auto),border-width: calc(medium),width: calc(fit-content(20rem))andwidth: min(unset, 1px)are dropped with a warning where they used to read.calc(inherit)was written back as a liveinherit, which changed what the element computed. Sec. 10.2 also requires the arguments ofmin(),max()andclamp()to share a consistent type, sowidth: min(0, 1px)is dropped as well. The same holds atfont-size,line-height,flex-basis, the opacity family and the<line-width>slots, whereborder-width: min(0, 1px)used to fold to0(#1139, #1144) - The gap decoration, scroll-driven animation and interest properties carry a comma-separated list where they carried a single value, one entry per rule line or per timeline, so
column-rule: 1px solid red, 2px dashed blueandscroll-timeline-axis: block, inlineread. A caller writing one entry passes a one-element list.Cascade.Properties.animation_range_item,animation_range,animation_timeline,timeline_axis,timeline_insetandinterest_delaygain list arms,timeline_namecarriestimeline_idententries, and the two timeline shorthands drop theirNonearm for thenonethat CSS puts inside the list;timeline-scope, which keeps it for the whole value, moves to its owntimeline_scope(#1024, #1050, #1051, #1052, #1053, #1074, #1083) Cascade.Stylesheet.font_face_descriptorfollows CSS Fonts 4 sec. 4:font-style: auto,font-weight: autoandfont-stretch: autoread through three new_autoarms, whilefont-style: normal italic,font-weight: lighterand the wholefont-techdescriptor are dropped where they were read.font_style_range,font_tech_descriptorandread_font_tech_descriptorare gone;tech()insidesrcandfont-tech()in@supportsstill read (#1116, #1117, #1118)- The numeric and length leaves carry the full authored grammar, so
tab-size: 10.5,flex-basis: 1e3px,border-width: 3dvhandvertical-align: 1capread where they were dropped with a warning.IntbecomesNumber of numberontab_size,floatbecomesnumberondash_length,animation_iteration_count,font_weight.Weightand the threeborder_image_*items,flex_basisandborder_widthgainDimensionfor a spelling no typed constructor carries,vertical_aligncarries oneLength of length_percentage, andposition_value.Axis_edge_offsetcarries alength_percentage. A caller building a literal writesNumber (Num 4.)(#612, #673, #685, #1021, #1023, #1048, #1054, #1070) Cascade.Properties.page_size,page_break_value,page_break_inside_valueandborder_image_slicecarry the CSS-wide keywords, the way their siblings already did. Exhaustive visitors must handle the new leaves (#993, #994, #1004)- Properties read the rest of their grammar, so
ruby-overhang: spaces,content: url(a.png),flex-wrap: balance,list-style-image: linear-gradient(red, blue),offset-path: circle(50%) content-box,hyphenate-limit-chars: auto 3,contain-intrinsic-width: auto none,container: markers stroke fill,-webkit-appearance: base-selectandtext-box: normalread where they were dropped with a warning. The value types gain the arms that carry them,position_try_fallbacksholds typed fallback entries,ray_sizenames the five sizesray()accepts directly,place_itemsgains the two modifier-first baselines, theview-timelineandtext-boxtypes carry their optional slots, and-webkit-mask-originand-webkit-mask-clipcarry WebKit's own box vocabulary rather than themask_boxof their unprefixed namesakes. Exhaustive visitors must handle the new leaves (#549, #668, #669, #670, #671, #725, #995, #1011, #1012, #1013, #1018, #1019, #1020, #1049, #1071, #1087, #1088) - Constructors that printed CSS no grammar grants are gone:
flex_basis.From_font, andLeft,Rightand their fourSafe_/Unsafe_spellings onalign_content(#658, #1014) Css.filtergainsOmitted of filter_function,Css.Supports.tgainsGeneral_enclosed,Css.kindgainsRadial_shape,Radial_sizeandPosition_value, andCss.Stylesheet.moz_document_conditiongainsUrl_exact,Domain,Media_documentandRegexp, so a match on any of them is no longer exhaustive. They retain what the reader used to discard (#461, #508, #869, #870)Css.colorkeeps the origin of a relative colour as a colour rather than in the opaque tail:Relative_rgbcarriescolor * stringandRelative_color_mixa pair of them (#313)- Values every browser drops are dropped with a warning, where they used to read: a colour anywhere but the final background layer, a second
-webkit-text-strokewidth,gap: -10%,text-decoration: fit-content(20rem), and a sizing function on a margin or inset property. This release addsCascade.Values.read_margin_length(#1096, #1097, #1101, #1108) - A
var()whose custom property resolves through another one keeps its reference rather than taking its fallback, so--n: 5px; --x: var(--n); color: var(--x, lime)computes the inherited colour as every browser does instead oflime. CSS Variables 1 sec. 3 puts the fallback in only where the custom property is the guaranteed-invalid value (#1100, #1102) border-imageandmask-borderfill their slots in any order, soborder-image: 50% noneandround none 30read where the reader took them in one fixed order (#1089)- Browser support comes from the web-features dataset rather than from versions written into the source, so a
backdrop-filtertargeting Safari 17.0 to 17.6 keeps its-webkit-twin, which the old boundary dropped. This release addsCascade.Support, whosemeasuredrecords what this project measured for productions the dataset gives no compat key and whoseself_measuredsays which answers came from there;Cascade.Optimize.targetsis an alias ofCascade.Support.targets(#1090, #1110, #1111) - An
@supportscondition keeps what the author wrote: the property name with its escapes, and the value unrewritten through the property's typed grammar, which changed what the condition tested.--minifykeeps the guard rather than deciding it, andCss.Supports.simplify_baselineis gone. Two spellings of one condition are still one block to the diff;Cascade.Supports.ppandto_stringgain?verbatim, which tells a serialiser from an identity, andCascade.Supports.Declarationcarries the name beside the declaration (#584, #587, #1092, #1095) - Implementation modules are no longer reachable through accidental
Cascade.*aliases, and the redundant public ones are gone. UseDeclaration.ppandDeclaration.to_stringrather thanpp_declarationandstring_of_declaration,Cascade.Parser.string_of_componentsrather thanParser.to_string_custom,Container.read/Media.readover a cursor rather thanof_components, the exhaustiveCss.Stylesheet.statement_declarationsrather thanCss.statement_declarations, andCss.vars_of_stylesheetrather thanCss.vars_of_rules.Cascade.Readeris the character cursorLexerdrives and no longer reads CSS, the one-offCss.Transform,Transform_origin,Perspective_originandAnimationstring parsers are replaced by the main readers,Css.Selector_summary.clear_memois gone (it did nothing), andCascade.Component.ppsays that it prints a debug dump (#348, #382, #496, #497, #499, #501, #504, #509, #514, #539, #544, #548, #806) Css.Stylesheet.layer_nameis the identifiers a<layer-name>is made of rather than the text between them, andCss.Stylesheet.edit_statementscallbacks returnKeep,ReplaceorDropthrough the newCss.Stylesheet.edittype (#442, #539)Declaration.declarationis a private variant: its constructors still match, but build values withDeclaration.v(#527)- The public constructors raise where they used to accept:
Invalid_argumentfor[]on a helper whose CSS value requires a non-empty list,Cursor.Parse_errorfromDeclaration.of_stringfor every input it refuses,Error.Parse_errorfromCss.Selector.of_string "", andFailurefromCss.Supports.propertyandCss.Declaration.custom_propertyon text that does not write back as the one declaration it names.Cursor.pairandtriplerewind the cursor when the separator or a later parser fails, so code that caughtParse_errorand read on sees a different position (#421, #428, #459, #509, #528, #535, #691) - Records gained fields, so expressions must set them and patterns must bind them or use
; _:recoveryonCascade.Error.t, which says whether the reader dropped the construct or kept it;sourceonCss.parse, filled byCss.of_string ~preserve_source:true;lineandcolonCascade.Reader.parse_error, whosefilenamenow holds a source name rather than a packed"<CSS input>:L:C";in_style_ruleonCss.Pp.ctx;media_inapplicableonCss.Context.query, which tells a recognised feature matching no value from an unknown one; andadded_propertiesandremoved_propertiesonTree_diff.Content_changed(#374, #491, #723, #747, #834, #868) Css.Media.equalreads normalised query structure where it read serialised text, so it is no longerCss.Media.compare a b = 0, andCss.Media.kindclassifies a negated width bound by the range it actually matches, sonot (min-width: 640px)sorts with the upper bounds (#328, #516)cascade diffexits 2, not 0, when it finds no difference but had to drop a declaration or a rule it could not read: what it dropped reached neither side of the comparison.--depthis gone, since a report is bounded by whole differences rather than by tree levels; pass--limit=nonewhere--depth=maxwas.cascaderequirescmdliner >= 2.0.0, the release that lets either side name standard input as-(#792, #796, #832, #833, #834, #835, #836)
Parsing
Cascade used to keep the part of a value it recognised and ignore the rest, and to lose a whole rule over one bad piece. Both are gone.
- A value a browser rejects is rejected whole, where the recognised prefix used to survive:
translateX(10px red),max(1px, red), a mismatchedcalc(), a shadow whose colour interrupts its lengths, a fourthtext-shadowlength, a repeatedcolor-scheme: only, a fractionalsteps()count, two edges on one axis of a<position>,autoas a colour or an SVG paint, a sizing keyword as the corner radius of a basic shape, and an unrelated keyword on a box size (#417, #617, #627, #629, #631, #673, #680, #688, #699, #701, #878, #882, #890, #891) - A property refuses what its own grammar refuses, so a value every browser drops is dropped with a warning rather than written back:
zoom: -1,line-height: 45deg,will-change: none,font-size: fit-content(20rem),padding-bottom: anchor-size(width),translate: auto,border-top-color: calc(2px - 3px),border-image: none, none,font-language-override: "default",grid-template-rows: -2px,grid-template: 10px,grid-area: calc(1/2/3/4/5),font-style: oblique 0,color-scheme: light only dark, an@font-facefont-weightorfont-stretchrange with a CSS-wide keyword for an endpoint,quotes:,border:and a@page sizegiven a percentage. Ranges, units, sizing functions and the closed<color>production are each checked, where the reader carried any dimension or any well-formed token run through, and a range now answers for the integer a math call rounds to rather than letting the call past, so a line index rounding to zero is no line. An empty value is a declaration only for a custom property. This release addsCascade.Properties.read_border_image_source(#640, #982, #1000, #1001, #1002, #1003, #1004, #1005, #1006, #1007, #1009, #1010, #1015, #1077, #1078, #1163, #1165, #1166, #1169) - A
<custom-ident>refuses the names CSS Values 4 sec. 4.2 reserves, in every ASCII case permutation, soanimation-name: default,counter-reset: DEFAULT,view-transition-name: defaultand adefaultcounter-style symbol are dropped with a warning. The string spelling is untouched, soanimation-name: "default"still reads. Six productions each carried their own exclusion list, five of which had forgottendefault; they now shareCascade.Cursor.custom_ident(#1143) - A value list carries the number of items its grammar grants, so
mask: none, none,mask: none, 50%andtransition-behavior: normal, normalread where they were dropped, whiletext-shadow: none, none,box-shadow: none, noneandlist-style: none none noneare dropped with a warning:noneis a whole value there, not a list item, and CSS Values 4 sec. 2.2 takes each option of a||at most once, whilebackground-position-x: center, 10pxreads, one position per background layer as the pair has, andplace-items: stretch centerreads:stretchis a value of both halves of that shorthand, so the slot after it is an ordinaryjustify-itemsvalue. An unquotedfont-familyname refuses a generic keyword only where it heads the sequence, sofont-family: serif serifis dropped andfont-family: Cambria Mathreads, at the@font-faceand@font-palette-valuesdescriptors as at the property (#1147, #1167, #1171, #1173, #1176) - A
@keyframesname is spelled the way its value requires, so@keyframes "default"keeps its quotes where it used to print as@keyframes default, which every browser drops, and@keyframes "none"is read where it was refused. CSS Animations 1 sec. 3 makes the two syntaxes equivalent and the name the value of the ident or string, serialised as an ident unless it is a disallowed keyword.color-schemereads its list items as<custom-ident>, socolor-scheme: defaultis dropped with a warning (#1146) - A property whose grammar names a
<length>takes neither a percentage nor an intrinsic-sizing keyword, and a<time>or<angle>needs its unit. Cascade read them wherever it read a length, soborder-width: 50%,top: min-content,transition-duration: 0,rotate: 0andbox-shadow: 20px 10%all parsed, most of them turning a declaration browsers drop into one that works.Css.Values.read_lengthgains?sizingandread_non_negative_lengthgains?length_only(#871, #879, #880, #951, #952, #953, #954, #1056, #1058, #1059) - Values browsers accept are read rather than dropped:
animation-duration: auto,grid: none / 200px,place-items: flex-start baseline, the fulltext-wrapgrammar,row-gap: normal, a custom counter-style name,transition-property: all, opacity, an emptyblur(), astyle()range query,display: flow-root list-item,outline: thin solid red, thesvg||tdcolumn combinator,:dir(auto),@-moz-document's URL matchers,white-space: collapse,text-box: auto,background-position: 50% bottom, every<image>atlist-style-image, a CSS-wide keyword at thepage-break-*aliases and atborder-image-slice,marginmixingautowith lengths in any slot, the four side styles atborder-style, both radii at everyborder-*-radiuscorner,display: grid-lanes, and a value whose grammar ends in an optional component (#334, #335, #427, #456, #461, #551, #572, #579, #594, #633, #641, #644, #646, #665, #666, #667, #682, #739, #805, #827, #870, #874, #877, #881, #884, #885, #886, #887, #889, #909, #993, #994, #995, #998, #1022, #1055, #1060, #1085, #1122) - A value is checked against the range its specification gives rather than one the reader assumed, so
overflow-clip-margin: -1px,offset-distance: -10px,shape-image-threshold: 50%,scroll-margin-block: 0 0,transition: opacity 1s -1s,border-right-width: calc(-1px)andz-index: calc(.5)read where they were dropped with a warning. A math function keeps its call in minified output rather than being unwrapped to a bare value the browser drops, since CSS Values 4 sec. 10.12 checks the range and rounds on what the function resolves to (#996, #999, #1026, #1047, #1080, #1082, #1123) - Each grid property takes its own grammar. Values a browser drops are dropped (
grid-template-columns: [a],[span] 1px,grid-column-start: 0,span -1,grid-template: 50% "text" infinite,grid: "a b" "b a"),grid-auto-rowsrefuses the area formsgrid-templateowns,subgridtakes its line-name list, a track size accepts math resolving to<flex>, and the areas form is read against its grammar rather than kept as any well-formed token run (#708, #711, #712, #714, #717, #718, #724, #749, #1016, #1076) - The font grammars are the ones the specifications spell.
font-variantandfont-variant-alternatesare typed rather than opaque text,@font-faceand@font-palette-valuesread their ownfont-familygrammars, thefontshorthand reads and writes all nine<font-width-css3>keywords where it read two and printed a percentage the slot has no room for, a CSS-wide keyword in a descriptor is dropped, and thesrcdescriptor reads an emptyurl()and drops only the item it cannot read, so a trailing comma no longer costs the whole font (#695, #963, #964, #965, #972, #1115) - Six
@counter-styledescriptors are read against their grammars, sorange: bogus,pad: 3,negative: 1andfallback: "decimal"are dropped where each was kept as an opaque string, and a counter symbol may be an image (#1130) - An
@propertyregistration drops rather than typing values by a grammar it cannot honour. Its syntax rejects a component with two multipliers, a multiplied<transform-list>and a*combined with anything, and itsinitial-valuerejects avar(),attr()orenv()at any depth, which has nothing to substitute from at registration time. Two syntaxes that used to hang the parser are read (#398, #707, #710, #713, #1142) - A bad piece is dropped on its own and the rule around it survives. A nested rule, a descriptor, a stray
;, a margin at-rule, an@counter-stylewith no descriptor, an@mediacondition, an@font-facedescriptor name, an@scopebound, an at-rule among@font-facedescriptors and leftover tokens after a value are each dropped alone, an unrecognised at-rule reaches the output with its block intact, an unknown@supportsor media condition keeps its guard, and a@mediaprelude replaces only the query of the list that fails, so@media ,(min-width: 10px)still guards its rules on the width.Optimize.drop_unknown_at_rulesdrops such a rule on request (#374, #380, #384, #388, #392, #399, #402, #403, #404, #405, #419, #420, #469, #483, #727, #867, #869, #895, #897, #946, #947, #948, #949, #974, #977) - A selector no browser can match is dropped rather than written back, in a nested rule's prelude as well as at top level, and a pseudo-element that carries structure keeps the compounds it allows:
::file-selector-button:hoverand a chained::part(label)::beforesurvive where a combinator after a pseudo-element does not. A.or a:names nothing across a space, so. x aand: hoverare dropped rather than read as.x aand:hover, which matched elements the author never named.Css.Selector.of_stringrefuses a string that is not a selector rather than reading it as an element name (#418, #426, #430, #441, #552, #553, #556, #559, #950, #976, #982) - A
var(),env()orattr()call keeps the type of the component it stands for and defers CSS-wide keyword validation to substitution, whether it is written as a slot of the value or inside another function, sooffset-rotate: color-mix(var(--a), var(--b))is kept where only a top-level call was. A value the typed reader refuses is preserved opaquely when the property is unknown or the value is a runtime substitution, and a value carrying an unmatched),]or}is dropped with a warning even where avar()sends it down the opaque path (#511, #518, #726, #729, #734, #735, #787, #813, #978, #997) - A
var()the evaluator cannot read a value from keeps its reference rather than taking its fallback: an empty binding, one the property's grammar refuses, one resolving through another custom property, and a CSS-wide keyword. CSS Variables 1 sec. 3 puts the fallback in only for a property holding its guaranteed-invalid initial value, sowidth: 37px; width: var(--nope, notalength)measured 37px where the browser lays outauto.--inline-varskeeps such avar()live where the definition it names sits on a selector or in an@mediathe pass cannot prove reaches the element, and every definition of a name still referenced reaches the output.Css.Declaration.parse_custom_propertytakes the empty value, so it accepts the pairscustom_propertyaccepts (#985, #986, #987, #988, #989, #990, #991, #992) - Keyword, at-rule and function names match without regard to case, so
grid-column: SPAN 2,@MEDIA,RGB(),VAR(--x),:dir(LTR),touch-action: NONE,display: LIST-ITEM FLOW-ROOT,align-items: FIRST BASELINE,grid-template-columns: REPEAT(3, 1FR),color: COLOR(DISPLAY-P3 1 0 0)andlinear-gradient(IN OKLAB, red, blue)read, while an author-defined name keeps the case it was written in. An escaped name reads as the name it spells:@supports (--x\3b y: red)is read, and@layer a\2e bnames the layera.brather than the sublayerbofa(#437, #442, #602, #603, #604, #620, #622, #767, #1141, #1162) - A math call a property's range makes it keep folds its arithmetic first, so
border-radius: calc(-1px * 2)printscalc(-2px)andwidth: calc(-5px - 5px)printscalc(-10px). The length family returned the authored call where the number and time families already folded and kept, and Chrome computes all three the same way. Sec. 10.12 clamps such a call at computed-value time, so the wrapper stays and only the arithmetic moves; the 504-file corpus is byte-identical (#1181) --minifyis several times faster on a large stylesheet, for byte-identical output: the SatCSS corpus' worst sheet drops from 3.5s to 0.6s. The rule conflict graph recorded every conflicting pair as its own edge, where the relation is really a union of cliques, so most of those edges and the selector comparisons behind them only restated an ordering the rest already imply; and a merge is now offered first to the rules that sit between the ones it moves, which are the rules that can refuse it (#1183, #1184)round()sends a tie to the nearest multiple toward positive infinity, soround(-3, 2)folds to-2where it used to fold to-4. CSS Values 4 sec. 10.7.3 gives that tie-break, and it is the one an<integer>slot already used. The rule had three copies, one per argument type, and they disagreed; there is now one (#1182)sign()folds only where its argument's sign is settled, somargin-left: calc(10px * sign(-1em))keeps its call wheresign(-1px)still folds to-10px. The answer turns on whether the argument is zero and a relative unit's reference can be one: a zero font-size, a zero viewport, a zero container, a percentage of zero (#1180)- An
@propertyinitial-valueat a non-universalsyntaxrefuses a length that resolves against an element, soinitial-value: 3em,3rem,3cqwandcalc(1px + 2em)drop the rule where3px,3vwandcalc(1px + 2px)read. The registration happens before any element exists, so there is nothing for such a length to resolve against; the universal syntax stores the value as written and still takes every unit. This is CSS Properties and Values API 1 sec. 4.1 as Chrome enforces it, the reading #1142 already followed for the substitution half (#1179) - A gradient's
<color-interpolation-method>takes the fifteen colour spacescolor-mix()takes, solinear-gradient(in srgb-linear, red, blue)andin hwb longer hueread where they were dropped, and a hue method after a rectangular space is refused.Cascade.Properties.color_interpolationcarries the space: its sixIn_*constructors are now oneInof acolor_spaceand an optional hue method (#1177) - A comparison function's operand is printed as it was written, so
border-width: min(var(--x), 1px)no longer collects acalc()around the reference andmin(calc(var(--x) + 1px), 2px)no longer collects a second one. CSS Values 4 sec. 10.1 makes a lonevar()a whole<calc-sum>(#1175) - A vendor prefix beside its unprefixed twin is recognised by the value the two spell rather than by the nodes they were parsed into, so a
mask-imagegiven avar()fallback its slot cannot type settles on one emission whether cascade synthesised the prefix or an author wrote it. Minifying that output again used to move it. Output grows 718 gzip bytes over the 504-file corpus (0.01%) and the sweep is within timing noise (#1178) - Cascade reads back everything it writes. Minified
@scope to (...),rotatewith a negative axis, a relative colour's channels, a-webkit-gradientcolor-stop()andcenterpoint,text-decoration: none solid,transition: none 1s, an unterminated string, a quotedanimation-name, a keyword-shaped keyframe name, an at-rule body ending on a backslash, au+aselector outsideunicode-range, an explicitanimation: spin 0s, aurl(at end of input and abackgroundlayer whosevar()stands ahead of another slot each survive the round trip (#558, #656, #875, #876, #894, #896, #898, #899, #900, #901, #902, #903, #910, #911, #912, #913, #1155) - A colour is read as the closed production CSS Color 5 sec. 3 defines. A relative colour's channel expressions are type-checked, so
rgb(from red calc(r + 10%) g b)is dropped as every browser drops it while the multiplying forms still read; an out-of-rangeoklab()oroklch()lightness written as a percentage clamps as the bare number already did; andcolor(from <origin> srgb r g b)folds to the origin when that origin is itself acolor()(#904, #905, #1061) offset,offset-anchor,offset-position,position-area,position-try-fallbacksandwhite-spaceare typed against their own grammars, and a literal reads as the node the library exports, so a constructed declaration and a parsed one compare and hash equally. Aborder-imageslice compares against its initial without a polymorphic equality, which walked another module's representation (#478, #485, #495, #651, #652, #653, #654, #657, #674, #683, #888, #935)- An input that ends mid-value keeps its declaration, and an
@pageprelude naming neither a page nor a pseudo-page invalidates the rule. CSS Syntax 3 sec. 4.3.5 ends a string at EOF as the string it read, and cascade dropped the rule and printed the value back without its closing quote, so the rest of the sheet was swallowed. A custom property holding a!outside its!importantflag is dropped with a warning, and--x: 1 ! importantis important with the value1(#972, #973, #979) - Everything the parser repaired or dropped is reported, so strict mode rejects it and the caret points at the value the author wrote rather than past the last byte.
@media screen {used to swallow the rest of the file and still returnOkwith no warnings.Cascade.Reader.intraisesParse_erroron a fractional or out-of-range number instead of truncating (#466, #472, #473, #477, #484, #496, #497, #499, #501, #538, #789, #793, #801)
Printing
text-decoration,mask-border,animationandmaskno longer minify to an empty value.text-decoration:solidprintedtext-decoration:, which no parser reads back, once dropping the initial style left no slot, and amasklayer holding only initials printed the same way (#682, #955)- An identifier is printed with the escapes needed to read it back as the same name, where
@layer a\3b bprinted two statements naming a layer the input never had and--x\3b ysplit into two declarations (#435, #436, #526, #598) - A compound operand of
not,andororin a@mediacondition keeps its parentheses, and unwrapping a@supportsnested in a style rule keeps the;before the sibling that follows. Both printed CSS browsers and cascade's own reader reject, losing the block or running two declarations together (#319, #370) - A relative colour's channel list reads back as the sheet it was printed from.
rgb(from #639 20% g b)minifies torgb(from #639 20%g b), which CSS Syntax 3 sec. 4.3.3 reads as the same two channels, but the reader kept the tighter spelling as a value of its own, so the sheet did not survive its own emission. Arotateaxis after avar()angle drops the separator the)already provides (#1189) - A
calc()printed without--minifykeeps the parentheses the author wrote, redundant ones included, while--minifyremoves those and keeps a precedence-sensitivecalc((1px - var(--a)) * 3)(#721) - A NaN-valued number prints as
calc(NaN)and a NaN-valued dimension ascalc(NaN * 1unit), wherecalc(sqrt(-1) * 1px)printedNaNpx, which browsers drop (#425) Css.to_stringrenders the sheet once, where it rendered it twice, the first pass only to measure the output and size the buffer exactly (#479)
Minification
- A colour folds to the bytes a browser paints, not to the nearest byte within a 0.002 DeltaE(OK) budget.
color(display-p3 .5 .5 .5)folded tograythough the browser paints 127 in one channel, and anlch()channel rounded. A colour the conversion leaves outside the sRGB gamut, or on a byte an engine's arithmetic could round either way, keeps its authored spelling; an authored sRGB value, a scaled percentage and an alpha are exact and fold (#1272). - A relative colour whose channels are the lab family's own keywords in order folds to its origin in that space, with the alpha the call names:
oklab(from rgb(0 0 0 / .1) l a b / 20%)minifies to#0003, as lightningcss writes it, and canonical mode reads Tailwind's shadow colours and their folded twins as one colour. The unminified and--losslessforms keep the call as written (#1248). - A static
calc()over a percentage or a length inflex-basisfolds as it does inwidth, socalc(.5 * 100%)minifies to50%and canonical mode reads Tailwind'sbasis-1/2and lightningcss's as one basis (#1245). calc(infinity * 1px)minifies to3.40282e38px, the largest length the printer spells and the form lightningcss writes, so canonical mode reads Tailwind'srounded-fulland lightningcss's as one radius (#1244).- A
stroke-widthin pixels minifies to the unitless number SVG reads the same way,stroke-width:1pxtostroke-width:1, and canonical mode reads the two as one width (#1243). - A signed number in a token stream keeps its sign and drops its leading zero:
calc(var(--s) * -0.5)minifies tocalc(var(--s)*-.5), as lightningcss writes it, and canonical mode no longer reads the two as different (#1242). - A registered
<length>or<length-percentage>zero minifies to0in an@propertyinitial-value, as a declaration's zero already did. It kept0px, so the canonical diff reported two equivalent registrations as different (#1237). - Two adjacent
@mediablocks with one condition merge even where a body holds aprefers-color-scheme,prefers-contrastorprefers-reduced-motionquery, which used to keep them apart. A nested@supportsalready merged, and the bodies are concatenated in order with nothing moving past anything, so the result evaluates as the input did (#1233) - A shorthand carrying a
var()keeps every component the author wrote, where--minifydropped one holding its initial:list-style: disc outside var(--x)becamelist-style: var(--x)andtext-decoration: solid var(--x)becametext-decoration: var(--x). CSS Variables 1 sec. 3 syntax-checks such a declaration only after substitution, so the drop changed what it means rather than only how it is spelled. With--x: circlethe first substitutes to two<list-style-type>values and is invalid at computed-value time, leaving the longhands unset, while the minified form substitutes to a validcirclethat sets the type, so a page using this pattern rendered differently. Re-run any minified output that puts avar()in one of these two shorthands (#1194) --minifywrites acalc()it unwraps as the leaf it became, sotransition-duration: calc(120ms)is.12sand not the120msan operand is spelled, andanimation: calc(1)is thenonethe bare1already gave. Both used to need a second pass to settle (#1160)--minifymerges two rules whose values were written with different spellings of one number, soa{tab-size:2e0ch}b{tab-size:2ch}groups on the first pass rather than the second.line-height,overflow-clip-margin, the grid track breadths, thecontain-intrinsic-*family andcolumnseach kept the authored spelling past the fold thatwidthalready had (#1159)--minifyruns the compatibility-prefix synthesis inside its own pipeline rather than after it, so a sheet no longer minifies smaller the second time it is run: the prefixed declarations are a rule's shared subset for the factoring like any other, anda{user-select:all}b{-webkit-user-select:ALL}reached its factored form only on a caller's next pass (#1158)--minifycontracts every shorthand family from the longhands that name it. This release adds the four-sided box families (border-width,border-style,border-color,scroll-margin,scroll-padding), the eight border sides and the logical axes,border-radiuswith elliptical corners,border-image,background,mask,columns,column-rule,offset,font-synthesis,-webkit-text-stroke,text-decoration,flex-flow,text-emphasis,grid-row,grid-column,grid-area,grid-template,grid,overscroll-behavior,contain-intrinsic-size,animation-range,scroll-timeline,view-timeline,container,background-position,white-space,text-wrapandfont. A component written at its own initial, or writteninitial, reads as the slot the shorthand leaves out, soflex-flow: row wrapminifies toflex-flow: wrapandflex: 0 0 autotoflex: none; a repeated side folds to the shortest spelling naming the same sides; and a longhand that only restates what the shorthand in front of it wrote is dropped, so the whole border family written out minifies toborder: 1px solid redalone (#915, #916, #917, #918, #919, #920, #921, #922, #923, #924, #925, #926, #927, #928, #929, #930, #931, #932, #933, #934, #935, #937, #939, #940, #942, #956, #959, #960, #961, #965)- Contraction never writes a declaration the input did not mean. It resets no longhand the run did not write, so a run leaving one of the shorthand's own longhands unwritten needs
--scope=stylesheet: under the default fragment scope an earliertransition-timing-functionfrom another file is not cascade's to reset. It no longer contracts a run holdinginherit,unset,revertorrevert-layer, since a CSS-wide keyword is a whole declaration value and pasting one into a shorthand madepadding-left: inheritbeside its three siblings intopadding: 0 2em 10% inherit, which every browser drops and which lost the element all four paddings. And it no longer loses aborder-imagea neighbouring rule set: the family shared one slot in the hazard model, so a rule holding the slice answered for a neighbour holding the source and the picture went missing (#843, #845, #914, #943, #957, #958, #968) Css.Propertiesgains the typed properties the contractions need:background-position-x/-yand their-webkit-masktwins,column-rule-width/-style,white-space-collapse,column-heightandcolumn-wrap.border-image-outsetandborder-image-widthreject a keyword their grammar does not carry (#926, #927, #928, #936, #938, #941)- A value keeps every digit and every unit the author wrote unless the shorter spelling means the same number.
.4285714emcame out short,calc(hypot(1px, 1px))came out as1.41421356, and a computed dimension past a million units lost its own digits. Acalc()whose result falls below zero keeps its call on a property whose range starts there, since CSS Values 4 sec. 10.3 clamps at used-value time:width: calc(-10px)computes to0pxwherewidth: -10pxis dropped, and folding the call away turned a working declaration into one browsers throw out. That now holds for a<number>as well as a length, soaspect-ratio: sign(-1px)keepscalc(-1)where it wrote-1, output its own reader then refused, at eleven properties among themflex-grow,line-height,tab-size,stroke-widthandborder-radius. A sum leads with a positive term where it has one, socalc(-10px + 100vw)minifies tocalc(100vw - 10px)rather than growing a sign intocalc(3px + -2em)(#350, #354, #362, #367, #676, #731, #967, #1113, #1149) --minifyfolds a value's spelling before two rules are compared, so a hex colour, a NaN, an unreducedmin(), a same-unitcalc(), a shorthand component at its longhand's initial, a repeatedfont-familyentry, acolor-mix()in a wide-gamut space, thesin()-atan2()family inside a custom property, an unitlessline-height: calc(), logical minimum sizes, duration units, stepped functions, hue-angle units andsteps(1)all reach one form (#471, #482, #597, #618, #624, #626, #635, #636, #637, #639, #640, #641, #647, #650, #672, #675, #678, #681, #755, #756)--minifykeeps a value whose shorter spelling would mean something else:display: block ruby,transition: opacity 0s 2s, a lonebackgroundposition, both disagreeing<box>values of a layer,position-area: top center, a box shorthand holding a top-levelvar(), ansrc:outside@font-face, a time unit in avar()fallback, a vendor-prefixed gradient written through a shorthand, afont-familyname the author spelled out, and thepage-break-*twin of abreak-*property (#387, #390, #401, #457, #470, #547, #633, #634, #637, #640, #641, #736, #766, #782, #803)--minifydrops the space at a%or)boundary in the box shorthands and ininset(), and prints a custom property as the author's own token stream, keeping the space a typed position or colour needs after a closing parenthesis and treating[]and{}as ordinary tokens (#614, #619, #623, #697, #700, #703, #709, #719, #720, #722)- A selector rewrite that changes what an element matches is gone.
.c:not(:enabled), an author's:not(:dir(ltr))under--enforce-spec, a single-argument:is()and a double:not()all keep their form, while a whole-rule:is(a, b)whose arguments share one specificity splits into the lista, bbefore rules are compared (#377, #431, #593, #596, #655) - Rules merge on what they mean rather than on how they are spelled:
@mediaand@containerblocks merge by query structure, adjacent@starting-styleand@containerruns merge, a nested@supportscondition simplifies against the ones enclosing it, and@media not all and (X)minifies to the Level 4@media not (X)(#323, #465, #516, #519, #585, #592, #809) - A declaration keeps its place whenever moving it would change what an element computes, whether the neighbour writes the same longhand under another property name, sits behind a nested rule, or reaches the same element from another rule (#352, #364, #376, #383, #386, #414, #415, #447, #452, #453, #454, #742)
--minifyreaches inside every at-rule that has a body.@-moz-document,@starting-style,@whenand@elseare optimised and flattened, an empty one is dropped, a@layerwhose own rules nest declarations is kept, and an unrecognised at-rule's opaque body is written back as the token stream it was read from (#341, #343, #344, #349, #372, #374, #389, #396, #560)- Nesting emits only selectors a browser can match: a rule nested under a pseudo-element parent is dropped wherever it sits, a selector-list or type-selector parent keeps its
:is()wrapper soa { .x& { } }flattens to.x:is(a)and not the class.xa, and--flatten-nestingleaves the result flat (#759, #800, #818, #822, #823, #975) - Default minification adds the WebKit fallbacks Safari 16.4 and Chrome 111 need, with matching
@supportstests, and drops a vendor prefix only when its unprefixed twin is Baseline widely available. A feature query on a prefixed property keeps its guard.mask-compositeandmask-modetake-webkit-mask-compositeand-webkit-mask-source-typein WebKit's own vocabulary,source-overforaddandautoformatch-source, as lightningcss writes them (#325, #378, #447, #751, #758, #797, #1256) Css.Resolvematches an attribute selector the way an HTML document does: the name folds to ASCII lowercase, the values of the HTML attributes that ignore case fold with it, whitespace splits on every ASCII space, and an unquoted value's escapes are decoded (#944, #945)- Computed-value evaluation resolves a direct
inherit,initial,unset,revertorrevert-layerfor every typed property, and whether a property inherits is decided in one place from the typed property (#763, #764) - A value reaching one minified text through two nodes is one node, so rules writing one of them merge in the pass that minifies them rather than the one after.
a{width:1.0px}b{width:1px}folds, and so doflex-basis,column-width,size,initial-letter-wrap,background-sizeandmask-size; a unit minifies lowercase whatever its case, sowidth:10.0PXis10px;content:'x'andcontent:"x"are one string; and ananimationholding nothing but initials is theanimation:noneit prints as, soanimation-range:normalis dropped after either spelling rather than after one of them (#1150, #1185, #1186, #1187, #1188, #1190) --minifyandcascade diffare faster on a large stylesheet, for byte-identical output. The slowest corpus stylesheet drops sharply, a long run of rules sharing one selector no longer allocates quadratically, a 4,000 statement sheet is linear, nested group-rule merges optimise only the newly joined list, and a single invocation reaches a stable result where it used to need a second (#413, #422, #424, #468, #480, #486, #487, #493, #502, #505, #507, #517, #519, #523, #542, #543, #566, #664, #746, #750)
Custom properties
Css.inline_vars ~inline_runtime:trueinlines a reference marked~runtimewhen the sheet defines its variable once. Such a reference always stayed live, so a caller asking for every variable resolved away, as tw's--inlinedoes, still printedcalc(var(--spacing)*4)(#1238).- A custom property's value, a container or media query, and an
@supportscondition all keep the text the author wrote. Whitespace runs survive, where--x: a bcame back asa b; escapes survive, where--v: gre\encame back asgre\E nand@container (min-width: 1\0px)reached the output with a raw U+FFFD. A condition is the question the rendering browser is asked rather than a value to respell, and CSS Custom Properties 1 sec. 4.1 forbids normalizing a custom property; the ends are still trimmed, and--minifystill makes its own separator decisions.Cascade.Token.tgainsrepr, the source text of a token whose spelling reserialization does not give back,Cascade.Token.Whitespacecarries its run, andCascade.Parser.to_string_verbatimwrites them back (#1064, #1065, #1066, #1067) Css.inline_varssees every place avar()can be written: an@font-facedescriptor,@pageand its margin boxes, a@keyframesframe,@position-try, a@supportscondition and a nested rule. A descriptor reference used to be dropped at parse time, andCss.custom_propsmissed a name declared inside@scope,@starting-style,@-moz-document,@whenor@else.Css.vars_of_declarationsreports thevar()inside astroke-dasharrayorstroke-dashoffsetdash, where it returned nothing (#322, #341, #342, #375, #423, #555, #571, #573, #575, #577, #1048)Css.inline_varsresolves a property defined across cascade layers against the sheet's layer order, unwraps an@layeronly where layer order and document order already pick the same winner, and keeps the@propertyregistration of a custom property it leaves live (#357, #371, #373, #416)- Substitution preserves what the declaration was. A custom property keeps its cascade layer and caller metadata, a
page-break-*declaration survives as itself, a reference marked resolved at runtime stays marked, and an overridden variable is reported through~warn(#315, #506, #520) @scope { color: green }reads, where the whole at-rule used to be dropped with a warning, and an at-rule inside a keyframe or descriptor block costs only itself rather than the rest of the block, so@keyframes k { to { @e {} opacity: 1 } }keeps the opacity (#1068, #1069)--minifykeeps the quotes on a<string>written to a custom property whose@propertysyntax accepts only an ident, and the space between the repetitions of a<type>+initial value (#626, #704)- A custom property whose value is only whitespace holds the empty value, so
--x:minifies to--x:rather than growing a space on every formatting pass, andCss.Declaration.custom_propertytrims to match (#1157) Css.resolve_themeaccounts for the declarations@keyframes,@page,@position-tryand a@supportscondition carry, builds eachtheme_defaultsbinding withparse_custom_propertyrather than reparsing assembled text, and binds a name that needs escaping instead of refusing it (#317, #324, #327, #421, #439)Css.inline_varsstays linear in at-rule nesting depth and no longer costs a square in the variable count: a 12,800-variable sheet is no longer quadratic (#481, #568, #569)Css.Variables.read_reference_bodyreads avar()argument list into a typed variable handle, andtyped_custom_propertywrites a declaration from a value already typed by its@propertyregistration (#626, #630, #642)
Canonical diff
- The projection canonicalises layer declarations to one form, so two sheets that establish the same layer order in the same places compare equal however they spell it. A single
@layer a, b;, per-layer@layer a;statements and the blocks alone all reach one@layer <names>;per conditional-free run, eacha.bpath expanding toathena.b; nothing is hoisted across a conditional group that declares a layer, so the order is preserved. This is what made Tailwind's preflight, whose layers are declared in one statement, report against tw's block-by-block sheet (#1275). --diff=canonicaldrops an@supportsblock whose guard is false however a browser answers it, reading a parenthesised<general-enclosed>term as the false CSS Conditional 3 sec. 6.1 makes it: Tailwind's@supports (@media(width>=1px): var(--tw))against nothing compares equal, under--enforce-spectoo, since no reading answers the guard yes.--minifykeeps the guard, andSupports.never_holdsis the reading (#1270)cascade diffno longer exits 2 over a declaration its reader refused: the reader is held to the browser's accept set, so a browser drops that declaration from whichever file holds it and renders the same, and the verdict is the one over what remains, the parse warning kept. Tailwind'sfilter: blur(<value>)placeholder against nothing exits 0. A rule the reader dropped still withholds the verdict, andCss_compare.unread_separatesis the library's answer (#1270)- A
color-mix()or a relative colour over alight-dark()argument is thelight-dark()of that operation over each branch, which CSS Color 5 sec. 6 picks by the element's colour scheme, socolor-mix(in oklab, light-dark(red, #00f) 50%, #0000)andlight-dark(#ff000080, #0000ff80)are one colour under--diff=canonicaland--minifywrites the second. A mix a branch cannot fold stays as written, and--losslesskeeps the mix (#1270) - An angle compares as the degrees it names under
--diff=canonical, which CSS Values 4 sec. 6.1 makes one dimension underdeg,grad,radandturn:.5turnand180degare one angle inrotate, in atransformfunction and in an unregistered custom property's stream, and1.5radis the85.9437deglightningcss writes, under the six-significant-figure budget a quotient already takes;--losslesskeeps the unit as written (#1270) - A colour function in a custom property's stream reads a missing axis as the zero CSS Color 4 sec. 4.4 makes it, as a colour longhand already did, so
--tw-mask-top-from-color:oklab(0% none none/.5)and its#00000080twin are one colour under--diff=canonical; a rule transitioning the custom property keeps them apart, as it does for a longhand (#1270) --diff=canonicalis several times faster on a large stylesheet, for byte-identical output: tw's site sheet against Tailwind's compiled one drops from 22s to 5.5s of CPU. Unwrapping a@supportsguard every target satisfies sent the rules before it through the local passes once per guard, dropping a guarded repeat cost a pass over every selector seen per statement, and a block's conflict candidates were walked once per declaration it holds (#1267)--diff=canonicalno longer reports two rules under a condition and its negation, such as@media (width>=20rem)and@media not (width>=20rem), or@container card (...)and@container card not (...), as moved when they are swapped (#1263)--diff=canonicalno longer reports two sheets as different when one writes its layer order as an@layer a, b;statement and the other by the order of its@layerblocks (#1262)--diff=canonicalno longer reports rules grouped into different@media,@supportsor@containerblocks, or a named@layerblock standing elsewhere among unlayered rules, as a difference (#1259)- A nested
&:where(.dark, .dark *)undersvg *flattens to the selectorsvg :where(.dark, .dark *)reads as, so--diff=canonicalno longer reports the pair as a selector change.Selector.canonicalizedropped the implied universal from a compound it read but kept it on the subject it lifted a spliced parent onto (#1257) --diff=canonicaljudges for the evergreen browsers--minifytargets: a@supportsguard every one of them satisfies is unwrapped and the declaration written before it is dead, a prefix a target needs is written on both sides and one no target needs is dropped from both, and a colour fallback every target parses past is dead. Tailwind's compiled sheet keepscolor-mix(in srgb, ...)fallbacks and@supports (color: color-mix(in lab, ...))twins that lightningcss and tw resolve, and every one of them was reported.--enforce-specnames no browser and keeps them apart.Css.optimizeandCss.canonicalize_rule_ordergain?judge(#1256)- A non-terminating quotient in a length or a percentage compares under the six-significant-figure budget a number's already does:
calc(1/3 * 100%)and33.3333%are one width under--diff=canonical, as Tailwind'sw-1/3and lightningcss's fold are, and--losslesskeeps them apart.--minifystill keeps the quotient as written.Values.calc_ctxgains abudgetfield the projection sets (#1255) - A declaration a
@media,@supportsor@containerblock repeats from the rule with the identical selector before it, value and all, is not a difference: an engine reading the guard sets the same value twice and one that does not reads the first rule alone. A rule between the two that writes the property keeps the repeat. Tailwind nests a colour twin inside its rule where a flat sheet repeats thecontent, and tw's site sheet had ten such entries (#1254) - Two declarations that conflict and sit in the other order inside one rule of the canonical form,
all: unsetcrossingcontent, are reported as that rule's reorder. The report dropped the swap as the projection's own ordering and fell back to a byte diff of the two forms, saying nothing was classified structurally (#1251) - A negated bound on a range feature is the opposite bound:
not (width >= V)and(width < V)compare equal under--diff=canonical, in a container query as in a media query, and--minifywrites the shorter form. Tailwind writes amax-*variant one way and lightningcss the other, so every such block on tw's site sheet was reported as removed and added (#1250) - A
<time>token in an unregistered custom property's stream compares as the duration it names, so--d:.1sand--d:100msare equal under--diff=canonical, as they were once--dwas registered<time>; a property a containerstyle()query names keeps its tokens, since the query compares them as written (#1247) - A reported move has to be one the cascade can see: the moved rule or block conflicts with a statement it crossed. A rule only one sheet has could place an unrelated rule on the other side of a pinned pair, and the report named that as a move although the two orders render the same (#1246)
- Declarations in a
@keyframesframe compare in the canonical order a style rule's do, so two frames holding the same declarations for different properties in another order are no longer a difference (#1241) - Nested
@mediablocks compare by the conditions around each rule rather than by which block encloses which. The two nesting orders of a breakpoint and a colour-scheme block were a difference, and so was a block closed and reopened around the blocks nested in it (#1240) - A
var()colour fallback ending a shadow's length run is read as the colour it is, sotext-shadow:1px 1px var(--c, oklab(from red l a b / 25%))and itsl a b/.25twin are equal under--diff=canonical(#1232) - A
var()fallback no slot can type compares and minifies as the typed value it substitutes into, sowidth:var(--w, rgb(0 0 0 / .1))and itsrgb(0 0 0/.1)twin are equal under--diff=canonical(#1231) --diff=canonicalreads an@containerstyle()orscroll-state()query as one query whatever case its function name is written in, which CSS Values 4 section 9 makes ASCII case-insensitive.@container STYLE(--x:1)and@container style(--x:1)compared as a difference where cascade's own container-condition equality already read them as one (#1226)--diff=canonicalreads a prefixed declaration the WHATWG Compatibility Standard section 3.4.1 names a legacy name alias as the same property as its unprefixed twin, so an identical pair normalizes to the twin alone:a{-webkit-transform:none;transform:none}anda{transform:none}compare equal, and so do the other 57 properties on that list. Only-webkit-text-decoration-colordid, which is not on the list at all. A prefix the section does not name stays a property of its own,-moz-spellings and-webkit-user-selectamong them, as does a differing value or importance. A check comparing a sheet against a minified form that dropped or synthesised one of those aliases changes answer (#1200)- A browser-backed sweep checks the guarantee itself: every pair
--diff=canonicalreports identical is rendered in headless Chrome and a page that paints differently under the two is a conflation - Canonical diff equates the rewrites that cannot change what a browser computes: a shorthand against its four side longhands,
:is(a, b)against the lista, bwhen the arguments share one specificity, equal@supportsblocks hoisted apart,calc(28/14)against2, and a redundant@layerorder pin (#475, #655, #753, #756, #775, #776, #777, #842) - A colour compares as a colour wherever it is written: a
nonechannel as the zero CSS Color 4 sec. 4.4 makes it behave as, a relative colour's origin as a typed colour, a shadow in an unregistered custom property, and a one-word family name quoted or unquoted when the stream proves it is a family (#312, #313, #314, #440, #696, #705, #847) - Canonical diff reports only reorderings that are really present in the two inputs, so a changed declaration no longer makes a neighbour read as moved, a cascade-neutral reordering stays unreported when the sheets also differ elsewhere, and writing one selector as a nested branch or as its own rule is not a move (#779, #780, #781, #819, #825, #826, #831)
- The canonical projection normalises the rules inside every at-rule that has a block, flattens authored nesting before comparing so a nested stylesheet and its flat equivalent read as identical, keeps structurally distinct
@containerconditions in separate cascade slots, and keeps content that is dead only under an assumption about browser support (#393, #529, #576, #760) --diff=treecompares a value on its minified spelling and prints it the way its own file spells it, prints the body of an added or removed rule as declarations, and pairs repeated occurrences of one selector by their declaration properties (#702, #706, #752)cascade diffno longer aborts when a reordered selector has the same rule index on both sides (#582)
Library
Browser_compare.runincascade.browserruns the comparison behindcascade diff --browserfor a program with its own page and sheets, andBrowser_compare.identicalis its verdict (#1260, #1268). It takes exactly two sheets, as the report carries one first and one second; a call that gave a different number used to be accepted and only its first two compared (#1274).Css.add_var_fallbacksgives eachvar()with no fallback of its own the value a lookup answers, so a sheet whose tokens are declared elsewhere, such as Tailwind's@referenceoutput, still resolves them (#1236).Css.to_stringandCss.to_buffertake?rename_custom_property, which rewrites every custom property name — the one a declaration declares and the one eachvar()reads, fallbacks included — from a single function. A consumer namespacing a sheet's variables has no other route: a reference lives inside a typed value, so a transform over the AST would have to rebuild every value holding one (#1235).Cascade.Source_indexlocates each at-rule and function token in a CSS source by byte offset, so a tool that must give back the author's own bytes can splice them at boundaries the parser supplied rather than by counting braces.at_ruleandat_statementare now exclusive: a blockful at-rule no longer also answers as a blockless one with a prelude running to the end of the source (#1234).Cssbuilds every property the AST models, barUnknown_propertyand the@font-facesrcdescriptor, neither of which is a plain property. This release adds the SVG presentation longhands, the CSS Anchor Positioning 1 and Motion Path 1 families, the scroll-driven animation properties, the border-image and multi-column families,contain_intrinsic_sizeand its axis longhands, the flow-relative border shorthands, the decoration-skip and typography longhands, the-webkit-,-moz-,-ms-and-o-spellings its{2:vendor_specific}section documents,view_transition_nameandview_transition_class,Css.all, and the shape, overflow, image, margin-trim, overlay, animation-composition and background-position-axis families, each with its value types (#1027, #1028, #1029, #1030, #1031, #1032, #1033, #1034, #1035, #1036, #1037, #1038, #1039, #1040)Css.Stylesheetreaches every statement and declaration a sheet holds:fold_statements,iter_statements,edit_statements,fold_declarations,statement_declarations,statement_children,map_statement_children,map_statement_declarationsandat_declaration_site.Css.map,Css.sort,Css.layers,Css.layer_blockandCss.flatten_nestingreach a rule inside@scope,@starting-style,@-moz-document,@whenor@else(#317, #337, #355, #356, #363, #368, #381, #382, #384, #389)Css.Resolveanswers the selectors engines answer::nth-child(... of S), the typed:nth-of-type()family,:has(), theiandsattribute case flags, and:emptyfor the elements Selectors 4 and the engines agree on. It ranks a cascade layer's own rules after every one of its sublayers, as css-cascade-5 sec. 6.4.3 requires, so@layer aoutranks@layer a.bhowever the two were declared (#607, #873)Resolve.prepareandResolve.Make.resolve_preparedsplit the sheet-only work out ofresolve, so a caller walking a document pays it once.Resolve.Make.resolveandlayer_orderdocument every block they leave out (#394, #567)Css.Context.matches_mediarespects zero-valued boolean features and resolution units and preserves unknown through negation, andmatches_containerrequires the supplied container to support every queried feature (#868)Css.equal_statementandCss.hash_statementcompare and key a statement without rendering it to CSS text,Css.Values.hash_colorkeys a colour,with_alphasets one, andCss.Properties.compare_propertyandDeclaration.compare_prop_keyare a total order on a property identity (#513, #595)- New building blocks:
Css.unknown_at_rulefor an at-rule cascade has no grammar for,Css.Declaration.value_ofto read a value at a property witness,Css.Properties.read_grid_template_tracks,Cascade.Syntax.is_ident,Css.Color_space.gamut_mapped_srgb_of_oklch,Css.Values.gamut_map_color,Properties.read_filter_functionandpp_filter_function, and the statement-merging passes as callable functions (#591, #592, #600, #616, #626, #717, #872) Cascade_diff.Tree_diff.has_container_added_of_typeandhas_container_removed_of_typelook inside a container reported as a whole, andCascade.Error.to_stringprints back a snippet that is valid UTF-8 (#395, #472)cascadedrops itsuutfdependency for the stdlib UTF-8 decoder, and the library no longer linksunix:mtimereads the monotonic clock--profilewanted and ships a js_of_ocaml implementation (#609, #788)
CLI tools
cascade diff --browser --html PAGE.html A.css B.cssrenders both files over the document in a headless Chromium and reports where the renders differ. The page is loaded afresh under each file at every viewport width and interaction state either file names, animations held at their start and transitions finished, and the whole page is captured and compared pixel for pixel. The verdict is the raster and nothing normalises it: two spellings the browser paints alike are one render, whatevergetComputedStylesays of them, and no cascade comparison is involved, so it can catch a false negative of--diff=canonical. Where a render differs, the computed styles of the elements under the differing pixels are listed, so the report names a property. Only a width or state some element of the page can reach is rendered, which keeps a large sheet to minutes. The browser version and what was rendered are recorded,--jsonwrites the same as a document, and it exits 2 without node or a browser, on a driver failure, or when nothing was rendered, never claiming equivalence. The node and browser locator the render harnesses use iscascade.browser, shared with the CLI (#1252, #1265, #1266, #1268)cascade prune PAGE.html... STYLE.cssremoves the rules a set of HTML documents cannot use,--dry-runreporting instead of writing. It andcascade applyleave alone the two selector forms Selectors 4 defines and no engine implements (#605, #863)cascade fmt --import-root DIRbounds--inline-importsfilesystem reads to the canonical root and its descendants (#744)cascade diffreads either side from standard input when the argument is-, writes the comparison as one JSON document with--json, and bounds a report by whole differences with--limit(#792, #796, #799)- A
cascade diffreport counts one difference for each thing that really differs, prints rule differences in the order the expected side names them, states a selector's move once, names a rule's nested block after the rule it belongs to, shows the contents of a block added or removed wholesale, and reports a rule that changed places next to whatever else the two sheets differ on. It shows the declarations a@keyframesframe gained, lost or changed, and counts a rule as changed only when its own declarations changed, so a rule holding an edited nested rule is no longer summarised as a difference the report cannot show (#345, #385, #389, #474, #580, #581, #783, #784, #794, #798, #814, #815, #906, #907) - A
cascade diffcharacter-level hunk escapes a byte with no glyph, a parse warning both inputs raise prints once under a label naming both files, and the tool scales near-linearly on a sheet where every rule changed (#785, #786, #790, #791, #795) cascade applyreads astyleattribute in source order, empties the<style>blocks it projects rather than removing them, keeps the comments a page holds, and keeps a declaration whose longhand a kept rule writes under another property name.--minimaldrops an inherited declaration only when it truly restates what the element would inherit (#326, #329, #332, #339, #340, #346)- Exit statuses say what happened:
cascade fmtexits 1 when parse recovery left no statement at all, andcascade applyexits 0 when a<style>block parses to at least one statement (#489, #494) - CLI help lists each option and exit status once, says what
--enforce-specgates, and classifies representative selectors forcascade prune.--enforce-speccan drop a rule with a raw non-ASCII selector without--minify, and--profilewithout--minifyno longer prints an empty factoring report (#611, #625, #628, #740)
Testing
- The render harnesses under
test/renderjudge by the raster throughBrowser_compare.run, the oracle behindcascade diff --browser: a stylesheet and each form cascade's transforms make of it (optimize, losslessoptimize, the minified text read back,inline_varsandprune), a pair--diff=canonicalcalls identical, a sheet and itsvar()-resolved form, and a sheet and the pagecascade applywrites from it, must each paint the same page. The computed-style drivers went with the value filters and colour exemptions they needed, so no harness decides that two spellings are one: the browser paints them alike or the run fails (#1269)
1.1.0
Breaking
Css.hexraisesInvalid_argumenton a malformed hex string instead of returning opaque black;Css.hex_optreturns an option. Parsing is unaffected (#232)cascade fmtandcascade diffdrop--memtrace, and the library drops thememtracedependency (#237)- A parse failure that drops every rule makes
cascade fmtexit 1 instead of writing an empty stylesheet with a green status (#273) cascade diff --diff=canonicalexits 1 whenever the two canonical forms differ, and prints the difference. It called such a pair equivalent and exited 0 when the structural walk reached no difference, which left a missing normalisation key and a blind spot in the walk both reading as success.Css_compare.equal ~mode:`Canonicalanswers on the same bytes, andCss_compare.No_diffno longer carries the two canonical forms (#290)Tree_diff.tgainslayer_order; record expressions must set it and record patterns must bind it or use; _(#295)- Optimizer profiling is per run:
Css.optimizeandOptimize.stylesheettake?stats:Stats.tandStats.snapshotreads back an immutable record.Optimize.counters,Optimize.pass_times,Optimize.iteration_statsandOptimize.set_profileare gone, along with the per-pass table and the marginal-stop counter, which no pass had written since the DAG scheduler replaced the multi-pass fixpoint (#288) Resolve.NODEgainstext_children, the data of a node's direct child text nodes, which:emptyneeds (#286)Apply.Make(...).computetakes~sheet:Stylesheet.tinstead of~css:string. It parsed the text itself and answered an empty result when the parse failed, so a caller could not tell invalid CSS from empty CSS; the parse, and the warningsCss.of_stringcollects with it, now stay with the callerApply.result.keptcounts the rules it says it counts. A block at-rule counted once for its wrapper, so a@mediaholding three rules reported one (#287)cascade applyexits 1 when a<style>block or the supplementary stylesheet parses to nothing, and leaves such a block in the page instead of deleting it. It used to delete the block and exit 0, shipping an unstyled page under a green status. A supplementary stylesheet that cannot be read is an error rather than no stylesheet at all (#287)
Parsing
Reader.peek_utf8_atreturnsNonefor negative and out-of-range offsets instead of indexing outside the input (#308)- URL references use RFC 3986 resolution, preserving data, scheme-relative, query-only and fragment-only URLs (#304)
- Container conditions parse component values directly, so strings and escaped or case-insensitive boolean keywords keep their CSS meaning (#305)
- Media queries parse component values directly, so escaped identifiers and balanced general-enclosed values keep their CSS meaning (#306)
- Typed
-webkit-and-moz-aliases use their standard value grammar. shape-outsidereads its whole grammar. Onlynone,circle(), a non-emptyinset()and the CSS-wide keywords were accepted, somargin-box,circle(50%) content-box,url(shape.png)and every other basic shape were rejected and the declaration dropped- An identifier takes any code point at or above U+0080, so a selector such as
.text-↗parses.Css.of_stringtakes?enforce_specandcascadetakes--enforce-specto restrict identifiers to the CSS Syntax 3 range list; output is the same either way (#254) - Parsed rather than dropped with a warning:
perspective: none,text-underline-offset: autoand a negativetext-underline-offset(#212); a nested rule whose selector starts with an identifier, such ash2:where(...)(#193); and an unrecognised media query such astheme(static), kept as never-matching instead of discarding the@mediablock or the@import(#192) - The eleven
scroll-marginproperties take a negative length, as CSS Scroll Snap 1 allows; onlyscroll-paddingis non-negative (#280) - A
}ends a declaration value inParser.block_contentsinstead of being swallowed along with the rest of the block, and a bad string serialises back to source that reads as one instead of vanishing from an at-rule prelude (#284)
Printing and nesting
background-positionandmask-positionprint one position per layer, comma-separated (#209)- A non-integer above roughly 4.6e10 prints as a number again, not as
scale(1.2345678.9012e+19)(#265) - An unknown at-rule keeps the space before its prelude under
--minify;@foo bar {x:1}printed@foobar{x:1}, a different at-rule (#272) - Flattening distributes the parent over every branch of a nested selector list:
.p { a, b { ... } }is.p a, .p b(#205) - Substituting
&wraps a complex parent in:is():.a .b { .dark & { ... } }flattens to.dark :is(.a .b)(#194)
Minification
--losslesspreserves logical sizing and corner aliases in source order.- A hex colour folds to its name whenever the name is no longer, which the hand-copied inversion table missed for
bisque,indigo,orchid,salmon,sienna,tomatoandviolet:#ffe4c4stayed hex where#f0ffffalready becameazure(#289) Css.Values.read_color_namereads every namepp_color_nameprints, not 21 of the 148, andgreyreads asGreyrather than theGraythat printsgray(#289)--losslesskeeps a longhand in source order against the shorthand that resets it, whether the property is typed or not; moving the pair changed what the rule rendered (#267, #270)--losslesskeeps a colour's alpha exact, like its other channels;oklch(... / .74567)printed/.746(#278)--losslesskeeps a flow-relative property in source order against a physical one of the same family, since the writing mode decides which physical side it resolves to (#277)- A vendor-prefixed value repeated with
!importantcollapses to the later declaration; only a genuine value difference is kept as a legacy fallback.display:-webkit-box;display:-webkit-box!importantsurvived as both - A selector list mixing a vendor pseudo-element with ordinary selectors is split, so a browser that ignores
::-webkit-search-cancel-buttonkeeps the other selectors' declarations (#203) - A rule with nested children absorbs a later rule with the same selector when the two are disjoint (#203)
- SVG presentation properties are typed and minify like any other value:
stop-color:#fff,fill-opacity:.1,stroke-dashoffset:0,stroke-dasharray:4 2,paint-order: stroke fill markerstostroke, a redundantvector-effectkeyword dropped, andfill-rule,clip-rule,stroke-linecapandstroke-linejoinread as their own grammars, includingmiter-clipandarcs(#214, #228, #234, #235, #236, #240, #241, #242) - More values fold to their exact equivalent:
grid-auto-flow: row densetodense(#230); a zero angle in radians to0deg, sohue-rotate(0rad)folds like the other units (#229);hue-rotate()with a zero argument inside a custom property (#257); afont-stretchkeyword to its percentage, except in thefontshorthand (#206); and adjacent gradient stops of one colour to a double-position stop, with a0deglinear-gradient angle dropped and the stops reversed, never for the legacy prefixed gradients (#214) --log=cascade.factor:debugreports the optimizer's factoring decisions: each fixpoint iteration and every segment reverted or skipped (#239)--minifyis faster on a large stylesheet, for the same output (#221)
Custom properties and @layer
Css.inline_varsresolvesvar()across@layerboundaries and folds a custom property redefined across layers to its cascade winner, so a layered stylesheet inlines like its unlayered form (#187, #189)cascade applyprojects rules inside@layeronto elements; a fully layered stylesheet, such as Tailwind v4 output, inlined nothing (#188)cascade applyweighs cascade layers instead of ignoring them: an unlayered declaration beats a layered one,!importantreverses that, and a rule with no inline form stays inside its layer (#283)cascade applyleaves a declaration in the stylesheet when a rule inside@scope,@starting-style,@when,@elseor@-moz-documentsets the same property; it moved inline, above the rule that was kept (#286)cascade applyonly inlines a rule whose selector its matcher can represent, so[data-k="X" i], a namespaced selector and the>>>and||combinators stay in the stylesheet rather than being inlined onto nobody and dropped, and:emptycounts an element's text:<p>text</p>is not empty (#286)Css.vars_of_declarationsreports thevar()references of 39 properties it answered with none, soCss.resolve_themeemits the theme binding forinline-size: var(--w)as it does forwidth: var(--w)(#266)
Canonical diff
- The projection expands selector-list rules onto their branches (#204), folds two conditional blocks sharing a condition (#211), keys
@media not all and (X)as the@media not (X)it equals while still emitting what it read (#231), and keys a run of@propertyrules by name, keeping the last registration (#227) - It skips the rule-regrouping passes, which depend on input order;
Css.optimizetakes?regroupto turn them off (#215, #224) - It normalises the space after a top-level comma in a custom-property value, drops a declaration a later rule with the identical selector also writes (leaving
!importantalone), and pairs exactly matching rules before falling back to the property signature (#206) - A custom property keeps its
!important, its layer and its metadata through the projection, so the differ no longer calls two sheets that disagree about the flag identical (#271) - It rewrites a quoted multi-word font name in a custom property as the
<ident>sequence it unquotes to, the same family name under CSS Fonts 4 sec. 2.1.1:--font-sans: ui-sans-serif, "Noto Color Emoji"and--font-sans: ui-sans-serif, Noto Color Emojireach one form. The structural comparator already folded the two together; the projection did not (#290) - Under
--losslessit keys acolor(srgb ...)whose channels land on whole bytes as thergb()spelling of the same colour, socolor(srgb 1 0 0)andrgb(255 0 0)stop reading as a difference. Exact conversions only:color(display-p3 1 0 0)and an off-grid channel stay distinct, and the printer still emits the function that was written (#289)
Diff report
- Grouping repeated selectors and conditions is linear in the number of rules while preserving source order (#307)
- The cascade layer order the two sheets declare is compared, and the report names the layer pairs that swapped. Dropping an
@layer a;pin, which makes the other layer the weaker one, read as no difference at all - Every entry the report prints names what it is about. A
@propertyor@keyframessurplus reached the rule level, which has no rule to name and printed a bare tree connector while still counting the entry towards the summary;@charset,@namespaceand@layer a, b;had no name at all - A container that changed places is reported whether or not its body changed, on the same order keys the rule level uses. Three absolute-index distances gated it before, so swapping an
@mediawith the rule below it - which changes which declaration wins above the breakpoint - printedCSS files are identicaland exited 0 - A rule that writes one property more than once, as a fallback chain does, is compared occurrence by occurrence; matching by name alone made
a{color:red;color:blue}againsta{color:red;color:green}reportcolor: blue -> red, a value neither side holds (#285) - Containers are compared however deep they nest. The walk stopped at three levels, so a leaf difference under five at-rules was reported as no difference at all, exit code included (#285)
- A container entry with nothing to show under it reads as
(modified, no details); it claimed a position change, which only aReorderedentry establishes (#285) - A comparison that classified nothing says so:
Changes: none classified structurally (see report below). It readNo structural differences, which claimed equivalence over a comparison that fell through to a string diff, and over a side whose content the parser discarded (#285) - A canonical-form difference the structural walk did not reach is printed as
Canonical forms differ:above a string diff of the two forms (#290) - A string diff names the two sides with the labels it was given, so
cascade diffheads it with the two file names instead ofExpectedandActual(#290) - A declaration reorder that decides the cascade is reported inside
@media,@layerand@supports(#268), and the at-rules that carry no selector -@page,@starting-style,@counter-style,@scope, a second@font-face- are compared on their bodies (#269) - An
@propertyis compared on its whole body and the entry names the descriptors that differ (#264); a rule is reported as reordered only when it moved against another rule (#263); and the size summary lists the two files in the order of the---and+++headers, so an addition no longer reads as a shrink (#261) - A selector written by more than one rule is reported once, at the top level and inside a container; when its declarations survive on both sides, spread differently, the entry names the move and counts as a rearranged rule (#259, #260)
- Blocks sharing a condition are reconciled one for one, so three
@containerblocks against two report the removed block rather than two changed containers; the same pairing decides media, layer and supports (#258) - Every statement of an added or removed container is reported, including a nested
@media, and a container is no longer counted again as a rule difference with an empty selector (#253) cascade diffbounds its report to the deepest level that fits, with--depthto pin a level or print the tree in full; parse warnings print above the report, capped per side; and blocks that only moved are reported as a shift run (#210)
New properties and values
- Complete the logical border properties:
Css.border_block_colorand its start/end siblings, theCss.border_inline_widthandCss.border_block_widthshorthands (typelogical_border_width), and the start/end style longhands such asCss.border_inline_start_style(#197, #198, #199, #200) - Add
Css.parse_font_family,Css.parse_list_style_typeandCss.parse_list_style_image, the single-value readers behind thefontandlist-styleshorthands (#201, #202) - Add
Css.Values.oklch_none_huefor an achromatic colour with a missing hue, printed asoklch(55.6% 0 none)(#190)
Testing
- CI rejects merlint findings and incomplete library record patterns (#310)
dune testrenders a stylesheet and its optimised forms in a headless browser and compares the computed style of every element, on a document derived from the stylesheet's own selectors; it skips where no browser is installed (#275)
1.0.0
First public release. Cascade was extracted from the tw (Tailwind CSS v4 in OCaml) project as a standalone CSS command-line tool and library, then stabilised over several internal milestones.
Library
- Typed CSS AST: selectors, declarations, values, statements, and stylesheets are sealed ADTs. Invalid constructions are caught at compile time.
Single warning-aware parse entry point:
Css.of_stringruns CSS Syntax Level 3 recovery and returns(parse, Error.t) result, whereparse = { stylesheet; warnings }.~strict:truepromotes the first warning toError _.Css.of_string_exnreturns the recovered stylesheet directly and raisesError.Parse_erroronError.
- Pretty-printer with separate pretty and minified contexts (
Css.to_string ?minify), with several typed printers exposed (pp_color,pp_length, ...). - Structural transforms (
fold,map,sort,flatten_nesting,inline_imports) and structural CSS diff utilities via thecascade.diffsub-library. - Optimizer with deduplication, rule merging, selector combining, and shorthand/longhand coverage including
allreset folding. Rule merging is order-independent: rules are scheduled through a conflict DAG so cascade-safe reorderings converge on the same output regardless of source order. - Minification optimises estimated compressed (gzip) transfer size by default: a global factoring that shrinks raw bytes but would grow the compressed output is not applied. Pass
~objective:\Raw(CLI--objective=raw`) to optimise raw bytes instead, for output that ships uncompressed. Spec coverage:
- Selectors Level 4: including
:has(),:is(),:where(),:not(), nesting&, and full attribute syntax. - Values & Units Level 4:
calc(),clamp(),min(),max(),minmax(), the modern length units, durations, angles. - Color Level 4: 15 colour spaces including
oklch(),oklab(),lch(),hwb(),color-mix(), plus the 148 named colours. - Conditional Rules Level 3-5:
@media,@supports,@container(including typedstyle()/scroll-state()queries with range operators),@when/@else. - Cascade Level 5:
@layerdeclarations and blocks, CSS-wide keywords, andallreset semantics in the optimizer. - Custom Properties Level 1:
var()parsing/printing, typed fallbacks, theme/default substitution,@propertyregistration. - Fonts Level 4 (
@font-facedescriptors), Animations Level 1 (@keyframes,@starting-style).
- Selectors Level 4: including
- Over 400 typed properties cover box model, flexbox, grid (including
grid-template-areasvalidation), logical properties, typography (font-variant-*,text-emphasis-*,text-decoration-skip-*,initial-letter*), borders andborder-image, backgrounds and gradients, transforms (translate,scale,rotate,transform), transitions, animations (animation-range*, scroll-driven timelines), filters, masks, scroll snap, anchor positioning (position-anchor,position-area,position-try-fallbacks), view transitions, and the common vendor-prefixed properties. - Custom-property workflows: typed
<syntax>parsing for@property, registered-property substitution against an explicitCss.Context.t, and round-trip-stablevar()serialisation with literal fallbacks.
CLI tools
- CLI commands share one binary file reader that closes its descriptor when a read fails (#309)
cascade: pretty-print and minify CSS files. It accepts stdin via-or a missing file argument, and writes output to stdout.cascade --minifyapplies the standard safe transforms, including deduplication, rule merging, selector grouping, empty-rule elimination, and nested-rule flattening, optimising estimated gzip transfer size by default (--objective=rawoptimises raw bytes instead).cascade --inline-importsresolves local@importrules relative to the input file, andcascade --inline-varssubstitutes static custom-property references.--keep-vars=NAMESpreserves selected custom properties.cascade diffprovides structural CSS diffing between two files withauto,tree,string, andcanonicalmodes; respectsNO_COLOR,CASCADE_COLOR, and--color, and colours only when stdout is a tty. Identical files exit 0 and differing files exit 1, so the command slots into CI checks and git hooks. Thecanonicalmode projects both sheets to a normal form first, so equivalent factorings (different rule grouping, cascade-safe rule and declaration order) compare identical rather than as spurious changes.- The CLI is installable as a binary through the Homebrew tap
samoht/tap/cascade, with opam installation still available for OCaml users.
Notes
cascadeparses already-decoded UTF-8 strings. The CSS Syntax Level 3 byte-stream decoding step (BOM handling,@charsetbyte sniffing, HTTP/environment charset fallback) is the caller's responsibility.- CSS nesting round-trips through the parser and printer, and the minifier flattens nested rules when safe.
@importrules are preserved by default. Use--inline-importsfor explicit closed-world filesystem inlining.- No source-map support.