A Chrome extension (MV3) that verifies you actually stayed focused during a work session — instead of just trusting you to. It watches your gaze locally with the camera, hard-blocks distracting sites, notices when you leave Chrome entirely, and turns the whole thing into a shareable, accumulating record of real focus time.
Everything runs locally. No camera frames, blendshapes, or session data ever leave the machine — there's no backend.
-
Trustworthy gaze detection. Calibrates a personal baseline before every session, then measures drift as deviation from your "looking at the screen," combines eye-blendshapes with head pose, applies hysteresis so it doesn't flicker, and skips frames it isn't sure about (no face / mid-blink) instead of falsely flagging them.
-
Site blocking. Redirects a user-chosen blocklist to an on-brand "nice try" page for the duration of the session, using
declarativeNetRequest. -
Leaving-Chrome detection. Uses
windows.onFocusChangedto catch when you alt-tab out of Chrome for more than 5 seconds — tracked separately from gaze drift. -
Friction to quit. Ending early takes a press-and-hold or a typed reason (on by default, configurable), so a moment of weakness isn't one click away.
-
Specific attribution. The end report says what actually happened — e.g. "2 gaze drifts, left Chrome once, tried instagram.com twice" — and feeds it into a share card.
-
Verified history & portfolio. Persists every session's full event log, accumulates verified hours / clean streaks / bests, surfaces one pattern insight ("you drift most in the first few minutes"), and exports a 30-day stats summary.
-
Runs in the background — no tab to keep open. You start and calibrate from the toolbar dropdown; the session itself runs in a hidden background document. Reopen the dropdown anytime for a live dashboard (timer, focus %, counters, and a preview of what the camera sees), or pop out a tiny floating window. Drifts reach you as a desktop notification + sound while you work.
Fixate can tell when you look away from the screen or leave Chrome. It cannot see what you switched to, and it cannot stop you closing the lid or quitting Chrome. And while a session is running the webcam stays on in the background (that's the cost of verifying focus without a visible window). The UI says all of this plainly — that honesty is the point.
(or browse the Releases page — ~10 MB, most of it the bundled face model)
Fixate isn't on the Chrome Web Store yet, so you install it from the zip. No build step, no terminal, no account — about a minute, and it's a normal supported Chrome flow.
1. Unzip it, and put the folder somewhere permanent.
Your Documents or home folder is fine. Not Downloads — Chrome runs the extension from
this folder, so if you later clear Downloads or move the folder, Fixate breaks. You should end
up with a folder called fixate containing manifest.json.
2. Open chrome://extensions.
Paste that into the address bar and hit enter (the Extensions menu → Manage extensions gets you there too).
3. Turn on Developer mode — the toggle in the top-right corner.
This is what lets Chrome load an extension that didn't come from the Web Store. Chrome will show a "Developer mode extensions" warning bubble on startup; that's expected and harmless.
4. Click "Load unpacked", and select your fixate folder.
Select the folder itself — the one with manifest.json directly inside it. Don't open the
folder and select a file.
5. Pin it. Click the puzzle-piece icon in the toolbar, then the pin next to Fixate, so the target icon stays visible. Click it to start a session.
First run: Chrome asks for camera permission (macOS may also ask you to allow Chrome the camera in System Settings → Privacy & Security → Camera). Then the dropdown runs a ~5-second calibration — look at the dot. After that the session runs in the background, so you can close the dropdown and keep working. Frames never leave your machine.
Download the zip again, and replace the contents of your existing fixate folder with the new
ones (keeping the same path). Then open chrome://extensions and click the ↻ reload icon on the
Fixate card. Your history and portfolio are stored by Chrome, not in the folder, so they survive
the update.
chrome://extensions → Remove on the Fixate card. That deletes its stored sessions too.
Delete the folder afterwards.
| What you see | What it means |
|---|---|
| "Manifest file is missing or unreadable" | You selected the wrong folder. Pick the one with manifest.json directly inside — if unzipping gave you a fixate folder containing another fixate folder, choose the inner one. |
| "Load unpacked" button isn't there | Developer mode is still off (step 3). |
| Camera light never comes on | Another app (Zoom, Meet, Photo Booth) is holding the camera. Quit it and reload the extension. |
| Extension card shows "error" after a Chrome restart | The folder moved or was deleted. Re-unzip to a permanent path and Load unpacked again. |
Requirements: Chrome 116+ (the background-camera API landed there) on macOS, Windows, or Linux, plus a webcam. Chromium-based browsers with the same version base — Edge, Brave, Arc — work through the same Load unpacked flow.
Clone the repo and Load unpacked the checkout directly — same steps 2–5 above, no zip
involved. ./scripts/build-zip.sh produces the release artifact at dist/fixate.zip.
manifest.json MV3 manifest
background/
service-worker.js session coordinator: offscreen lifecycle, DNR blocking,
windows.onFocusChanged, timer (alarms), notifications, saved record
offscreen/ invisible background document — owns the camera + gaze detection
popup/ the dropdown: configure + calibrate + start (idle), live dashboard
(active), report (end)
float/ optional tiny floating window mirroring the live dashboard
blocked/ the redirect landing page for blocked sites
history/ verified history, portfolio, insight, export
lib/
gaze.js GazeTracker: calibration + baseline drift + head pose + hysteresis
storage.js sessions, portfolio, settings, insight, export (chrome.storage.local)
copy.js all personality/roast copy + attribution formatting
sound.js synthesized Web Audio cues (rotating, playful; no audio files)
sharecard.js canvas share card
boot-guard.js classic script: surfaces any page's startup error instead of freezing
vendor/mediapipe/ bundled MediaPipe Tasks Vision (wasm + FaceLandmarker model)
icons/ generated target-mark icons
scripts/build-zip.sh packages dist/fixate.zip for a release
- Why a background document. A popup is destroyed the instant you click away, and a service
worker has no DOM/camera — so neither can watch your gaze while you work. The detection lives in
a Chrome offscreen document (reasons
USER_MEDIA+AUDIO_PLAYBACK): a hidden page the extension keeps alive for the session. It owns the camera, runs the gaze loop, plays the catch sound, and pushes a small preview frame + live stats out for the dashboards. - Who owns what. The offscreen doc does detection. The service worker coordinates
everything durable: it starts/stops the offscreen doc, runs the timer (
chrome.alarms), blocks sites (declarativeNetRequest), watches OS focus (windows.onFocusChanged), fires notifications, and assembles + saves the final record. The popup/float are thin views that read a liveviewfrom the worker and send commands; they can be closed without stopping anything. The popup also owns the "look at the dot" baseline capture, in the dropdown itself, and hands the baseline to the worker before closing its own camera stream. - Ephemeral service worker. No durable state lives in worker module variables (except the churny
preview frame, which the offscreen doc re-pushes ~6×/s). Session state is in
chrome.storage.session, andwindows.onFocusChanged/alarms.onAlarmare registered at top level so they survive respawns. - Messaging.
BEGIN_SESSION {config, baseline, weak}→ popup finished in-dropdown calibration, so the worker spins up the offscreen doc and arms blocking + timer;OFX_START/OFX_STOPdrive the detector (stop returns the final tally);OFX_FRAME/OFX_LIVE/OFX_CATCHflow from detector → worker;GET_VIEWgives popup/float/blocked the live snapshot;END_SESSIONtears down and returns the saved report;BLOCKED_HITcounts a blocked-site attempt.
All thresholds live in TUNING at the top of lib/gaze.js: EYE_DRIFT_DELTA,
HEAD_YAW_DEG, HEAD_PITCH_DEG, the ENTER_MS / EXIT_MS hysteresis windows, and the
BLINK_SKIP confidence gate. Drift is always measured relative to the personal baseline captured
during calibration, so these are deltas, not absolutes.
Note: the old live debug overlay lived in the (now removed) full-screen session tab. With detection moved to the invisible offscreen document there's no on-screen surface for it yet; re-adding a live tuning panel to the floating window (fed by extra per-frame numbers from the offscreen doc) is the natural next step.
-
Bump
"version"inmanifest.json(Chrome requires it to increase for the reload to count as an update). -
./scripts/build-zip.sh→dist/fixate.zip. The script prints the version and confirmsmanifest.jsonlanded at the zip root, which is whatLoad unpackedneeds. -
Tag and publish:
git tag -a v0.3.0 -m "Fixate v0.3.0" git push origin main --tags gh release create v0.3.0 dist/fixate.zip --title "Fixate v0.3.0" --notes "…"
Keep the asset named fixate.zip — the README's download button points at
releases/latest/download/fixate.zip, which only resolves if every release uses that same name.
dist/ is gitignored, so the zip only ever lives on the release.
host_permissionsis currently broad (*://*/*) for a simple MVP blocklist. To publish, narrow it to the configured sites (or move blocking to a declarative ruleset scoped to them).declarativeNetRequestFeedbackis only needed for match-debugging on unpacked builds; it can be dropped for release.
MediaPipe Tasks Vision and the FaceLandmarker model are bundled under vendor/mediapipe/
(Apache-2.0, © Google). They're vendored locally because MV3's CSP forbids loading remote code.