calm-scroll

Calm Scroll

A Chrome extension (Manifest V3) that blurs images likely to trigger common phobias — spiders, snakes, insects, blood/gore, needles, clowns, dogs — using an on-device ML model (Xenova/mobileclip_s0, a transformers.js port of Apple’s MobileCLIP-S0). All inference runs locally in the browser; no network calls at runtime.

Structure

Requirements

Local setup (required once, before the extension will build/run correctly)

The toolbar icons, vision model, ONNX Runtime WASM binary, and precomputed category text embeddings are all generated, not committed to the repo (see .gitignore). Generate them locally:

npm install                       # also vendors the WASM runtime via postinstall
npm run icons:generate            # generates icons/*.png (zero deps, fully offline)
npm run models:fetch-vision       # downloads + vendors the ~45MB fp32 vision encoder
npm run embeddings:precompute     # downloads the text encoder (~170MB, not kept),
                                   # writes the small category-embeddings.generated.json

Each of these hits the network — that’s expected and only happens at dev/build time on your machine, never inside the shipped extension (which loads everything from its own bundled files with allowRemoteModels: false). Re-run models:fetch-vision/embeddings:precompute if you ever bump @huggingface/transformers or change the model/prompts.

Note on precision: the vision and text encoders both use dtype: 'fp32', not a quantized variant — verified empirically that uint8 quantization collapses this specific model’s discriminative signal almost entirely (every category scored below a “neutral” baseline for every test image), and fp16 fails to load at all (an ONNX Runtime graph-optimizer bug specific to this model’s architecture). fp32 is the only precision confirmed to work correctly here.

Model: upgrade / downsize considerations

Researched in September 2026 as a check on whether Xenova/mobileclip_s0 (11.4M-param vision encoder, ~45MB fp32) is still the right size/accuracy tradeoff. Conclusion: no smaller model is worth switching to, but a same-size accuracy upgrade exists if this gets revisited.

Blur timing (startupDisplay)

Configurable in the options page’s “Blur timing” section (ExtensionSettings.startupDisplay, see src/shared/categories.ts’s doc comment for the full rationale):

Classification itself still fails closed in both modes (an error or timeout is treated as sensitive) — the setting only changes which state an image starts in and which class gets added on a positive result, not the fail-safe direction.

Mechanism: 'blurred' mode’s document_start CSS (public/content/blur.css) is no longer a static manifest.json content_scripts entry, since it now only applies in one of the two modes. src/background/index.ts’s syncBlurCssRegistration() registers or unregisters it via chrome.scripting.registerContentScripts (the scripting permission) instead, keyed off the current setting, on install/startup and every settings change. Verified empirically (tests/e2e/startup-display.spec.ts) that this still blocks any flash of unblurred content in 'blurred' mode — the dynamic registration has the same document_start guarantee a static entry would.

Develop

npm run dev

Then in Chrome: go to chrome://extensions, enable Developer mode, click Load unpacked, and select the generated dist/ folder. @crxjs/vite-plugin gives you HMR — most content/background/popup/options edits apply without a manual extension reload; a full manifest change still needs “Reload” on the extension card.

Build

npm run build

Output goes to dist/; load that folder as an unpacked extension the same way. A Vite plugin (vite.config.ts) prunes a ~23MB unused duplicate ONNX Runtime WASM binary that Rollup otherwise bundles as a side effect of following a reference inside onnxruntime-web — the extension always loads the WASM vendored separately at public/ort/ instead (see src/offscreen/index.ts’s wasmPaths override).

Typecheck / Lint / Format

npm run typecheck
npm run lint
npm run format:check

Testing

npm test          # fast unit tests (src/shared/similarity.ts's decide()) -- no setup needed
npm run test:e2e  # real extension + real model inference against live reference pages

npm test is dependency-free and runs on every change. npm run test:e2e drives the actual built extension in a real browser (Wikipedia’s “Spider” article, plus a best-effort Google Images check) and needs the full local setup above done first — see tests/e2e/README.md.

Notes

Before submitting to the Chrome Web Store

CHROMEWEBSTORE.md (listing copy, permissions justifications, privacy data table, developer info, all 5 graphics assets) and PRIVACY.md (the policy itself, hosted and confirmed live at pavgreen.github.io/calm-scroll/PRIVACY.html) are both content-complete — nothing left to fill in. What’s actually left is the mechanical submission itself: