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)
- 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-boundariesconstraint:scope:extension→ onlyscope:shared). - 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.
- 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. - No paid APIs. Currency rates come from Frankfurter (free, keyless ECB rates), cached 24h, with bundled static fallback rates.
The three runtime surfaces
| Surface | Entry (bundled by tools/build.ts) | What it does |
|---|---|---|
| Content script | src/content/content-script.ts | Annotates prices in the page, shows the hover tooltip (in a shadow root), layaway capture (alt-click + context-menu message) |
| Popup | src/popup/popup.ts | Rate entry (hourly or yearly÷hours), currency, feature toggles, layaway list (sign-in gated), account sign-in |
| Service worker | src/background/service-worker.ts | The 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.launchWebAuthFlow → signInWithIdp. 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:
lintand the tests (vite:test --coverage) run vianx affected.typecheckandbuildfor this project are not gated in CI today — CI only builds the web app — so runbunx nx typecheck|build stretched-chrome-extensionlocally before shipping an extension change.
Common tasks
- Try it:
bunx nx build stretched-chrome-extension→chrome://extensions→ Load unpacked →dist/apps/stretched-chrome-extension. - Add a feature: new folder under
src/features/, flag entry infeature-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 insrc/features/multi-currency/price-detector-multi.tsand the static fallback table inrates.ts.