Skip to content

feat(ext): Nudge for Chrome, MV3 extension MVP - #9

Merged
astraedus merged 6 commits into
mainfrom
extension-mvp
Jul 26, 2026
Merged

feat(ext): Nudge for Chrome, MV3 extension MVP#9
astraedus merged 6 commits into
mainfrom
extension-mvp

Conversation

@astraedus

Copy link
Copy Markdown
Owner

MVP of Nudge for Chrome, the MV3 sibling of the Android app. Free, GPL-3.0, no account, zero telemetry, zero network requests. Lives in a new extension/ directory; the Android app is untouched.

Built to ext-07 (PRD) and ext-08 (architecture); MV3 recipes from ext-01, YouTube from ext-03, Android semantics from ext-05.

What's in it

All 12 MVP items: three block modes (Hard Block / Delay / Breathing), temporary access after a completed pause, Daily Time Limit with midnight reset and a mid-browsing flip, Scheduled Override (incl. overnight spans), local-only usage stats, YouTube Shorts rule, Commitment Lock (Strict Mode), Escape Hatch (daily 2-minute pass), custom block messages, popup + dashboard + block page, onboarding, and JSON export/import.

src/core/ is pure TypeScript with zero chrome.* imports, a direct port of the Android pure-Kotlin domain layer and its test intent (BlockEngine, ScheduleEvaluator, WebDomainMatcher, StrictModeChallenge, RuleWeakening, EmergencyPass, StatsCalculator). That's what keeps the engine exhaustively unit-testable, exactly as on Android.

Verification

Gate Result
npm run lint clean
npm run typecheck clean
npm test 400 passing, 15 files
npm run build loadable .output/chrome-mv3, manifest + SW verified
npm run e2e 19/19 passing

E2E drives a real Chrome with the extension loaded. To give it real hostnames with no network, it maps *.test onto a local server via --host-resolver-rules, so DNR sees ordinary navigations. It covers the block redirect, subdomain matching, master toggle, delay → temp allow → real alarm expiry → re-block, breathing, walked-away, budget escalation, the mid-browsing budget flip of an already-open tab, scheduled override (in / out / disabled), Strict Mode gating a weakening change while never gating a strengthening one, and the single-use daily pass.

