Desktop focus feedback built on MediaPipe Face Landmarker landmarks in the browser (src/tracker.js), an internal focus model surfaced as the fried-flow scale (Fried, Steady, Locked In) in src/insights.js, and tunable constants in src/neurogaze-config.js (single source of truth for thresholds and weights).
This document describes exactly what is computed in code and why those quantities are used.
- Video → Face Landmarker runs about every 67 ms (~15 Hz) on a 640×480 stream.
- Per frame, raw geometric signals are derived from normalized landmark coordinates ((x,y)\in[0,1]^2) (image space; (y) increases downward).
- Rolling means (length
FEATURE_MA_WINDOW) smooth pose and auxiliary scalars unless a motion gate says the head/eyes are moving too fast (then smoothing buffers are frozen except on the first sample after a reset). InsightEngine.updateconsumes the tracker output, maintains internal timers and a PERCLOS-like buffer, then applies the focus model atSCORE_TICK_HZ(asynchronous from raw frame rate).
All symbol names below match neurogaze-config.js unless noted.
- Detector: Google MediaPipe Tasks Vision Face Landmarker (float16 model),
VIDEOmode, one face, GPU delegate when available. - Why: Stable, real-time 2D/3D-ish facial landmarks without shipping a custom vision model; suitable for continuous monitoring.
Before any score logic trusts a frame, faceGeometryReliable requires:
- Inter-ocular width (d_x = |x_{263}-x_{33}|) in ([
FACE_MIN_INTER_EYE,FACE_MAX_INTER_EYE]). - Face height (d_y = |y_{152}-y_{10}|) ≥
FACE_MIN_HEIGHT.
Why: Rejects tiny faces, partial crops, and extreme perspective where later ratios (pitch, roll, EAR) are unstable.
We use the six-point EAR layout from the blink / drowsiness literature: P1–P6 label the 2D eye landmarks, with P1–P4 spanning eye width and P2↔P6, P3↔P5 forming two vertical pairs for eye height (open vs closed), as in Dewi et al., Figure 2 — open and closed eyes with facial landmarks (P1…P6) (Electronics 2022, 11(19), 3183).
Per eye (Euclidean distances in normalized image coordinates):
[ \mathrm{EAR}_{\mathrm{eye}} = \frac{|P_2-P_6| + |P_3-P_5|}{2,|P_1-P_4|} ]
In code (earFromSixLandmarks), the landmark array is ordered ([P_1, P_2, P_3, P_4, P_5, P_6]) → MediaPipe indices:
| Point | Left eye (LEFT_EYE_6) |
Right eye (RIGHT_EYE_6) |
|---|---|---|
| (P_1) | 33 | 362 |
| (P_2) | 160 | 385 |
| (P_3) | 158 | 387 |
| (P_4) | 133 | 263 |
| (P_5) | 153 | 373 |
| (P_6) | 144 | 380 |
Tracker uses bilateral mean (\mathrm{EAR} = (\mathrm{EAR}_L + \mathrm{EAR}_R)/2).
- Blink / closed: (\mathrm{EAR} < \texttt{EAR_THRESHOLD}) with
EAR_THRESHOLD= 0.2 only (one global cutoff inneurogaze-config.js). - Note: The value passed into insights for PERCLOS is the per-frame EAR (not the moving-average buffer); the MA buffers in the tracker are used for smoothed pose/aux outputs, not for re-exporting EAR.
Why: EAR is a standard, lightweight proxy for eye openness from landmarks, used for blinks and drowsiness-style signals without iris segmentation.
Let face height (f_h = |y_{152}-y_{10}|) (chin–forehead), inter-eye width (f_w = |x_{263}-x_{33}|), eye midpoints in (x) and (y), nose tip index 4.
[ \text{pitch} = \frac{y_{\mathrm{nose}} - \frac{y_{10}+y_{152}}{2}}{f_h} ]
Larger positive pitch ⇒ nose shifted down relative to face box ⇒ looking down (keyboard/phone-like) in this coordinate convention.
[ \text{yaw} = \frac{x_{\mathrm{nose}} - x_{\mathrm{eyeMid}}}{f_w} ]
Used for pose grace (suppresses pitch/roll penalties on extreme turns) and T_yaw distraction (single-monitor only — see below).
Roll is the angle of the outer-eye segment vs horizontal:
[ \text{roll} = \mathrm{atan2}(y_{263}-y_{33},, x_{263}-x_{33}) ]
Why: Roll captures "head on desk / sideways phone" style tilt; pitch captures "looking down"; yaw helps suppress false positives when the user is still engaged but turned toward another monitor.
Smoothed values pitchSmoothed, yawSmoothed, rollRadSmoothed are arithmetic means over the last FEATURE_MA_WINDOW accepted samples (see motion gate).
Hysteresis on smoothed pitch:
- Latch on if
pitchSmoothed>HEAD_PITCH_CHIN_DOWN_ONSET(0.185). - Latch off if
pitchSmoothed<HEAD_PITCH_CHIN_DOWN_RELEASE(0.125).
chinDown is true only if latched and |yawSmoothed| < HEAD_YAW_MAX_FOR_CHIN_DOWN (0.44).
Why: Hysteresis avoids flicker from landmark noise. Yaw cap avoids treating a side glance at another screen as "phone down" when pitch is projection-skewed.
Let gap (= (y_{\mathrm{nose}} - y_{\mathrm{eyeMid}}) / f_h) (nose below eyes in normal forward pose makes this positive).
[ \text{fromGap} = \max(0,; \texttt{LOOK_UP_GAP_BASELINE} - \text{gap}) ] [ \text{fromPitch} = \max(0,; \texttt{LOOK_UP_PITCH_DEADBAND} - \text{pitch}) \times \texttt{LOOK_UP_PITCH_GAIN} ] [ \text{lookUpNorm} = \min(0.4,; \text{fromGap} + \text{fromPitch}) ]
Why: When the user tilts back (ceiling / "away" gaze), the nose–eye vertical gap shrinks and pitch goes more negative in this formulation; the metric flags that distinct from chin-down.
[ \text{lipNorm} = \frac{|y_{14}-y_{13}|}{f_h} ]
Why: Large sustained mouth opening is used as a yawn / jaw fatigue cue.
When eyes are open, the tracker stores the midpoint of both irises (indices 468/473) if available, else eye-region fallback, in a 1.5 s buffer. Over the last ANALYSIS_WINDOW_MS (500 ms) it computes mean normalized speed between consecutive samples (skips (\Delta t > 200) ms).
If there are at least 4 samples and mean speed > MOTION_GATE_MEAN_SPEED (0.28), the frame is motion-heavy: feature MA buffers do not update, and concentrationFrameTrusted becomes false when combined with open eyes.
Why: Rapid eye/head motion makes single-frame landmarks a poor proxy for "steady work"; gating avoids punishing or smoothing across meaningless jitter.
- Transition closed (
EAR< threshold) recordsblinkCloseStart. - On reopening, if duration ∈ (50 ms, 800 ms],
blinkJustCompletedis set andlastCompletedBlinkDurationMsstored.
Blink events feed the Eye Comfort subscore (see below).
Not full PERCLOS (percentage of eyelid closure); a binary window:
- Each frame with a face: append 1 if (\mathrm{EAR} < \texttt{EAR_THRESHOLD}), else 0, to a FIFO of length
PERCLOS_WINDOW(45 samples ≈ ~3 s at ~15 Hz). - (\texttt{perclos} = \frac{\sum \text{samples}}{N}), the fraction of "closed" frames in the window.
Why: Mirrors the spirit of PERCLOS—proportion of time eyes appear closed—as a fatigue/droopiness signal without specialized eye cameras.
All four timers share the same accumulate/decay pattern: they tick upward on trusted frames with eyes open and the relevant signal active, and decay back to zero when the signal clears.
Accumulates while chinDown, concentrationFrameTrusted, and eyes openish. Decays at T_OFF_DECAY_PER_SEC when not chin-down.
Three-tier ramp gPhone(T, perclos):
- [0, 8 s): soft tier —
G_PHONE_ALPHA · T · eyeBlend(perclos)whereeyeBlend = EYE_BLEND_MIN + (1−EYE_BLEND_MIN)·perclos; awake users register at 55% strength (raised from 28% to make short phone checks register sooner). - [8, 18 s): confirmed tier — adds
G_PHONE_BETA · (T−8). - 18 s+: exponential tail —
G_PHONE_GAMMA · (exp((T−18)/τ) − 1).
Why: Tightened from 15 s / 30 s windows so brief sustained looks-down register meaningfully rather than requiring 15–30 s of continuous distraction.
Accumulates while rollSeverity(roll) × poseGrace(|yaw|) > 0, trusted, eyes openish. Decays at T_ROLL_DECAY_PER_SEC.
Three-tier ramp gRoll(T) with G_ROLL_* and T_ROLL_* constants. Currently tracked but not used in scoring (available for future reintroduction).
Accumulates while lookUpSeverity(lookUp) ≥ LOOK_UP_SEVERITY_ONSET, trusted, eyes openish. Decays at T_LOOK_DECAY_PER_SEC.
Three-tier ramp gLook(T). Currently tracked but not used in scoring (available for future reintroduction).
Accumulates while |yaw| ≥ T_YAW_ONSET_NORM (0.36, roughly 20°+), trusted, eyes openish, and hasMultipleMonitors is false. When multiple monitors are detected via the Electron screen API, T_yaw decays immediately and contributes nothing to scoring.
Three-tier ramp gYaw(T) with G_YAW_* and T_YAW_* constants, 2 s grace window before accumulation starts.
Why: Repeated sideways glances on a single monitor signal distraction (phone to the side, looking around the room). Multi-monitor users — including laptop + external display — are explicitly excluded because a sustained high yaw angle simply means they're working on their other screen.
On startup and whenever a monitor is plugged or unplugged, the Electron main process calls screen.getAllDisplays() and pushes the count to the renderer via IPC. The dashboard stores this as displayCount and passes hasMultipleMonitors: displayCount > 1 into every InsightEngine.update() call.
A laptop with one external display counts as 2 displays, correctly suppressing yaw penalties.
The score is a three-component average, each subscore 0–100, evaluated at SCORE_TICK_HZ (8 Hz) when calibrated and concentrationFrameTrusted.
Tracks how often the user blinks against a 15 BPM healthy baseline (stored as EYE_COMFORT_BASELINE_BPM). Screen workers typically blink 3–8× per minute, well below this target.
- Blinks are recorded in a 45 s rolling window (
EYE_COMFORT_WINDOW_MS). - Current BPM is EMA-smoothed toward the raw window rate (α = 0.18 per frame).
- A 7 s grace (
EYE_COMFORT_GRACE_MS) after calibration completes holds the score at 100 while the window fills. - Deficit = max(0, baseline − smoothedBpm). If deficit > 0, score decays at
min(EYE_COMFORT_MAX_DECAY_PER_SEC, EYE_COMFORT_DECAY_K · ratio²)per second (quadratic — small deficits decay slowly, large deficits faster). If deficit ≤ 0, score recovers atEYE_COMFORT_RECOVER_PER_SECper second.
[ \text{engagementScore} = 100 \times (1 - \mathrm{clamp01}(\text{phoneBad} + \text{yawBad})) ]
Where:
[ \text{phoneBad} = \mathrm{clamp01}!\big(f_{\text{pitch}}(\text{pitch}) \times \text{poseGrace}(|yaw|) \times g_{\text{phone}}(T_{\mathrm{off}}, \text{perclos}) \times \text{gate}\big) ]
[ \text{yawBad} = \begin{cases} 0 & \text{if hasMultipleMonitors} \ \mathrm{clamp01}(g_{\text{yaw}}(T_{\mathrm{yaw}}) \times \text{gate}) & \text{otherwise} \end{cases} ]
Eye gate: (\text{gate} = \texttt{EYE_GATE_MIN} + (1-\texttt{EYE_GATE_MIN}) \times \texttt{perclos}) — drowsiness amplifies all penalties.
[ \text{perclosEnergy} = \mathrm{clamp01}!\left(\frac{\text{perclos} - \texttt{ENERGY_PERCLOS_GRACE}}{1 - \texttt{ENERGY_PERCLOS_GRACE}}\right) ]
[ \text{energyScore} = 100 \times (1 - \text{perclosEnergy}) ]
ENERGY_PERCLOS_GRACE is 0.03 (lowered from 0.08) so alert users register some energy cost rather than always sitting at 100. No artificial cap — full PERCLOS range can move the score to 0.
[ \text{combinedScore} = \frac{\text{eyeComfortScore} + \text{engagementScore} + \text{energyScore}}{3} ]
After calibration completes, active session seconds (#activeSessionSec) accumulate only while the face is present and geometry is reliable — stepping away or poor lighting acts as a natural pause.
At each score tick, the combined score is multiplied by a Gaussian decay factor:
[ \text{decay} = \exp!\left(-\left(\frac{t_{\mathrm{active,min}}}{\texttt{SESSION_DECAY_TAU_MIN}}\right)^{!\texttt{SESSION_DECAY_BETA}}\right) ]
With SESSION_DECAY_TAU_MIN = 200 and SESSION_DECAY_BETA = 2 (Gaussian shape — starts nearly flat, then steepens):
| Active session time | Decay factor | Score penalty |
|---|---|---|
| 45 min | 0.961 | −4% |
| 90 min (end of ultradian cycle 1) | 0.845 | −16% |
| 120 min | 0.726 | −27% |
| 180 min | 0.444 | −56% |
Why: Ultradian rhythms run in ~90-minute cycles. Cognitive performance degrades across cycles without rest. TAU = 200 places the steepest part of the curve right at the 90-minute cycle boundary — scoring stays nearly flat for the first 45 minutes, then drops meaningfully as the first cycle closes. Taking a break resets active session time (see Break Boost below). The decay resets fully when a new session starts.
When the face is absent (no detection), a break timer starts immediately. Three thresholds govern what happens:
| Duration | Behaviour |
|---|---|
< 1 min (BREAK_MIN_DURATION_MS) |
Ignored — stood up briefly, sneezed, etc. No boost on return. |
| 1–60 min | Break mode. Active session timer pauses. On return, active time is partially reduced (boost applied). |
≥ 60 min (BREAK_SESSION_END_MS) |
Session auto-ends — user has walked away for the day. |
When the user returns from a qualifying break (≥ 1 min, < 60 min), #activeSessionSec is reduced proportionally to how much of the ultradian cycle they stepped away from:
- Short break (1–10 min): linear partial reset — 1 min away reduces active time by 1/10th of the full-reset amount, 9 min by 9/10ths.
- Full break (≥ 10 min,
BREAK_FULL_RESET_MS): active session time resets to zero — the ultradian decay clock restarts from scratch.
A "Focus boost +X%" insight fires immediately on return, quantifying how much the decay factor improved. The boost is proportional: a longer break earns a larger score recovery.
While away, the tray icon switches from the live fried-flow state to a gray –. After 1 minute, it switches to a blue BRK indicator. A break banner appears in the dashboard showing elapsed away time. On return, the banner disappears and the tray icon resumes showing the live fried-flow state.
Internal rawScore remains continuous in [0, 100] for smoothing, insights, and session math. The user-facing UI displays only the fried-flow state derived from the rounded EMA:
[ \text{display} \leftarrow \texttt{DISPLAY_SCORE_SMOOTH} \cdot \text{display} + (1-\texttt{DISPLAY_SCORE_SMOOTH}) \cdot \text{raw} ]
Calibration: Until CALIBRATION_FRAMES consecutive face frames with reliable geometry, the engine stays calibrating (rawScore/displayScore held at 100, all timers cleared).
Status labels (based on displayed internal value):
| Internal value | Label |
|---|---|
| ≥ 80 | Locked In |
| ≥ 50 | Steady |
| < 50 | Fried |
| Constant | Role |
|---|---|
EAR_THRESHOLD |
Open vs closed eye for EAR and PERCLOS bits |
FEATURE_MA_WINDOW |
Samples in rolling mean for pose / lip / look-up |
SCORE_TICK_HZ |
Internal score integration rate |
DISPLAY_SCORE_SMOOTH |
EMA smoothing for displayed score |
CALIBRATION_FRAMES |
Face frames required before scoring begins |
T_OFF_SOFT_CAP_SEC / T_OFF_MED_CAP_SEC |
Phone-check tier boundaries (8 s / 18 s) |
G_PHONE_* |
Phone penalty ramp coefficients |
EYE_BLEND_MIN |
Soft-zone floor for phone penalty when eyes are open (0.55) |
T_YAW_ONSET_NORM |
Yaw threshold to start accumulating T_yaw (~20°) |
G_YAW_*, T_YAW_* |
Lateral gaze penalty ramp (single-monitor only) |
PERCLOS_WINDOW |
Length of binary closed-eye history |
ENERGY_PERCLOS_GRACE |
PERCLOS fraction below which energy score is unaffected (0.03) |
EYE_COMFORT_BASELINE_BPM |
Target blink rate (15 BPM); deficit drives eye comfort decay |
SESSION_DECAY_TAU_MIN |
Ultradian decay time constant (200 min — steepest at 90-min cycle boundary) |
SESSION_DECAY_BETA |
Decay shape exponent (2 = Gaussian) |
BREAK_MIN_DURATION_MS |
Minimum absence to count as a break and trigger boost (1 min) |
BREAK_FULL_RESET_MS |
Absence length that fully resets active session decay (10 min) |
BREAK_SESSION_END_MS |
Absence length that auto-ends the session (60 min) |
Notifications are driven by a stateful chain in InsightEngine rather than a random picker, so each message is aware of what fired before it.
| Score range | Tier |
|---|---|
| < 35 | break |
| 35–54 | slipping |
| 55–79 | ok |
| ≥ 80 | good |
| Field | Purpose |
|---|---|
#insightChain |
{ tier, depth, scoreAtFire } — tracks the last fired tier and how many times that tier has repeated |
#highScoreSince |
Timestamp when score first crossed ≥ 80, used for flow milestones |
#flowMilestone |
Which flow milestone has already fired (20min / 60min) so they only fire once per streak |
#bodyDecks |
Per-bucket shuffled decks for no-repeat body rotation |
- Same tier repeating →
depth++→ message references the prior check-in ("still" / "again" language). - Slipping → break escalation → dedicated "things slipped further" message instead of a generic break notification.
- Recovery (previous tier was bad, current score ≥ 70) → one-time comeback message.
- Flow milestones: 20 min sustained ≥ 80 → "You're in flow"; 60 min sustained → "An hour in the zone". Replaces the old
#goodFocusSincefield.
Standard cooldown is 5 min. Drops to ~3 min when the score has fallen 12+ points since the last insight and the current tier is still negative. Prevents long silences during a real focus crash.
Each bucket (e.g. 'slipping-1', 'break-2') maintains its own shuffled deck. All options in a bucket are cycled before any repeats, and the same body is never shown back to back. Replaced the old global pick() call.
All negative-tier bodies were rewritten with specific, science-backed, actionable tips in plain language: box breathing, 20-20-20 rule, hydration, vagus nerve exhale, movement breaks, cold water, and nap-vs-caffeine guidance.
The three subscore labels in the stats panel (Screen Strain, Engagement, Energy) each have a small ⓘ icon. Hovering reveals a styled bubble explaining the metric in plain English:
- Screen Strain — blink rate / eye strain proxy.
- Engagement — head position (chin-down / looking away).
- Energy — eye drooping / alertness (PERCLOS).
Implemented as CSS-only .info-tip / .info-tip-bubble classes. No JS changes required.
The macOS / Windows menu-bar icon is a 64×64 canvas drawn in the renderer and sent to the main process as a PNG data URL via the tray-icon IPC channel. Three visual states:
| State | Icon | Color |
|---|---|---|
| Active (score ≥ 80) | Score number | Green |
| Active (score 50–79) | Score number | Yellow |
| Active (score < 50) | Score number | Red |
| Away (< 1 min) | – |
Gray |
| Break (≥ 1 min) | BRK |
Blue |
Font size auto-shrinks so text always fits within the icon regardless of number of digits or label length.
The expanded stats view includes a T yaw row showing accumulated side-gaze seconds (suppressed and displayed as zero on multi-monitor setups), and a monitor count flag that lights up when multiple displays are detected.
A dismissable update banner appears above the footer whenever a new version has been downloaded and is ready to install. It shows the version number and a "Restart" button — no need to spot the small footer text.
The footer "Check for updates" button still works for manual checks; its label reflects the current download state (checking / downloading / ready).
| File | Used for |
|---|---|
assets/macOS/icon-light-dock.png |
macOS Dock icon (light) — source of truth |
assets/macOS/icon-dark-dock.png |
macOS Dock icon (dark) — source of truth |
assets/macOS/icon-light.icns |
macOS app icon (light), DMG window icon — generated |
assets/macOS/icon-dark.icns |
macOS app icon (dark) — generated |
assets/macOS/lobi.icon |
macOS 26+ adaptive icon (electron-builder) |
assets/windows/icon-dark.ico |
Windows taskbar / installer icon |
When you update the dock PNGs, regenerate the .icns files with:
./scripts/generate-icons.sh- Startup check —
autoUpdater.checkForUpdates()runs once when the app is ready (packaged builds only). - Periodic check — a
setIntervalre-runs the check every 6 hours so users with the app open all day still receive updates automatically. - Tray menu — the context menu item label reflects the current update state and changes dynamically:
| State | Tray label |
|---|---|
| Idle | Check for Updates |
| Checking | Checking for Updates… |
| Downloading | Downloading vX.Y.Z… |
| Ready | Restart to Install vX.Y.Z |
| Up to date | Up to Date ✓ |
| Error | Update Check Failed — Retry |
- State seeding on window open — a
get-update-stateIPC handler lets the renderer query the current state immediately on load, fixing a bug where the "Restart" button required multiple clicks if the update had downloaded before the dashboard window was opened.
The scoring engine has a Vitest test suite (src/insights.test.js) covering:
- Calibration gate (scoring holds until N stable frames)
- Face-absent / away detection
- Insight cooldown and escalating cooldown
- Score threshold and tier classification
- Insight escalation (slipping → break escalation path)
- Recovery insight (bad → good comeback)
- Flow milestones (20 min and 60 min sustained ≥ 80)
- No-repeat body rotation (deck exhausts before repeating)
- Insight payload shape (non-empty title + body)
- Ultradian decay (math verification + engine integration)
- Break boost (11 tests: boost magnitude, isOnBreak getter, partial vs full reset, session-end trigger)
- Multi-monitor T_yaw suppression (7 tests)
Run with:
npx vitest runRequires Node 22.x. Pinned to vitest@2 for Node 22.0.0 compatibility (v4 requires ≥22.12).
- Run the app, grant camera, open the dashboard.
- Calibration: Hold a normal working pose until calibration completes (~30 stable frames).
- Phone check: Look down for 5–8 s with eyes open —
T_offshould accumulate and engagement score drop. - Yaw distraction (single monitor only): Turn head sideways past ~20° and hold —
T_yawaccumulates and engagement drops. On a multi-monitor setup this should have no effect. - Energy: Partially close eyes —
perclosrises and energy score drops (now responsive from ~3% closure rather than 8%). - Eye comfort: Avoid blinking for 30–60 s — eye comfort score should visibly decay as smoothed BPM falls below 15.
- Session decay: Check
activeSessionMiningetLiveMetrics()— decay is ~4% at 45 min and ~16% at 90 min. - Break mode: Step away from the camera. After ~10 s, the tray should show
–. After 1 min, it should switch to blueBRKand the dashboard banner should appear. Return — a "Focus boost +X%" insight should fire and the banner should disappear. - Session auto-end: Stay out of frame for 60 min — the session should end automatically.
- Motion gate: Rapidly move head/eyes —
concentrationFrameTrustedfalse should pause score ticks. - Update banner: In a packaged build, trigger an update download and reopen the dashboard — the banner should appear immediately without needing to click anything.
- Tray update states: After triggering an update check, right-click the tray icon — the menu item label should reflect the current download state in real time.