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.
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.
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.
| 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.
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";Three mixins, configurable prefix and layer set:
@use "zero-specificity-bridge" as zsb with (
$prefix: "app",
$layers: ("legacy", "tokens", "patches", "components", "overrides")
);zsb.layer-orderemits the@layerorder 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.
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.
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.
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.
: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.
- 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.
If you build on this pattern, a citation is appreciated. See
CITATION.cff.