Three real bugs caught and fixed

  1. URLSearchParams truncated the blocked URL. regexSubstitution cannot percent-encode, so the target arrives verbatim and routinely contains its own ?/&. watch?v=abc&t=30 was being read as watch?v=abc, users would land on the wrong page after completing a pause. Now slices after the marker; regression tests cover the whole class (own query, #fragment, literal %XX).
  2. React hooks after an early return in DelayView/BreathingView (found by rules-of-hooks). The fix needed an enabled flag on useCompleteOnZero, because a disarmed view has a zero-length countdown and remaining === 0 would otherwise complete the pause instantly and grant real access.
  3. 'INHERIT' isn't a BlockMode, so it couldn't be a coerceMode fallback, flagged independently by two test lanes.

CI

New extension-ci.yml (lint → typecheck → unit → build → Playwright under xvfb), scoped paths: ['extension/**'].

release.yml gains the mirror-image paths-ignore: ['extension/**'] so extension commits stop rebuilding the Android app. Verified before touching it: GitHub does not evaluate path filters for tag pushes, so the v* Android release flow is unaffected. No ext-v* tag has been created.

Reviewer notes

  • Known gap: a rule always blocks in some mode, there's no "allow by default, block only during the window" schedule. Outside a Scheduled Override the rule's own Default Behavior applies. This is faithful to Android, but "block only during work hours" isn't expressible yet; likely the first follow-up.
  • Strict Mode can't stop removal from chrome://extensions, and the dashboard says so plainly rather than pretending, honesty is the differentiator.
  • YouTube fixtures are hand-authored from the ext-03 taxonomy, not live DOM captures; refresh from real DOM when possible.
  • extension/CLAUDE.md carries dev/build/test commands, load-unpacked steps, the architecture map, release process, and a lessons section.

Draft on purpose, the orchestrator QA-gates the merge.

WXT 0.20.27 + TypeScript strict + React 19 + vitest 4 + Playwright.
MV3 manifest carries the ext-01 permission set and, critically, lists
blocked.html in web_accessible_resources, a DNR redirect to a page that
isn't web-accessible fails silently even though we own it.

Core (pure TS, zero chrome.* imports) ported from the Android domain layer:
- types.ts / settingsSchema.ts (schemaVersion 1, total+lenient migration)
- domainMatcher.ts   <- domain/WebDomainMatcher.kt
- scheduleEvaluator.ts <- domain/engine/ScheduleEvaluator.kt (overnight spans)
- blockEngine.ts     <- domain/engine/BlockEngine.kt (priority order preserved)
- ruleResolver.ts    (Scheduled Override replaces default behavior in-window)

Design tokens are the Android teal in-app palette, light + dark.
protocol.ts fixes the SW<->UI contract (discriminated union + ResponseFor
map) so every surface can be built against it independently.
ui/: teal-token primitives, formatting helpers (4-char badge budget),
typed rpc wrapper. No emoji-as-icon: the mark is the Android pause glyph.
… strict-mode gate

background/dnr.ts: every enabled rule compiles to ONE main_frame redirect to
blocked.html; the original url rides along via regexSubstitution \0 (verified
against the DNR reference: \0 = entire match, requires regexFilter, and the
pattern is anchored ^...$ so \0 is the whole url, not just its prefix).
Temp-allow grants are SESSION allow-rules at a higher priority, so they die with
the browser and a crash can never leave a site permanently unlocked.

background/tracker.ts: event-driven timestamp accounting. Nothing accumulates in
a worker global (the worker dies after ~30s idle); every event is an atomic
read-modify-write. Counts only while Chrome has OS focus and the machine is not
idle. Attribution is capped at 3 heartbeats so a laptop sleep or clock jump
cannot inflate screen time into a daily limit the user never spent.

background/alarmsHub.ts: midnight self-reschedules on an absolute "when" time,
because periodInMinutes:1440 drifts off local midnight across a DST change;
alarms are re-armed on every worker wake, per Chrome's own guidance.

background/messagesRouter.ts: the Commitment Lock gate lives in the WORKER, not
the UI, because a gate a page can skip is not a gate. COMPLETE_PAUSE is guarded
so a HARD_BLOCK (including a budget-exhausted one) can never be completed away.

Fixes two real bugs found during integration:
- settingsSchema: 'INHERIT' is not a BlockMode, so it could not be a coerceMode
  fallback (flagged independently by two test lanes). Now has its own coercion.
- BlockPage read the target with URLSearchParams, truncating any url at its own
  first '&': "watch?v=abc&t=30" became "watch?v=abc", sending the user to the
  wrong page after completing a pause. Now slices after the marker; regression
  tests cover the whole class (own query string, #fragment, literal %XX).
Completes the MVP: block page (Hard Block / Delay / Breathing / Escape Hatch),
popup, dashboard (stats + settings + Commitment Lock + export/import),
onboarding, and the YouTube Shorts content script with the ext-03 selector
taxonomy (multi-selector chains, loud console.warn when only the generic
[href^="/shorts/"] fallback matched, hand-authored DOM fixtures under test).

e2e (19 specs, real Chrome, extension loaded): the suite gets real hostnames
with no network via --host-resolver-rules=MAP *.test 127.0.0.1:<port>, so DNR
sees ordinary navigations. Covers block redirect, subdomain matching, master
toggle, delay -> temp allow -> REAL alarm expiry -> re-block, breathing,
walked-away, budget escalation, mid-browsing budget flip of an already-open tab,
scheduled override in/out/disabled, Strict Mode gating a weakening change while
never gating a strengthening one, and the single-use daily pass.

Lean Chrome flags in the e2e fixture: without them the suite's browsers get
OOM-killed on a developer machine (earlyoom is configured --prefer ^chrome$),
which surfaces confusingly as "browser has been closed" during fixture setup.

CI: extension-ci.yml (lint -> typecheck -> unit -> build -> Playwright under
xvfb), scoped paths: ['extension/**']. release.yml gains the mirror-image
paths-ignore so extension commits stop rebuilding the Android app. Verified
first: GitHub does NOT evaluate path filters for TAG pushes, so the v* Android
release flow is untouched.

eslint: rules-of-hooks caught a real latent bug -- DelayView/BreathingView
returned early BEFORE calling their hooks. Fixing it required an `enabled` flag
on useCompleteOnZero, because a disarmed view has a zero-length countdown and
"remaining === 0" would otherwise complete the pause instantly and grant real
access. The React Compiler advisory rules are deliberately off; rules-of-hooks
stays on.

Gate: eslint clean, tsc clean, 400 unit tests passing (15 files), build loads,
19/19 e2e passing.
… surfaces

PRD item 9 says the full-tab dashboard IS the options page, but the manifest
never declared it, so right-clicking the toolbar icon -> Options went nowhere.
One line in wxt.config.ts; verified in the BUILT manifest, not just the source.

Adds e2e/surfaces.spec.ts (5 specs) covering the extension's own pages, which
had no end-to-end coverage: options_page is registered, the popup renders
today's screen time and the dashboard link, the dashboard renders its stats and
the Commitment Lock honesty note, onboarding renders the tagline, and none of
the three pages logs a console error on load. These catch a class the unit tests
cannot, an entrypoint never wired into the manifest, or a page that throws on
mount against real chrome APIs rather than mocked ones.

Two findings while writing them, both now in extension/CLAUDE.md lessons:
- A <button> inside role="tablist" is exposed as role `tab`, NOT `button`, so
  the dashboard tabs are invisible to getByRole('button'). Correct behaviour,
  surprising in tests.
- The dashboard paints a loading state and fills in when GET_DASHBOARD_STATE
  resolves, so e2e must wait for loaded content rather than assume it is instant.

Gate: eslint clean, tsc clean, 400 unit tests (15 files), 24/24 e2e.
… infinite redirect loop

CRITICAL, found by live-Chrome QA on 3 domains. A Hard Block rule carrying a
daily limit, while still under that limit, matched NO branch in the engine's
chain: not "unconditional" (it has a limit), not over budget, not DELAY or
BREATHING. It fell through to ALLOW. DNR redirects the domain regardless of
mode, so the result was navigate -> redirect -> block page -> ALLOW -> back to
the site -> redirect, forever, hammering the service worker.

A daily limit is meaningless on a Hard Block: the site is barred outright, so
there is no browsing time to budget. The engine now blocks any applicable
HARD_BLOCK rule, and RuleEditor explains that instead of offering the limit
control, so the invalid combination cannot be authored.

WHY 400 UNIT TESTS AND 24 E2E SPECS MISSED IT: two unit tests had encoded the
buggy ALLOW as the EXPECTED result. Both were written from a spec sentence that
described the implementation ("a Hard Block rule with a limit is NOT
unconditional") rather than the user-visible outcome. A test that asserts the
bug is worse than no test. Both are rewritten to assert behaviour.

Covering the CLASS, not the instance: the engine's real invariant is that if any
rule applies, the verdict is a BLOCK, ALLOW means "no rule applies here" and
nothing else. tests/core/blockEngine.test.ts now drives the full
mode x limit x usage matrix (3 x 2 x 5) asserting no combination can fall
through, plus that ALLOW stays reachable ONLY for an empty/disabled/out-of-
schedule rule set. Enumerating it found no further fall-through combinations.

Regression proven, not assumed: with the fix reverted, 7 unit tests and the new
e2e spec fail; with it, all pass.

Also from the QA round:
- modulepreload hints disabled (build.modulePreload: false). They are useless for
  pages loaded off disk and Chrome logged ~6 warnings per page; a clean console
  is part of a zero-telemetry product's trust story. The surfaces e2e now asserts
  zero preload warnings so they cannot come back.
- dashboard re-fetches on visibilitychange/focus, so a tab left open reflects new
  activity instead of looking like broken stats.
- blocked.html declares an empty favicon, killing a stray net::ERR_FAILED.

Gate: eslint clean, tsc clean, 440 unit tests (15 files, +40), 27/27 e2e (+3).
@astraedus
astraedus marked this pull request as ready for review July 26, 2026 03:18
@astraedus
astraedus merged commit efd1b15 into main Jul 26, 2026
2 checks passed
@astraedus
astraedus deleted the extension-mvp branch July 26, 2026 03:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant