Modernizing legacy CSS without breaking the cascade — introducing CSSForge

rust dev.to

CSS has changed a lot.

We now have native nesting, :is(), :where(), :has(), range media queries, cascade layers, container queries, @scope, @starting-style, and much more.

But there is still an awkward problem:

How do you safely move an existing flat CSS codebase toward modern CSS?

Going from nested CSS → flat CSS is mostly compiler lowering. Going in the opposite direction is much harder.

Consider:

.card {
  padding: 1rem;
}

.card:hover {
  color: blue;
}

.card > .body {
  min-width: 0;
}

.card + .card {
  margin-top: 1rem;
}

@media (width >= 48rem) {
  .card {
    padding: 1.5rem;
  }
}
Enter fullscreen mode Exit fullscreen mode

A modernized version could be:

.card {
  padding: 1rem;

  &:hover {
    color: blue;
  }

  > .body {
    min-width: 0;
  }

  + .card {
    margin-top: 1rem;
  }

  @media (width >= 48rem) {
    padding: 1.5rem;
  }
}
Enter fullscreen mode Exit fullscreen mode

That example looks simple. Real stylesheets aren't.

Once you introduce selector lists, pseudo-elements, :is(), :not(), cascade layers, !important, non-adjacent rules, comments, duplicate fallback declarations, @scope, container queries, and source-order dependencies, a refactoring that merely looks correct can subtly change the cascade.

That is why I built CSSForge.

CSSForge is a safety-first semantic CSS refactoring and modernization engine written in Rust. It includes both a headless CLI and an interactive terminal workbench.

The core idea: prove before transforming

CSSForge does not treat CSS as text replacement.

Before applying a transformation, its proof model checks things such as:

  • selector/specificity equivalence
  • declaration preservation
  • !important preservation
  • source-order/cascade equivalence
  • at-rule context
  • cascade-layer precedence
  • scope boundaries
  • lossless source editing If a transformation cannot be proven safe, the goal is to refuse it instead of producing prettier but semantically different CSS.

For example:

.alpha .title,
#hero .title {
  color: red;
}
Enter fullscreen mode Exit fullscreen mode

may look like a good candidate for:

:is(.alpha, #hero) .title {
  color: red;
}
Enter fullscreen mode Exit fullscreen mode

but :is() uses the specificity of its most specific argument. That can lift the .alpha branch to ID-level specificity.

CSSForge's design explicitly treats specificity inflation as something to reject rather than hide.

Another common trap:

.card {}
.card__title {}
Enter fullscreen mode Exit fullscreen mode

This is not valid native-CSS nesting:

.card {
  &__title {}
}
Enter fullscreen mode Exit fullscreen mode

Native & is a selector, not Sass-style string concatenation. CSSForge explicitly refuses that transformation as well.

More than basic :hover nesting

The current ruleset covers 26 transformations across several groups, including native nesting, conditional at-rules, modern selector transformations, at-rule merging, deduplication, and structural refactoring.

Examples include:

Nesting

  • nest-pseudo-class
  • nest-pseudo-element
  • nest-attribute
  • nest-compound
  • nest-descendant
  • nest-combinator
  • factor-selector-list Conditional at-rules
  • nest-media
  • nest-supports
  • nest-container
  • nest-starting-style Modern selectors
  • modernize-is
  • modernize-where
  • modernize-media-range-syntax Merging & deduplication
  • merge-same-named-layer
  • merge-adjacent-media
  • merge-adjacent-supports
  • merge-adjacent-container
  • merge-adjacent-identical-selector
  • merge-identical-rule-bodies
  • factor-identical-states-with-is
  • gather-related-selector-rules ## Lossless editing matters

One thing I specifically did not want was:

parse entire stylesheet
→ serialize entire stylesheet
→ generate a massive formatting diff
Enter fullscreen mode Exit fullscreen mode

CSSForge's documented architecture uses semantic parsing for validation but applies changes through targeted byte-range patches, so untouched source can remain byte-for-byte unchanged, including comments, indentation, and quote style.

That matters when reviewing a real PR. A refactoring tool should ideally show you:

 .card {
   padding: 1rem;
+
+  &:hover {
+    color: blue;
+  }
 }
-
-.card:hover {
-  color: blue;
-}
Enter fullscreen mode Exit fullscreen mode

—not a 5,000-line formatting diff.

Interactive TUI + CLI

For interactive work:

cssforge
# or
cssforge interactive ./src/css
Enter fullscreen mode Exit fullscreen mode

The TUI lets you select files and rules, cycle presets, and inspect unified diffs before applying changes.

For automation:

cssforge analyze ./src
cssforge analyze ./src --json

cssforge apply ./src/app.css \
  --preset modern \
  --output new-file
Enter fullscreen mode Exit fullscreen mode

Install with Cargo:

cargo install cssforge
Enter fullscreen mode Exit fullscreen mode

Precompiled builds are also documented for Linux x64/ARM64, Windows x64/ARM64, and Apple-Silicon macOS.

What CSSForge is not

CSSForge is not trying to replace:

  • a formatter
  • a minifier
  • Stylelint
  • Lightning CSS
  • PostCSS
  • your build pipeline The narrower goal is:

Forward semantic CSS refactoring: modernize only where the transformation can be justified, and preserve the original code where it cannot.

That includes deliberately refusing BEM string concatenation, specificity-lifting transformations, destructive reserialization, and arbitrary at-rule movement across cascade barriers.

Open source

CSSForge is written in Rust and released under the MIT license. The workspace includes the main CLI/TUI executable, a reusable core library, and a Ratatui-based TUI crate.

If you work with large CSS codebases, I'd especially like feedback on:

  • CSS patterns that are difficult to refactor safely
  • selector edge cases
  • cascade/specificity traps
  • additional transformations worth proving
  • real-world stylesheets that make good stress tests If this problem interests you, give the repo a try and let me know where it breaks.

Source: dev.to

arrow_back Back to Tutorials