npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

zero-specificity-bridge

v1.0.0

Published

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.

Readme

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.