A browser extension that hides star counts, like counts, and other bias-inducing engagement signals on the web. Ships with presets for GitHub, GitLab, X, and Twitter, and lets users add their own CSS-selector rules for any site they grant permission to.
- Framework: WXT (Manifest V3)
- UI: React 18 + TypeScript
- Targets: Chromium (primary) and Firefox
- License: MIT
- Node 22 (a
.mise.tomlis provided;mise installwill pick it up) - pnpm 9.15.0 (enabled via
corepack; the.mise.tomlhook runscorepack enableon directory entry)
If you do not use mise, just make sure node -v reports 22.x and pnpm 9.15.0
is on your PATH.
pnpm installpostinstall runs wxt prepare, which generates the .wxt/ types the
TypeScript project depends on. If you ever see missing types for the
auto-imported storage, browser, defineBackground, etc., re-run
pnpm install or pnpm wxt prepare.
Start a dev build with hot reload. WXT launches a fresh browser profile with the extension loaded.
pnpm dev # Chromium
pnpm dev:firefox # Firefoxpnpm build # .output/chrome-mv3
pnpm build:firefox # .output/firefox-mv2
pnpm zip # zipped artifact for the Chrome Web Store
pnpm zip:firefox # zipped artifact for AMOpnpm compile # tsc --noEmit, strictentrypoints/
background.ts MV3 service worker: seeds storage, watches rule
changes, (un)registers dynamic content scripts for
user-defined hosts.
content.ts Static content script matched on the preset hosts.
Runs the rule engine at document_start.
popup/ React popup UI: toggles presets, adds/removes user
rules, requests host permissions at rule-add time.
src/rules/
presets.ts Built-in rules for github.com, gitlab.com, x.com,
twitter.com.
store.ts Storage layer over wxt's `storage`. Persists user
rules and per-preset overrides; computes effective
rules.
engine.ts Injects a single <style> element with
`visibility: hidden !important` for selectors whose
url pattern matches the current page. Re-applies on
SPA URL changes (pushState / replaceState / popstate
/ hashchange).
dynamic-scripts.ts Calls `chrome.scripting.registerContentScripts` for
user rule hosts that aren't already covered by the
static preset matches.
messaging-bg.ts Background-side message helpers.
match.ts Thin wrapper around WXT's `MatchPattern`.
wxt.config.ts Manifest builder. `host_permissions` are limited to
the preset hosts in production; setting `E2E=1`
widens them to `*://*/*` (see "Testing" below).
A few intentional choices worth knowing about:
- Hiding, not removing. The engine sets
visibility: hidden !importantrather thandisplay: noneor DOM removal. This avoids layout shifts and keeps the page's reflow behaviour intact. - Single injected
<style>per page. The engine collects all active selectors into one rule list and re-uses one<style id="starhater-style">element. Cleanup happens on the content script'sAbortSignal. - Two sources of rules. Presets live in code (
presets.ts); their on/off state is stored as an override map insync:presetOverrides. User rules live entirely insync:userRules. Both stores are watched by the background and the content script. - Permissions are requested, not preset. Adding a user rule calls
browser.permissions.request({ origins: [urlPattern] }). Without that grant,syncDynamicContentScriptswill silently skip registering the script for that host.
The Playwright suite under e2e/ boots a real Chromium with the unpacked
MV3 build loaded as an extension and exercises:
- The bundled GitHub preset against the real
https://github.com/tai2/star-haterpage. - Toggling presets from the popup.
- The full lifecycle of a user-defined rule (add via popup → dynamic
content script registered → element hidden → delete → element visible
again) against a hermetic
127.0.0.1server.
Run the suite:
pnpm e2e # builds with E2E=1, then runs Playwright
pnpm e2e:build # build only (writes .output/chrome-mv3)The pnpm e2e script always runs e2e:build first — the production
manifest is never tested. Two things to keep in mind:
E2E=1widenshost_permissionsto*://*/*. Without this, the popup'schrome.permissions.request(...)call triggers a native browser prompt that Playwright cannot dismiss. With it, the grant is implicit. Don't ship an E2E build.headless: falseis required. Chrome extensions don't load in the default headless mode. The fixtures launch achromium.launchPersistentContextwith--load-extension+--disable-extensions-except. On CI we wrap the run inxvfb-run(see.github/workflows/ci.yml).
The HTML report lands in playwright-report/; failure traces go to
test-results/. Both are gitignored.
.github/workflows/ci.yml runs on every push to main, every pull request,
and on manual dispatch. It has two jobs:
- Type-check —
pnpm compile. - Playwright E2E —
xvfb-run -a pnpm e2eonubuntu-latest, with the Playwright browser cache keyed offpnpm-lock.yaml. The HTML report is uploaded as an artifact on every run; traces are uploaded on failure.
forbidOnly and retries: 2 only apply when CI=true is set.
- Append a
Ruleentry toPRESETSinsrc/rules/presets.ts. Use thepreset.<host>.<what>id convention. - If the host isn't already covered, add it to the
matchesarray inentrypoints/content.tsand toPRESET_HOSTSinwxt.config.ts. - Add a test under
e2e/exercising the preset on a real page.
For a user-supplied rule you don't need to touch any of this — adding it through the popup is enough.
entrypoints/ Extension entry points (background, content, popup)
src/rules/ Rule model, storage, engine, dynamic scripts
e2e/ Playwright tests + fixtures + local http helper
public/icon/ Extension icons (copied verbatim by WXT)
wxt.config.ts Manifest configuration
playwright.config.ts Playwright configuration
.github/workflows/ CI