A cross-browser extension (Chrome MV3 + Firefox) and embeddable web widget that detects doomscrolling and interrupts you with a pixel-art jumpscare, meme burst, and modal prompt.
No tracking. No telemetry. All data local.
git clone https://github.com/youruser/enough-bruh.git
cd enough-bruh
npm install
npm run buildLoad in Chrome: chrome://extensions/ → Enable Developer Mode → Load Unpacked → select dist/chrome
Load in Firefox: about:debugging → Load Temporary Add-on → select dist/firefox/manifest.json
src/
├── shared/
│ ├── types.ts # All types, DEFAULT_SETTINGS, SOCIAL_PATTERNS, BUNDLED_MEMES
│ └── storage.ts # chrome.storage.local / localStorage abstraction + snooze/backoff helpers
├── heuristics/
│ └── detector.ts # DoomscrollDetector class — rolling window engine
├── audio/
│ └── player.ts # AudioPlayer — HTMLAudioElement, volume, intensity, reduced-motion
├── visuals/
│ ├── overlay.ts # OverlayRenderer — meme burst + static safe mode
│ └── modal.ts # ModalUI — ARIA dialog, EN/TL copy, keyboard accessible
├── content/
│ ├── index.ts # Content script — scroll listener, evaluation loop, coordinator
│ └── overlay.css # All injected styles (overlay, modal, jumpscare warning)
├── background/
│ └── index.ts # Service worker — messaging, cross-tab tracking, programmatic injection
├── popup/
│ ├── popup.html # Quick-access toolbar popup
│ └── popup.ts
├── options/
│ ├── options.html # Full settings page
│ ├── options.css # Pixel/retro UI theme (Press Start 2P)
│ └── options.ts
├── widget/
│ ├── widget.ts # Standalone embeddable widget (no extension needed)
│ └── demo.html
└── manifests/
├── manifest.chrome.json
└── manifest.firefox.json
scripts/
├── build.mjs # esbuild pipeline for chrome / firefox / widget targets
├── gen-icons.mjs # Generates valid PNG icons (16/48/128px) via pure Node zlib
└── gen-memes.mjs # Generates pixel-art meme PNGs via pure Node zlib (no canvas)
tests/
├── heuristics.test.ts # 23 tests — DoomscrollDetector, extractDomain, isSocialFeed
├── accessibility.test.ts # 10 tests — OverlayRenderer safe/reduced-motion guards
├── audio.test.ts # 8 tests — AudioPlayer volume, sequence, photosensitive
├── storage.test.ts # 7 tests — loadSettings, saveSettings, snooze, backoff
└── e2e/widget.spec.ts # Playwright E2E — widget demo page
DoomscrollDetector tracks three independent signals:
| Signal | How it works | Config key |
|---|---|---|
| Scroll rate | Counts scroll events in a rolling time window; fires if rate >= threshold | scrollThresholdPerSec, rollingWindowSec |
| Time on site | Stamps domainEnterTime on page visit; elapsed time checked on every evaluation — counts while idle |
sustainedTimeSec |
| Consecutive social visits | Increments per social-feed navigation across tabs | consecutiveVisits |
Trigger rule:
- Any 2+ signals fires (scroll+time, scroll+visits, time+visits).
- Idle-alone path: if
countIdleTime=trueand current domain is a social feed andtimeOnDomain >= sustainedTimeSec, fires regardless of scroll — so just having the tab open counts.
Clock injection: DoomscrollDetector accepts a nowFn?: () => number for deterministic testing without Date.now().
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Master on/off |
audioEnabled |
boolean | true |
Enable all audio |
fahhEnabled |
boolean | true |
Play fahh.mp3 clip |
flashbangAudioEnabled |
boolean | true |
Play flashbang.mp3 |
volume |
0–100 | 80 |
Master volume (intentionally loud) |
visualsEnabled |
boolean | true |
Enable meme burst |
visualIntensity |
0–100 | 60 |
Controls image count and animation speed |
safeMode |
boolean | false |
Limits brightness/contrast, caps images at 3 |
photosensitiveMode |
boolean | false |
Disables ALL flashing + aggressive audio |
scrollThresholdPerSec |
number | 3 |
Scroll events/sec to count as active scrolling |
sustainedTimeSec |
number | 15 |
Seconds on social feed before time signal fires |
consecutiveVisits |
number | 3 |
Social tab visits in a row to fire visits signal |
rollingWindowSec |
number | 10 |
Window for measuring scroll rate |
snoozeDurationMin |
number | 5 |
Duration of snooze in minutes |
exponentialBackoff |
boolean | true |
Soften intensity on repeated triggers |
jumpscareWarning |
boolean | true |
Show flashing WARNING screen before interrupt |
dndEnabled |
boolean | false |
Enable Do Not Disturb schedule |
dndStart / dndEnd |
string | 22:00/08:00 |
DND window (HH:MM, supports overnight) |
trackedDomains |
string[] | [] |
Allowlist mode — when non-empty, only these domains are monitored |
disabledDomains |
string[] | [] |
Never run on these domains |
whitelistedDomains |
string[] | [] |
Never trigger on these (whitelisted) |
localLoggingEnabled |
boolean | false |
Log trigger events locally to storage |
assetPackOptIn |
boolean | false |
Allow downloading extra meme packs |
npm run build # gen-icons + gen-memes + esbuild → dist/chrome, dist/firefox, dist/widget
npm run gen-icons # Generates assets/icons/icon-{16,48,128}.png via Node zlib (no deps)
npm run gen-memes # Generates assets/images/meme01-06.png via Node zlib (no deps)
npm run build:watch # Watch mode
npm run clean # Delete dist/ and coverage/
npm run lint # ESLint on src/
npm test # Jest unit tests (48 tests)
npm run test:e2e # Playwright E2E (requires build first)
npm run zip:chrome # Zip dist/chrome → enough-bruh-chrome.zip
npm run zip:firefox # Zip dist/firefox → enough-bruh-firefox.zipesbuild compiles all TypeScript targets as IIFE bundles (not ESM) — required for Chrome MV3 service workers and Firefox background scripts.
| Permission | Why |
|---|---|
storage |
Saves settings, snooze state, trigger log to chrome.storage.local |
activeTab |
Read current tab URL for domain checks |
alarms |
Snooze timer via chrome.alarms |
scripting |
Programmatically inject content.js into already-open tabs on install/update |
tabs |
Query existing tabs for programmatic injection |
host_permissions: <all_urls> |
Content script injection on all sites |
By default enoughBruh monitors all domains matching SOCIAL_PATTERNS. To restrict it to specific sites only:
- Open Settings → [08] DOMAINS
- Add domains to Tracked domains (allowlist), e.g.:
reddit.com tiktok.com - When the allowlist is non-empty, enoughBruh only runs on those domains and ignores everything else.
When jumpscareWarning: true (default), a full-screen pixel-art warning flashes for ~2 seconds before the meme burst:
- Red hazard-stripe border, blinking
!!icon - Text: WARNING / JUMPSCARE INCOMING / THIS MAY CAUSE A HEART ATTACK
- Audio fires immediately after at full volume (default 80)
- Disable in Settings → [03] BEHAVIOR → Jumpscare warning screen
- Photosensitivity safe mode — disables ALL flashing visuals and aggressive audio
- Safe mode — limits brightness/contrast, caps images at 3
prefers-reduced-motion— automatically shows static toast + skips flashbang- Fullscreen video — visuals suppressed when video is fullscreen
- ARIA — overlay:
role="alert", modal:role="dialog" aria-modal="true", all buttons focusable, Escape dismisses - Jumpscare warning is skipped automatically if
photosensitiveModeis enabled
npm test # 48 unit tests
npm test -- --coverage # With coverage report
npm run build && npm run test:e2e # Playwright E2ETest setup uses jest-environment-jsdom. E2E tests use Playwright against dist/widget/demo.html.
All data is stored locally in chrome.storage.local. No network requests are made by the extension itself. No telemetry, analytics, or tracking. Debug logging is off by default and writes only to local storage when enabled. See PRIVACY.md.
- Smart detection — Monitors scroll intensity, time on site, and consecutive social media visits using a configurable rolling window.
- Audio interrupts — Plays a "fahhh" clip followed by an optional flashbang meme sound.
- Meme image burst — Cascades meme images across the screen in a playful animation.
- Friendly modal — Offers Snooze, Take a Break, Disable on Site, and Settings options.
- Exponential backoff — Repeated triggers become softer so it's not annoying.
- Full settings UI — Audio toggles, visual intensity slider, sensitivity controls, DND schedule, domain whitelist.
- Privacy-first — All data stored locally. No telemetry by default. Minimum permissions.
- Photosensitivity / Epilepsy Safe mode — Enable this in Settings to disable ALL flashing visuals and aggressive audio.
- Safe Mode — Limits brightness, contrast, and animation frequency. Caps images at 3.
prefers-reduced-motion— If your OS has this enabled, enoughBruh automatically shows only a static, non-animated toast with no flashbang sound.- Fullscreen video — Visuals are suppressed when a fullscreen video is playing.
- Individual toggles — You can disable audio and visuals separately. You can also disable only the flashbang sound while keeping the "fahhh" clip.
- To fully disable all effects: Turn off both "Sound" and "Visuals" in the popup, or disable the extension entirely.
- Overlay has
role="alert"andaria-live="assertive". - Modal has
role="dialog"andaria-modal="true". - All buttons are keyboard-focusable. Modal can be dismissed with Escape.
- Screen-reader-only text provides context for assistive tech.
- Node.js ≥ 18
- npm
git clone https://github.com/youruser/enough-bruh.git
cd enough-bruh
npm install
npm run build- Go to
chrome://extensions/ - Enable Developer mode
- Click Load unpacked
- Select the
dist/chromefolder
- Go to
about:debugging#/runtime/this-firefox - Click Load Temporary Add-on
- Select
dist/firefox/manifest.json
After building, open dist/widget/demo.html in any browser — no extension needed.
# Unit tests (Jest)
npm test
# Unit tests with coverage
npm test -- --coverage
# E2E tests (Playwright) — requires build first
npm run build
npm run test:e2eenough-bruh/
├── assets/
│ ├── audio/ # fahh.mp3, flashbang.mp3 (replace placeholders)
│ ├── icons/ # Extension icons (replace placeholders)
│ └── images/ # meme01–06.webp (replace placeholders)
├── src/
│ ├── shared/
│ │ ├── types.ts # All types, defaults, social patterns
│ │ └── storage.ts # chrome.storage / localStorage abstraction
│ ├── heuristics/
│ │ └── detector.ts # Doomscroll detection engine
│ ├── audio/
│ │ └── player.ts # Audio playback module
│ ├── visuals/
│ │ ├── overlay.ts # Meme image burst renderer
│ │ └── modal.ts # Interruption modal/toast
│ ├── content/
│ │ ├── index.ts # Content script (scroll listener + coordinator)
│ │ └── overlay.css # All overlay/modal styles
│ ├── background/
│ │ └── index.ts # Service worker (messaging, cross-tab tracking)
│ ├── popup/
│ │ ├── popup.html # Quick-access popup UI
│ │ └── popup.ts
│ ├── options/
│ │ ├── options.html # Full settings page
│ │ ├── options.css
│ │ └── options.ts
│ ├── widget/
│ │ ├── widget.ts # Standalone embeddable widget
│ │ └── demo.html # Demo page with controls
│ └── manifests/
│ ├── manifest.chrome.json
│ └── manifest.firefox.json
├── tests/
│ ├── setup.ts
│ ├── heuristics.test.ts
│ ├── accessibility.test.ts
│ ├── audio.test.ts
│ ├── storage.test.ts
│ └── e2e/
│ └── widget.spec.ts
├── scripts/
│ └── build.mjs # esbuild build script
├── .github/workflows/
│ └── ci.yml # GitHub Actions CI
├── package.json
├── tsconfig.json
├── jest.config.cjs
├── playwright.config.ts
├── .eslintrc.cjs
├── .editorconfig
├── .gitignore
├── README.md
├── PRIVACY.md
└── PUBLISHING.md
See PRIVACY.md for full details.
TL;DR:
- All data stays on your device.
- No telemetry, analytics, or tracking by default.
- No network requests unless you explicitly opt into asset packs.
- Minimum browser permissions:
storage,activeTab,alarms. - Debug logging is local-only and off by default.
See PUBLISHING.md for step-by-step guides for Chrome Web Store and Firefox Add-ons.
# Watch mode (rebuilds on file change)
npm run build:watch
# Lint
npm run lint
# Clean
npm run clean- Fork the repo
- Create a feature branch
- Add tests for new functionality
- Ensure
npm testandnpm run lintpass - Submit a PR