feat(ext): Nudge for Chrome, MV3 extension MVP - #9
Merged
Conversation
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
marked this pull request as ready for review
July 26, 2026 03:18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) andext-08(architecture); MV3 recipes fromext-01, YouTube fromext-03, Android semantics fromext-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 zerochrome.*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
npm run lintnpm run typechecknpm testnpm run build.output/chrome-mv3, manifest + SW verifiednpm run e2eE2E drives a real Chrome with the extension loaded. To give it real hostnames with no network, it maps
*.testonto 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
URLSearchParamstruncated the blocked URL.regexSubstitutioncannot percent-encode, so the target arrives verbatim and routinely contains its own?/&.watch?v=abc&t=30was being read aswatch?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).DelayView/BreathingView(found byrules-of-hooks). The fix needed anenabledflag onuseCompleteOnZero, because a disarmed view has a zero-length countdown andremaining === 0would otherwise complete the pause instantly and grant real access.'INHERIT'isn't aBlockMode, so it couldn't be acoerceModefallback, flagged independently by two test lanes.CI
New
extension-ci.yml(lint → typecheck → unit → build → Playwright under xvfb), scopedpaths: ['extension/**'].release.ymlgains the mirror-imagepaths-ignore: ['extension/**']so extension commits stop rebuilding the Android app. Verified before touching it: GitHub does not evaluate path filters for tag pushes, so thev*Android release flow is unaffected. Noext-v*tag has been created.Reviewer notes
chrome://extensions, and the dashboard says so plainly rather than pretending, honesty is the differentiator.extension/CLAUDE.mdcarries 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.