ohm ← home

Docs

Everything you need to know about ohm: what it will and won't do, the filter syntax it understands, which selectors survive a redeploy, and what to do when a rule silently does nothing.

01What ohm is (and isn't)

ohm runs your own hand-written cosmetic filters — rules like x.com##div[data-testid="metadata"] — as a Chrome extension, scoped to only the hosts those rules mention. That's the entire feature set. It exists because Chrome's Manifest V2 removal killed uBlock Origin, and with it, years of personal cosmetic-filter lists that had nowhere left to run.

A few things it deliberately doesn't do, stated plainly because the category invites scope creep:

02How it works

The generator parses your pasted list entirely in your browser tab — nothing you paste is ever sent anywhere. It groups the surviving rules by host, builds a Manifest V3 extension around them, and packages it as a zip:

  1. Paste. A uBlock Origin cosmetic filter list — the same .txt you were using before.
  2. Parse. Client-side JavaScript splits it into rules, drops what ohm can't run (see below), and reports what it kept and skipped.
  3. Build. A manifest.json scoped to exactly the hosts your surviving rules mention, plus a rules.js data file and a content.js that applies them.
  4. Download & load. Unzip, open chrome://extensions, enable Developer mode, click Load unpacked, pick the folder.

There's no server anywhere in this pipeline. The generator is a static page; the extension it produces has no host_permissions beyond the sites you named and no code path that could reach the network even if it wanted to.

03Filter syntax reference

Each rule is one line: host##selector. The host can be a comma-separated list (x.com,pro.x.com##...); each host gets its own copy of the rule. host#?#selector (uBO's "extended" cosmetic syntax) is accepted the same way.

What gets skipped, and why

Line looks likeWhy it's skipped
||doubleclick.net^$third-partyNetwork filter — ohm only does cosmetic filters, never blocks requests
x.com#@#.adException rule — nothing to except, since ohm doesn't ship default rules
x.com##+js(...)Scriptlet injection — out of scope, only plain content-script behavior is supported
x.com##^script:has-text(...)HTML filter — not implementable as a content-script style/DOM tweak
*##.ad or example.*##.adWildcard host — ohm needs a concrete host to scope the extension's permissions to

Every skipped line is listed with its line number and reason in the README.md bundled into your download, so nothing disappears silently at import time.

Supported procedural operators

Beyond plain CSS selectors (which just get hidden with display: none !important), ohm supports one procedural operator per rule, in the form selector:op(argument):

OperatorExampleWhat it does
has-textspan:has-text(Sponsored)Hides the matched element if its text content includes the given string
upwardspan:upward(3)Walks up 3 parent elements from the match and hides that ancestor instead
upwardspan:upward(.card)Non-numeric argument: hides the nearest ancestor matching that selector, via closest()
nth-ancestorspan:nth-ancestor(2)Alias for the numeric form of upward
remove-classb:remove-class(badge-verified)Strips one or more classes (comma-separated) from the match, leaving it in place
remove-attra:remove-attr(target)Strips one or more attributes (comma-separated) from the match
remove.toast:remove()Removes the matched element from the DOM entirely
style.card:style(opacity: 0.4;)Appends raw CSS declarations to the match's inline style — include !important yourself if you need it

Only one procedural operator per rule. ohm's content script matches each rule against a single flat pattern, base-selector:op(argument), anchored to the end of the string. It cannot parse two chained or nested ops in one selector.

Won't work: div:has(> span:has-text("Suggested Videos")):upward(6) — nesting :has-text() inside :has(), then chaining :upward() after it. This parses without any error and ships in your zip, but silently matches nothing at runtime, because the resulting "base selector" ohm tries to run is left with unbalanced parentheses.

Works: span:has-text(Suggested Videos) — one flat operator, applied directly. If you need to hide a specific ancestor rather than the text node itself, use :upward() alone against the most specific selector you can find for that ancestor, rather than combining it with a text match.

04Durability: which selectors last

This is the single highest-value thing to get right in your list, because it's the difference between rules that survive a year and rules that break every time a site redeploys its frontend.

Fragile — avoid when you have a choice

Durable — prefer these

When both exist for the same element, prefer the attribute selector. If only a generated class is available, it'll probably still work today — just expect to revisit it eventually, and know that when it silently stops, the fix is almost always "find the new generated class and swap it," not "the whole approach is broken."

05Troubleshooting

ohm's own principle is to degrade loudly, not silently — but a rule that's syntactically valid and simply targets the wrong element has no way to announce that. Here's how to tell the difference between "not installed right" and "the selector stopped matching."

Confirm the extension is even running

  1. Open chrome://extensions and confirm ohm is listed and enabled, with no errors badge.
  2. On a page one of your rules targets, open DevTools → Elements, and search (Ctrl/Cmd+F in the panel) for a <style> tag containing one of your selectors. If your list had any plain CSS rules for that host, this tag should exist — if it doesn't, the content script isn't running on that page at all (wrong host scoping is the usual cause).

A CSS rule isn't hiding anything

Copy the selector out of the injected <style> tag and paste it into the DevTools console as document.querySelectorAll('your-selector'). An empty result means the selector no longer matches anything on the page — the site changed its markup. Zero results is the most common failure mode and is almost always a durability problem (see above).

A procedural rule (has-text, upward, etc.) isn't working

06FAQ

Why did uBlock Origin stop working?

Chrome removed the Manifest V2 code paths in 2025, including the enterprise policy that had been keeping MV2 extensions alive. uBlock Origin's full feature set — network-level blocking via webRequestBlocking — depends on APIs Manifest V3 doesn't have. uBlock Origin Lite is Chrome's sanctioned MV3 replacement, but it's deliberately declarative: no custom filters, no element picker, no dashboard.

Why can't ohm block ads or network requests?

By design, not by platform limitation. Cosmetic filtering — hiding elements with CSS after the page loads — never depended on the APIs MV3 removed; it's just a content script, which MV3 still supports fine. Network-level blocking is a different, larger problem this project isn't trying to solve. If you want that too, run uBlock Origin Lite alongside ohm — they don't conflict.

Is my filter list private?

Yes. The generator runs entirely in your browser tab — there's no server it talks to, and the extension it produces has zero network permissions. You can verify this yourself: view-source the generator page, or read the roughly 250-line content.js that ends up in your download.

Why "Load unpacked" instead of the Chrome Web Store?

Every list is different, so every generated extension is a unique, personal build — there's nothing generic to publish. Chrome Web Store review for extensions touching social media sites is also slow and unpredictable. Load unpacked is a few extra clicks in exchange for skipping all of that.