Skip to content

ookyet/zero-specificity-bridge

Repository files navigation

zero-specificity-bridge

Restyle a legacy site without touching its DOM.

By Qifeng Huang (ookyet) · ookyet.com

The toolkit is three Sass mixins. The pattern is the actual product: legacy selectors get mapped to a new design system inside a dedicated cascade layer, wrapped in :where() so their specificity is always (0,0,0). The new styles win because of layer order, not because anyone won a specificity fight. And the bridge is scaffolding with a documented exit, not a compatibility shim you keep forever.

Where this comes from

I built this while migrating ookyet.com off a Hugo theme (PaperMod). The bridge shipped with the site's first commit in September 2025. For the next eight months every page was styled through class names the old theme owned, while the templates stayed untouched. The dual-class handover finished in June 2026, and the site now passes the deletion criterion: strip every legacy class and nothing changes. The bridge is still deployed there, as a working demonstration rather than a dependency.

This repository is the generalization of that migration. Nothing in it is speculative. Every behavioral claim below is asserted in a real browser by npm run verify, against a fixture nastier than most actual themes.

The problem

Migrating a themed site to your own design system usually means one of two ugly things. Either you rewrite every template up front and ship the whole redesign as one risky release, or you start out-specifying the old stylesheet rule by rule and end up with #header .nav ul li a.active and !important everywhere. The root cause is the same in both cases: the old theme owns the DOM's class names, and specificity makes fighting it cumulative. Every override you write is one more thing the next override has to beat.

The pattern

Phase DOM What happens
1 — Bridge untouched Legacy stylesheet demoted into the lowest layer; the new design system styles the legacy selectors via :where() in a patches layer. Full visual migration, zero template edits.
2 — Handover dual-class Templates gain owned classes (.c-*) alongside the legacy ones. Bridge and components consume the same skin mixins, so rendering cannot drift between them.
3 — Exit owned Legacy classes removed. The exit test is mechanical: no node styled only by a legacy selector. Then delete the bridge and the legacy stylesheet; rendering does not change.

There are two roads into phase 1. If you can drop the old stylesheet and own all CSS from day one, the bridge just styles the legacy selectors. That is what I did on ookyet.com. If you cannot delete it yet, you keep it loaded and demote it: @import url("legacy.css") layer(zsb.legacy). That second road never ran on my production site; it is covered by this repository's CI instead, and it is where both sharp edges below were found.

Every row of the table, on both roads, is asserted by the browser test: computed styles are compared across all three phases plus a bridge-deleted end state.

Quick start

npm install --save-dev zero-specificity-bridge sass
// _layer-order.scss — a dedicated module, so the layer order is the
// first thing your compiled CSS establishes (@use must precede all
// other statements, so this include cannot sit at the top of main.scss)
@use "zero-specificity-bridge" as zsb;
@include zsb.layer-order;
// _legacy-intake.scss — demote the untouched legacy stylesheet
@import url("legacy.css") layer(zsb.legacy);
// _patches.scss — the bridge
@use "zero-specificity-bridge" as zsb;

@include zsb.bridge("#masthead .site-title a") {
  color: var(--ink);
  font-family: var(--font-display);
}
// main.scss
@use "layer-order";
@use "legacy-intake";
@use "tokens";
@use "patches";
@use "components";

API

Three mixins, configurable prefix and layer set:

@use "zero-specificity-bridge" as zsb with (
  $prefix: "app",
  $layers: ("legacy", "tokens", "patches", "components", "overrides")
);
  • zsb.layer-order emits the @layer order statement. It must be the first thing your compiled stylesheet establishes.
  • zsb.layer($name) wraps content in a prefix-qualified layer, and rejects names outside the declared set. No author CSS can leak out of the contract.
  • zsb.bridge($selector) wraps content in :where($selector) inside the patches layer. The whole pattern in one call.

Sharp edges

I hit both of these while building the verified example. Both have a guard.

Sass hoists plain-CSS @import above your layer statement. I had arranged for the @layer order statement to be byte zero of the compiled output, then opened the compiled file and found the import sitting above it. That is correct behavior (CSS wants imports first), but it means the import's target layer is established before your declared order. If that layer is not the first name in your order, your cascade has been silently rearranged and no error will ever tell you. guards/check-layer-contract.sh fails the build unless every @import ... layer() targets the first declared layer.

Legacy !important inverts layer priority. For !important declarations the layer order runs backwards: earlier layers win, and a layered !important beats every normal declaration in every later layer. So one color: #999 !important in the old stylesheet survives the entire bridge. In the example fixture it holds through phases 1 and 2 and only clears in phase 3, when the legacy class leaves the DOM and the old selector stops matching. guards/check-important-intake.sh reports every instance up front, and you decide per case: strip it at intake, or accept that the property migrates late.

Verified example

examples/legacy-blog/ is a classic 2010s blog stylesheet with #id-chain selectors up to specificity (1,1,3) and one !important, taken through the full lifecycle. npm run verify compiles it, runs both guards, and asserts computed styles in Chromium for all five states: legacy baseline, phases 1 through 3, and the bridge-deleted end state.

When not to use this

Greenfield work: there is no legacy stylesheet to bridge, so just use cascade layers directly. Changing a button color: a layer architecture pays for itself when you replace a theme's whole visual system, not before. And if nothing actually depends on the old CSS, delete it and go straight to phase 3.

Prior art

:where() and cascade layers are standard, well-documented CSS. See MDN on specificity and the CSS-Tricks cascade layers guide; demoting third-party CSS into a low layer is likewise an established technique. What I have not seen documented elsewhere is the migration lifecycle built on those primitives: bridging legacy selectors to a new design system with the DOM untouched, a dual-class handover whose parity is guaranteed by shared skin mixins, a mechanical exit criterion, and guards that enforce the whole contract in CI. That lifecycle is what this repository claims and ships.

License

  • Code (scss/, guards/, examples/, scripts/, .github/): MIT. Use freely, no attribution required.
  • Documentation (README, CHANGELOG, docs/): CC BY 4.0. Share and adapt with credit to Qifeng Huang (ookyet) and a link back to this repository.

Citation

If you build on this pattern, a citation is appreciated. See CITATION.cff.

About

Restyle a legacy site without touching its DOM: legacy selectors bridged to a new design system at zero specificity inside CSS cascade layers, with a verified three-phase exit path.

Topics

Resources

License

MIT, Unknown licenses found

Licenses found

MIT
LICENSE
Unknown
LICENSE-docs

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors