A dwell timer, a local classifier, and nowhere for the data to go.

This page is the honest version. Not a features list, the actual mechanics: what triggers a record, how classification happens without a server, and the two real detection bugs that got found and fixed along the way.

2.5 seconds, continuous, per post.

An IntersectionObserver watches every post as it crosses 50% visibility. The moment a post is genuinely on screen, a per-post timer starts. Scroll away before 2.5 seconds and the timer resets to zero, nothing is recorded. Hold at 50%+ visibility for the full 2.5 seconds and the post qualifies for classification.

The same visibility signal also drives the scan count, so "entered your feed" and "started the dwell clock" are anchored to one observer instead of two separate systems that could quietly disagree.

TriggerIntersectionObserver, 50% visibility threshold
Dwell threshold2.5 continuous seconds
Interruption behaviorAny scroll away resets the timer to 0
PlatformsTwitter/X, Reddit, LinkedIn, YouTube

Feeds don't sit still under a tracker.

Twitter/X and new Reddit both virtualize their DOM. Scroll a post far enough out of view and the framework can unmount that node completely, then create a brand-new node for the same logical post once you scroll back, even by a small amount.

A scan counter that only dedupes by DOM element identity reads that remounted node as a new post and counts it again. In testing, that produced scan counts sitting roughly 30% above what was actually scrolled past, a real overcount, not a display bug.

The fix layers on a second, cheap check: a short hash of each post's opening text, held in a bounded in-memory set that's never written to disk and never leaves the page. A remounted node now hashes to the same value as the original and gets skipped. The trade-off is honest: two different posts that happen to share their first ~120 characters, an exact repost, say, would now read as one. That's a better failure mode than triple-counting a post because a virtualized feed recycled its node.

What happens in the ~200ms after a post qualifies.

No network request, no API call. Post text is tokenized, lemmatized, and matched against a keyword and phrase map covering 27 topics, then scored for sentiment and emotion using local lexicons bundled with the extension.

Lemmatization

"Investing", "invested", and "investments" all reduce to one base form, so a topic's keyword list doesn't need every inflection spelled out by hand.

Negation-aware

"Not interested in politics" no longer scores as politics, and "not scared" doesn't register as fear. Applied to both the topic classifier and the emotion scorer.

Phrase matching, not just words

Roughly a fifth of the keyword vocabulary is multi-word phrases like "interest rate" or "climate change". Early versions tokenized to single words only, so that entire slice of the vocabulary silently never matched anything. Fixed by splitting each topic into a word set and a phrase list at load time.

Emoji-aware sentiment

The base sentiment lexicon scores an emoji-only post as perfectly neutral by default. An originally curated emoji-to-sentiment map closes that gap for emoji-dense, short-form text.

A stored record, exactly.

field example value what it means ──────────────────────────────────────────────────────────── topics ["politics"] keyword and phrase matched topic sentiment "negative" sentiment bucket compound -0.62 raw sentiment score, -1.0 to +1.0 emotions {fear:2, anger:1} local emotion lexicon counts dwell_ms 4200 milliseconds spent in viewport platform "twitter" which site hour 22 hour of day, 0–23 day_of_week 2 0 = Sunday, 6 = Saturday has_media false whether the post had image or video ──────────────────────────────────────────────────────────── never stored: post text, author handle, post URL

The emotion lexicon hard-codes ordinary news vocabulary as automatic emotion hits. Words like "war", "crisis", and "protest" trigger fear or anger regardless of how the sentence actually reads, so a neutral headline like "war ends, ceasefire holds" scored identically to a genuinely alarming one. Any week with more hard news than usual would show fear and anger spiking, independent of your actual reaction to it.

The fix gates emotion hits by the post's own sentiment direction: on a neutral-to-positive post, negative-emotion hits are damped to a quarter weight instead of counted at face value, and the same applies in reverse. A per-post cap also stops one link-dense or hashtag-heavy post from dominating a week's emotion totals on its own. It's an honest partial fix, a genuinely neutral crime story can still tick fear slightly, but it removes the structural bias toward fear and anger on any heavy news week.


News vocabulary isn't the same as felt emotion.

Why a "post count" alone is misleading.

Early digests only ever showed one number: posts recorded in detail. That left people comparing it against a much larger "posts scrolled past" figure they'd noticed elsewhere, two genuinely different denominators with no labels attached to explain the gap.

Scrolled pastEvery post that entered your viewport at all
DwelledStayed ≥2.5s, the genuine pause
RecordedDwelled, with enough signal to classify

The digest's "Where your attention went" section shows all three, plus the resulting attention rate, e.g. paused on 5.3% of what scrolled past. Two lightweight local counters make this possible: neither stores anything beyond a daily integer.

What's still imperfect.

The base sentiment score, not the emotion layer above, still misreads some hard-news phrasing. "The war has ended and a ceasefire now holds" can still score net-negative overall, because the underlying lexicon rates words like "war" negatively no matter the surrounding resolution. Correcting that properly would mean training a domain-shifted valence lexicon for news-style text specifically, a bigger project than a scoring adjustment. It's flagged here rather than quietly left implied as solved.