← Back to Stash

Stash's Chrome extension injects a preview modal into every page where the user clicks the capture button. On most sites it works. On LinkedIn, for one user, the modal refused to appear — clicks registered, the capture pipeline ran, but the screen stayed frozen with no visible UI.

Diagnostics confirmed everything I could think of: the modal element was in the DOM, the dialog was open, the host had correct dimensions (1273×995), shadow DOM was attached, event listeners were bound. And yet — getComputedStyle(overlay).display === “none”.

The thing that made it baffling: I'd setdisplay: flex inline with !important. There was nothing in my code — anywhere — that set display to none. So what was overriding the highest-priority rule I know how to write?

Most developers reason about CSS in one layer: the styles their app declares. In reality, the cascade resolves across three origins, and the priority order is not what you think:

User-Agent  ← browser defaults (weakest)
Author      ← your stylesheets + inline style="..."
User        ← browser extensions, user stylesheets

Priority (without !important):  User-Agent < User < Author
Priority (with    !important):  Author !imp < User !imp < User-Agent !imp

Notice the flip: without !important, author wins. With !important, the order reverses — a user-origin !important rule outranks any author-origin one, inline or not. This is by design: it lets users with accessibility needs (enlarged text, high-contrast) force styles that no website can override.

The side effect: any browser feature that injects user-origin CSS — accessibility stylesheets, reader mode, and most importantly, ad-blockers— sits above every declaration you can write.

Shadow DOM is sold as CSS isolation, and it mostly is — for one direction. It prevents author-origin styles from leaking in or out across the shadow boundary. That's what you want when you're injecting UI into pages whose CSS you don't control.

What it does not block is user-origin CSS. The spec explicitly allows user-origin rules (and inherited properties in general) to penetrate the shadow boundary. Which means:

  • Your shadow DOM shields your modal from LinkedIn's stylesheet. Good.
  • It does not shield your modal from the user's ad-blocker. Easy to miss.
  • An inline style.setProperty(“display”, “flex”, “important”)is author-origin no matter where the element sits. It cannot outrank a user-origin !important.

Brave Shields, uBlock Origin, AdBlock Plus, and Ghostery all ship cosmetic filter lists — CSS rules that hide elements matching known ad patterns. Those lists have been curated for over a decade. Browse the public EasyList rulesets and you'll find thousands of entries like:

.overlay { display: none !important }
.modal { display: none !important }
.popup { display: none !important }
.popover { display: none !important }
.banner { display: none !important }
.ads, .ad, #ad { display: none !important }
[class*="modal-backdrop"] { display: none !important }

The obvious, semantic class names — the ones your IDE autocompletes, the ones every tutorial uses — are already claimed. A capture modal named .modalisn't distinguishable from an ad modal named .modal, and the filter doesn't try to tell them apart. It just hides.

The fix in the Stash codebase was a one-line change per class name:

// before — invisible on Brave, uBlock, AdBlock
<div class="overlay">
  <div class="modal">...</div>
</div>

// after — safe
<div class="stash__ov">
  <div class="stash__md">...</div>
</div>

The stash__ prefix is not a style preference — it's a necessity for any DOM a content script injects into pages it doesn't control. The rule generalizes to three items worth internalizing:

  • Every injected element gets a product-specific prefix. Class names, IDs, data attributes. No bare .overlay, .modal, #popup, data-ad. Ever.
  • Shadow DOM is a one-way mirror, not a wall.Author styles don't leak. User styles do. Design for the user-origin layer existing.
  • Test on a blocker-equipped browser. Brave with Shields on, Chrome with uBlock Origin. If your UI breaks there, millions of real users won't see it either.

This isn't a content-script edge case. Any embedded widget — chat bubbles, CMP banners, support popovers, analytics overlays — sits in someone else's page and competes with the same filter lists. The percentage of users running a blocker is not small. On some audiences it exceeds fifty percent. Shipping a UI that works for everyone but them is shipping a UI that works for half your users.

Class names feel like the least important part of the codebase. They're the first thing the cascade resolves against, and the last thing most developers think about. One prefix, everywhere, costs nothing and removes an entire class of silent failure.