Skip to main content

Chrome Extension

See also: Firestore and Security Rules, Data Model, Auth and Users, Testing

apps/stretched-chrome-extension"Stretched — Prices in Hours", a Manifest V3 Chrome extension that finds prices on any web page and, on hover, translates them into hours of the user's life. It is the Stretched thesis applied where spending decisions actually happen: product pages, carts, checkouts.

Start with the project's own docs — this page is the map, those are the territory:

  • apps/stretched-chrome-extension/README.md — intent, feature status, the 45-vertical design-intent taxonomy, setup steps.
  • apps/stretched-chrome-extension/CLAUDE.md — agent-facing constraints and gotchas. Read it before changing anything in this app.

Hard constraints (the things everything else follows from)

  1. No Angular, no Firebase SDK, no runtime dependencies. Content scripts run inside arbitrary pages; the bundle stays tiny and framework-free. The only shared lib it may import is @stretched/types (enforced by an @nx/enforce-module-boundaries constraint: scope:extension → only scope:shared).
  2. Privacy is one-directional. Site classification happens locally against a bundled domain map; browsing URLs and page content never leave the machine. Account sync pulls data down and pushes only the layaway list up.
  3. Investing surfaces are silent — and so is Stretched's own site. On brokerage/crypto/market-data domains, and on stretched.app + the Firebase hosting domains (src/core/excluded-domains.ts), the extension annotates nothing. Investing: the site-wide "no investment commentary" disclaimer applies here too. Own site: prices there are already framed in hours, and the annotator would mutate the app's own DOM.
  4. No paid APIs. Currency rates come from Frankfurter (free, keyless ECB rates), cached 24h, with bundled static fallback rates.

The three runtime surfaces

SurfaceEntry (bundled by tools/build.ts)What it does
Content scriptsrc/content/content-script.tsAnnotates prices in the page, shows the hover tooltip (in a shadow root), layaway capture (alt-click + context-menu message)
Popupsrc/popup/popup.tsRate entry (hourly or yearly÷hours), currency, feature toggles, layaway list (sign-in gated), account sign-in
Service workersrc/background/service-worker.tsThe only cross-origin fetch surface for content scripts (currency rates) via src/core/messages.ts; owns the layaway context menu

The entry files are thin bootstraps; behavior lives in tested *-init.ts / feature modules. Settings live in chrome.storage.sync (one key, stretchedSettings) and every surface reacts to changes live via onSettingsChanged.

Feature modules & flags

Every user-facing feature is a folder under src/features/<id>/, gated by a flag in src/core/feature-flags.ts. That file is a registry (id, label, description, default) — the popup renders one toggle per entry automatically. Current flags: categoryFraming, layaway, multiCurrency, accountSync. (A pageTotal whole-page price-sum badge existed until 2026-07 and was removed as a product decision — the flag id is retired, not reusable, because old chrome.storage.sync blobs may still carry it.)

Feature modules never touch chrome.* or fetch directly — IO is injected through a deps parameter, which is what makes 100%-coverage testing possible. Wiring (real chrome APIs, real fetch) happens only in content-script-init.ts, popup-init.ts, and the service worker.

Site categories ("worth it for you")

src/core/site-category.ts defines 45 verticals (airlines, grocery, real-estate, gaming, …), each with metric archetypes and a tooltip framing: recurring-bill verticals get an annualized second line ("renewed monthly: ≈ 1 workday 4 h a year"), others get a one-line hint ("Judge it in cost per wear…"). src/core/vertical-domains.ts is the curated ~1,300-domain map (global top-10k scope, one vertical per domain — integrity is spec-enforced). The long-term plan (see the README's design-intent section) is account-personalized per-category metrics; the category ids move to @stretched/types when that lands.

Account & layaway

Auth is Firebase Auth over REST (src/features/account/firebase-auth.ts): sign-in, registration (+display name), password reset, and Google sign-in via chrome.identity.launchWebAuthFlowsignInWithIdp. The Google button hides itself until the OAuth client id in src/core/firebase-config.ts is filled (one-time console step, documented in the README). Same Firebase project as the apps — see Auth and Users.

The layaway list ("sleep on it") requires a signed-in session — it is account data. Two capture gestures, one pipeline (src/features/layaway/layaway-capture.ts): alt-click a price, or right-click → "Add to Stretched layaway" (the service worker registers the menu and relays the click to the content script, which remembers where the right-click landed). Signed out, both gestures toast a sign-in prompt and the popup section shows a prompt instead of the list. The list is tier-capped (free 10 / paid 200): the cap comes from entitlementsFor(tier) in @stretched/types, with the tier read by decoding the stored ID token's JWT payload (storedAccountClaims — no SDK, no network); at the cap, captures toast an upgrade prompt and the popup shows "n of m slots used". firestore.rules enforces the same cap server-side (keep-but-block-adds on downgrade — see Auth and Users). Items are stored locally and, when the accountSync flag is on, synced to the layaways/{uid} Firestore document over REST — its own collection, separate from users/{uid} by design. Shape: LayawayDoc in @stretched/types; rules: Firestore and Security Rules.

Quality gates

  • sonarjs/cognitive-complexity: ['error', 13] — now enforced workspace-wide from the root eslint config (this project pioneered the rule; its local copy was removed when the rule moved to the root).
  • vitest coverage thresholds: 100% statements/functions/lines, 98% branches (bootstrap/wiring entry files are excluded and must stay logic-free).
  • In CI: lint and the tests (vite:test --coverage) run via nx affected. typecheck and build for this project are not gated in CI today — CI only builds the web app — so run bunx nx typecheck|build stretched-chrome-extension locally before shipping an extension change.

Common tasks

  • Try it: bunx nx build stretched-chrome-extensionchrome://extensions → Load unpacked → dist/apps/stretched-chrome-extension.
  • Add a feature: new folder under src/features/, flag entry in feature-flags.ts, wire in the init file(s), specs to the coverage bar.
  • Add domains to a vertical: edit vertical-domains.ts; the integrity specs catch duplicates, casing, paths, and excluded-list overlap.
  • Add a currency: extend SUPPORTED_CURRENCIES + patterns in src/features/multi-currency/price-detector-multi.ts and the static fallback table in rates.ts.