diff --git a/.github/assets/banner.svg b/.github/assets/banner.svg
new file mode 100644
index 0000000..8aaf43d
--- /dev/null
+++ b/.github/assets/banner.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+ drogers0/
+ SectorScope
+ Sector exposure for your Fidelity portfolio
+
+
diff --git a/.github/assets/icon.svg b/.github/assets/icon.svg
new file mode 100644
index 0000000..ed0be4c
--- /dev/null
+++ b/.github/assets/icon.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/screenshots/card.png b/.github/screenshots/card.png
new file mode 100644
index 0000000..9152958
Binary files /dev/null and b/.github/screenshots/card.png differ
diff --git a/.github/screenshots/options.png b/.github/screenshots/options.png
new file mode 100644
index 0000000..f03ca15
Binary files /dev/null and b/.github/screenshots/options.png differ
diff --git a/.github/screenshots/popup.png b/.github/screenshots/popup.png
new file mode 100644
index 0000000..96d4448
Binary files /dev/null and b/.github/screenshots/popup.png differ
diff --git a/.gitignore b/.gitignore
index 105aa91..bfc0d99 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
node_modules/
dist/
+release/
*.local
.DS_Store
*.har
diff --git a/CLAUDE.md b/CLAUDE.md
index 20ad9bc..201217c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,7 +43,7 @@ These are the rules we hold the line on in this repo. Apply them to every change
## Commands
```bash
-npm run build # build all 3 Vite configs + copy manifest/styles → dist/
+npm run build # single `vite build` via @crxjs/vite-plugin → dist/ (Chrome-ready MV3)
npm run test # vitest run (node env)
npm run typecheck # tsc --noEmit (both tsconfigs)
npm run lint # eslint src
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e4e4412
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 David Rogers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6b0032e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,156 @@
+
+
+
+
+
+ A Chrome extension that adds a native-looking sector-exposure card to your Fidelity portfolio. Recommendation-only; all data stays local.
+
+
+
+
+
+
+
+
+
+
+---
+
+Fidelity's portfolio summary shows you an **asset allocation** donut (stocks vs. bonds vs. cash), but nothing about your **sector** mix. SectorScope is a Chrome extension (Manifest V3) that adds a card looking like it shipped with the site: it reads the holdings already on the page, resolves each fund/ETF to its true sector breakdown, and charts your real Information-Technology-vs-Financials-vs-everything-else exposure. Then, against a target allocation you set, it tells you **what to buy with your available cash** to close the gaps, and optionally what to trim.
+
+It never asks for your credentials, never places a trade, and never sends your holdings anywhere. Everything runs in your browser.
+
+> [!NOTE]
+> SectorScope is **recommendation-only** and provides **estimates**. It does not place, pre-fill, or submit trades, and it is **not affiliated with or endorsed by Fidelity Investments**.
+
+
+
+
+
+## Features
+
+- 🍩 **Native-looking sector donut** — a custom SVG chart (no CDN libraries) styled to match Fidelity's own Asset allocation card, injected right into the Summary page's masonry layout.
+- 🧮 **True look-through exposure** — resolves each holding to its underlying sector mix via Fidelity's ETF and mutual-fund composition endpoints (by symbol and CUSIP), so a total-market fund is split across sectors instead of dumped into "Other."
+- 🎯 **Target allocation you control** — set a per-sector target (per Fidelity account), map each sector to a preferred buy ticker, and see current-vs-target at a glance.
+- 💸 **Holdings-aware buy recommendations** — a greedy allocator deploys your *available-to-trade* cash into the most underweight sectors, preferring instruments you already hold before introducing a new ticker.
+- ✂️ **Optional trim suggestions** — sector-level sell-to-target amounts for overweight sectors, restrictable to tax-advantaged accounts so you don't trigger avoidable capital gains.
+- 🔒 **Local by design** — target allocations and a short-lived sector-exposure cache live in `chrome.storage.local`. No account data ever leaves the browser.
+
+## Screenshots
+
+
+
+ Toolbar popup — quick per-account glance & toggles
+ Options — per-account targets, buy tickers, and behavior
+
+
+
+
+
+
+
+> All screenshots use **synthetic** holdings and made-up tickers (`TCHGX`, `INDUX`, …) — never real account data.
+
+## How it works
+
+```mermaid
+flowchart LR
+ A([Fidelity Summary page]) --> B[Content script reads holdings + cash]
+ B --> C[Service worker fetches sector composition]
+ C -. opt-in .-> Y[Yahoo lookup unclassified funds]
+ Y --> D
+ C --> D[Aggregate → sector %]
+ T[(Target allocation chrome.storage.local)] --> D
+ D --> E[Allocate cash to underweight sectors]
+ E --> F([Render card donut · buys · trims])
+
+ classDef proc fill:#1b2438,stroke:#28324d,color:#e6e9ef;
+ classDef edge fill:#243056,stroke:#6c7ba6,color:#c9cfdb;
+ classDef opt fill:#202a3f,stroke:#6c7ba6,color:#9aa4b7,stroke-dasharray:4 3;
+ classDef out fill:#ef5f6e,stroke:#c94656,color:#20131a,font-weight:bold;
+ class B,C,D,E proc;
+ class A,T edge;
+ class Y opt;
+ class F out;
+ linkStyle default stroke:#6c7ba6,stroke-width:1.5px;
+```
+
+Every step runs locally. The only network requests are the sector-composition lookups described in [Privacy & permissions](#privacy--permissions) — never your holdings, balances, or account numbers.
+
+## Privacy & permissions
+
+SectorScope handles private financial data, so it is built to keep that data on your machine:
+
+- **No credentials.** It never asks for, reads, or stores your Fidelity username, password, cookies, or CSRF tokens.
+- **No trading.** It cannot place, pre-fill, or submit orders. It only reads and recommends.
+- **No analytics, no backend.** There is no server. Your holdings, balances, and account numbers are never transmitted anywhere.
+- **Local storage only.** Your target allocations and a 7-day sector-exposure cache live in `chrome.storage.local`. Account identifiers are hashed before use.
+
+Every permission is requested for one concrete reason:
+
+| Permission | Why it's needed |
+| --- | --- |
+| `storage` | Save your target allocations and cache sector-exposure data locally. |
+| `digital.fidelity.com` | Read the holdings and available-to-trade balance from the portfolio page you're viewing, and inject the card. |
+| `research2relay.fidelity.wallst.com` | Fetch **ETF** sector exposure by symbol. |
+| `fundresearch.fidelity.com` | Fetch **mutual-fund** composition by CUSIP. |
+| `query1.finance.yahoo.com`, `fc.yahoo.com` | **Opt-in only** — look up sector data for funds Fidelity doesn't classify (e.g. some 401(k)/403(b) institutional funds). Off by default; enable it in Settings. |
+
+All Fidelity fetches are made with `credentials: 'omit'` and an 8-second timeout.
+
+## Install
+
+SectorScope isn't on the Chrome Web Store yet. A prebuilt, ready-to-load zip is attached to every [GitHub Release](https://github.com/drogers0/SectorScope/releases/latest) — no build tools required.
+
+1. Download `sectorscope-dist-v.zip` from the [latest release](https://github.com/drogers0/SectorScope/releases/latest).
+2. Unzip it. You'll get a `sectorscope-/` folder that contains `manifest.json`.
+3. Open `chrome://extensions`, enable **Developer mode** (top-right), click **Load unpacked**, and select that unzipped `sectorscope-/` folder — the one *containing* `manifest.json`, not its parent.
+
+Then log in to Fidelity and open **Portfolio → Summary**. The card injects below the Asset allocation card. If it asks you to visit your Positions page once, do that and return to Summary (Positions primes the holdings data). The MV3 service worker shows **"Inactive"** on `chrome://extensions` until a Fidelity page wakes it — that's normal.
+
+
+### Build it yourself
+
+Prefer to build from source? Requires **Node 18+**:
+
+```bash
+git clone https://github.com/drogers0/SectorScope.git
+cd SectorScope
+npm install
+npm run build # outputs the loadable extension to dist/
+```
+
+Then **Load unpacked** the resulting **`dist/`** folder (the one containing `manifest.json`) exactly as above.
+
+## Development
+
+```bash
+npm run build # build all Vite configs + copy manifest/icons/styles → dist/
+npm run test # vitest (node env) — 416 tests
+npm run typecheck # tsc --noEmit (both tsconfigs)
+npm run lint # eslint src
+```
+
+**Architecture:**
+
+- `src/content/` — content script: detects the page, reads holdings/balances, injects the card, survives SPA navigation.
+- `src/background/` — MV3 service worker: cross-origin sector-exposure fetches + cache.
+- `src/core/` — framework-free engine: sector mapping, aggregation, and the buy/trim recommendation logic (the primary unit-test surface).
+- `src/ui/` — the vanilla-TS card island (donut, legend, tables).
+- `src/popup/`, `src/options/` — React surfaces for quick toggles and the target-allocation editor.
+- `src/fidelity/` — parsers for Fidelity's positions/balances/account endpoints, driven by sanitized fixtures.
+
+> [!IMPORTANT]
+> Unit tests run in a node env with no DOM, no service worker, and no live Fidelity endpoints. Passing `typecheck` / `lint` / `test` is necessary but **not sufficient** — build, load the unpacked `dist/` in a Chrome dev window, and verify the change on a real Fidelity page before calling it done.
+>
+> **Never commit real account data.** Tests, fixtures, and screenshots must use synthetic values only.
+
+## Contributing
+
+Issues and pull requests are welcome. Please keep changes covered by tests, run `npm run typecheck && npm run lint && npm run test` before opening a PR, and never include real brokerage data in code, fixtures, screenshots, or commit messages.
+
+## License
+
+[MIT](LICENSE) © David Rogers
+
+SectorScope provides estimates and does not place trades. Not affiliated with Fidelity Investments.
diff --git a/icons/icon-128.png b/icons/icon-128.png
new file mode 100644
index 0000000..2b9f530
Binary files /dev/null and b/icons/icon-128.png differ
diff --git a/icons/icon-16.png b/icons/icon-16.png
new file mode 100644
index 0000000..a15001b
Binary files /dev/null and b/icons/icon-16.png differ
diff --git a/icons/icon-32.png b/icons/icon-32.png
new file mode 100644
index 0000000..d1620b4
Binary files /dev/null and b/icons/icon-32.png differ
diff --git a/icons/icon-48.png b/icons/icon-48.png
new file mode 100644
index 0000000..a3d556a
Binary files /dev/null and b/icons/icon-48.png differ
diff --git a/manifest.json b/manifest.json
index 1fe3bce..62a10bc 100644
--- a/manifest.json
+++ b/manifest.json
@@ -4,6 +4,12 @@
"version": "1.0.0",
"description": "Augments Fidelity portfolio pages with a native-looking Sector exposure card. Recommendation-only. All data stays local.",
"permissions": ["storage"],
+ "icons": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ },
"host_permissions": [
"https://digital.fidelity.com/*",
"https://research2relay.fidelity.wallst.com/*",
@@ -12,26 +18,32 @@
"https://fc.yahoo.com/*"
],
"background": {
- "service_worker": "serviceWorker.js",
+ "service_worker": "src/background/serviceWorker.ts",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://digital.fidelity.com/*"],
- "js": ["pageDataCapture.js"],
+ "js": ["src/content/pageDataCapture.iife.ts"],
"run_at": "document_start",
"world": "MAIN"
},
{
"matches": ["https://digital.fidelity.com/*"],
- "js": ["contentScript.js"],
- "css": ["styles.css"],
+ "js": ["src/content/contentScript.iife.ts"],
+ "css": ["src/content/styles.css"],
"run_at": "document_start"
}
],
"action": {
"default_popup": "popup.html",
- "default_title": "SectorScope"
+ "default_title": "SectorScope",
+ "default_icon": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ }
},
"options_ui": {
"page": "options.html",
diff --git a/northstar.md b/northstar.md
deleted file mode 100644
index bb74360..0000000
--- a/northstar.md
+++ /dev/null
@@ -1,861 +0,0 @@
-# High-level design spec: SectorScope: Fidelity sector-exposure Chrome extension
-
-## 1. Product goal
-
-Build a Chrome extension that augments Fidelity’s portfolio summary/account pages with a native-looking **Sector exposure** card.
-
-The extension should:
-
-1. Detect when the user is viewing a Fidelity account/summary page.
-2. Read the current account’s positions and current market values.
-3. Read available cash / “available to trade.”
-4. Compute current sector exposure.
-5. Let the user configure target sector exposure.
-6. Show a chart that visually matches Fidelity’s existing **Asset allocation** card.
-7. Recommend what sector/fund/ticker to buy and how much, based on available cash and underweight sectors.
-8. Keep all brokerage data local to the browser.
-
-This should be a **recommendation-only tool**. It should not place trades, autofill trade tickets, submit orders, or request/store Fidelity credentials.
-
----
-
-## 2. Existing source logic
-
-The starting point is the `sector_exposure.ipynb` notebook in `drogers0/musings`.
-
-Current notebook behavior:
-
-* Reads a local Fidelity portfolio CSV.
-* Fetches ETF/mutual-fund sector exposure from Fidelity endpoints.
-* Maps Morningstar/Fidelity sector names to canonical sector names.
-* Aggregates each holding’s dollar value into sector-level dollar exposure.
-* Builds current sector percentages and plots a pie chart.
-* Hardcodes target sector percentages and maps them to canonical sector labels.
-* Computes current vs. target percent differences and dollar differences.
-* Simulates deploying available cash into the most underweight sectors.
-
-The extension should port this logic into TypeScript and replace local CSV/PDF inputs with live Fidelity page/session data.
-
----
-
-## 3. Proposed repo stack
-
-Use a modern Manifest V3 Chrome extension setup.
-
-Recommended stack:
-
-```text
-TypeScript
-Vite
-React or vanilla DOM components
-Manifest V3
-chrome.storage.local
-CSS modules or scoped CSS
-Vitest for unit tests
-Playwright for local extension/e2e tests, optional
-```
-
-React is optional. The core logic should be framework-independent pure TypeScript.
-
----
-
-## 4. Suggested repo structure
-
-```text
-fidelity-sector-exposure/
- package.json
- vite.config.ts
- tsconfig.json
- manifest.json
-
- src/
- background/
- serviceWorker.ts
-
- content/
- contentScript.ts
- fidelityPageDetector.ts
- pageDataBridge.ts
- injectCard.ts
- styles.css
-
- popup/
- Popup.tsx
- popup.html
- popup.tsx
-
- options/
- Options.tsx
- options.html
- options.tsx
-
- core/
- types.ts
- sectors.ts
- sectorMapping.ts
- exposureProvider.ts
- portfolioAggregator.ts
- targetAllocation.ts
- recommendationEngine.ts
- formatters.ts
-
- fidelity/
- endpoints.ts
- positionsParser.ts
- balancesParser.ts
- accountParser.ts
-
- ui/
- SectorExposureCard.tsx
- DonutChart.tsx
- Legend.tsx
- RecommendationTable.tsx
- TargetEditor.tsx
-
- storage/
- settingsStore.ts
- cacheStore.ts
-
- test/
- fixtures/
- positions.sample.json
- balances.sample.json
- portfolioAggregator.test.ts
- recommendationEngine.test.ts
- parsers.test.ts
-```
-
----
-
-## 5. Extension architecture
-
-### 5.1 Content script
-
-Runs on Fidelity portfolio pages.
-
-Responsibilities:
-
-* Detect the relevant Fidelity page.
-* Locate the existing **Asset allocation** card or summary card container.
-* Inject the custom **Sector exposure** card.
-* Collect or receive positions/balances data.
-* Render chart and recommendations.
-* Open configuration UI when user clicks “Configure target exposure.”
-
-Target pages likely include:
-
-```text
-https://digital.fidelity.com/ftgw/digital/portfolio/summary
-https://digital.fidelity.com/ftgw/digital/portfolio/positions
-https://digital.fidelity.com/ftgw/digital/trade-equity/*
-```
-
-Use conservative host permissions.
-
-### 5.2 Background service worker
-
-Responsibilities:
-
-* Handle cross-origin fetches when necessary.
-* Cache sector exposure data.
-* Store/retrieve settings.
-* Avoid keeping sensitive account data longer than necessary.
-
-Important: service workers are transient in Manifest V3. Do not rely on global in-memory state.
-
-### 5.3 Options page / popup
-
-Responsibilities:
-
-* Let user configure target allocation.
-* Let user map sectors to preferred buy tickers.
-* Let user reset settings.
-* Show privacy/data-local notice.
-* Optional: export/import target allocation JSON.
-
----
-
-## 6. Data sources
-
-### 6.1 Positions endpoint observed in HAR
-
-Primary positions endpoint:
-
-```text
-POST https://digital.fidelity.com/ftgw/digital/positions/poswebex/api/positions
-```
-
-Useful response fields:
-
-```ts
-rowData[].rowType
-rowData[].sym.name
-rowData[].sym.cusip
-rowData[].sym.desc
-rowData[].qty.val
-rowData[].curVal.val
-rowData[].actPer.val
-rowData[].meta.securityType
-rowData[].tradActns.isBuy
-rowData[].tradActns.isSell
-acctTot[].currentVal
-```
-
-Use this for holdings/current market value.
-
-Expected normalized shape:
-
-```ts
-type Holding = {
- symbol: string;
- cusip?: string;
- description?: string;
- currentValue: number;
- quantity?: number;
- accountPercent?: number;
- securityType?: string;
- canBuy?: boolean;
- canSell?: boolean;
-};
-```
-
-### 6.2 Available-to-trade endpoint observed in balance HAR
-
-Primary transaction balances endpoint:
-
-```text
-POST https://digital.fidelity.com/ftgw/digital/api-unified-balances/api/tran-balances/v1
-```
-
-Useful response path:
-
-```ts
-transactionBalances[0].balanceDetails.balances[]
-```
-
-Find the balance item where:
-
-```ts
-balanceCode === "AVAILABLE_TO_TRADE"
-```
-
-Expected normalized shape:
-
-```ts
-type CashBalance = {
- availableToTrade: number;
- asOf?: string;
-};
-```
-
-### 6.3 Account metadata endpoint observed in balance HAR
-
-```text
-POST https://digital.fidelity.com/ftgw/digital/trade-equity/accounts
-```
-
-Useful fields:
-
-```ts
-acctDetail[].acctNum
-acctDetail[].acctType
-acctDetail[].acctSubType
-acctDetail[].name
-acctDetail[].isDefaultAcct
-acctDetail[].isTradable
-```
-
-Expected normalized shape:
-
-```ts
-type FidelityAccount = {
- accountId: string; // internal only; never display full value
- displayName?: string;
- accountType?: string;
- accountSubType?: string;
- isDefault?: boolean;
- isTradable?: boolean;
-};
-```
-
-### 6.4 Fidelity sector exposure endpoints from notebook
-
-Notebook endpoint templates:
-
-```text
-ETF exposure:
-https://research2relay.fidelity.wallst.com/pi/etf-api/v1/Exposure?symbols={SYMBOL}
-
-Mutual fund exposure:
-https://fundresearch.fidelity.com/mutual-funds/api/v1/investments/{CUSIP}/composition?funduniverse=RETAIL&period=10YR
-```
-
-Current notebook uses CUSIPs for mutual-fund composition data.
-
----
-
-## 7. Data flow
-
-```text
-Fidelity page loads
- ↓
-Content script detects account/summary page
- ↓
-Extension obtains holdings from page data / observed Fidelity request / endpoint response
- ↓
-Extension obtains available-to-trade balance
- ↓
-Normalize holdings
- ↓
-Fetch or cache sector exposures by symbol/CUSIP
- ↓
-Aggregate sector dollars
- ↓
-Convert to sector percentages
- ↓
-Load user target allocation
- ↓
-Compute current-vs-target differences
- ↓
-Allocate available cash to underweight sectors
- ↓
-Render Fidelity-style Sector exposure card
-```
-
----
-
-## 8. Core TypeScript data contracts
-
-```ts
-export type CanonicalSector =
- | "Information Technology"
- | "Communication Services"
- | "Financials"
- | "Consumer Discretionary"
- | "Health Care"
- | "Industrials"
- | "Consumer Staples"
- | "Energy"
- | "Materials"
- | "Real Estate"
- | "Utilities"
- | "Fixed Income"
- | "Cash"
- | "Other";
-
-export type Holding = {
- symbol: string;
- cusip?: string;
- description?: string;
- currentValue: number;
- quantity?: number;
- accountPercent?: number;
- securityType?: string;
- canBuy?: boolean;
- canSell?: boolean;
-};
-
-export type SectorExposureItem = {
- sector: CanonicalSector;
- percent: number; // 0-100
-};
-
-export type HoldingWithExposure = Holding & {
- sectorExposure: SectorExposureItem[];
-};
-
-export type SectorAggregate = {
- sector: CanonicalSector;
- currentValue: number;
- currentPercent: number;
-};
-
-export type TargetAllocation = {
- sector: CanonicalSector;
- targetPercent: number;
- preferredBuySymbol?: string;
-};
-
-export type Recommendation = {
- sector: CanonicalSector;
- preferredBuySymbol?: string;
- currentValue: number;
- currentPercent: number;
- targetPercent: number;
- percentDifference: number;
- recommendedBuyDollars: number;
- futureValue: number;
- futurePercent: number;
- futurePercentDifference: number;
-};
-```
-
----
-
-## 9. Core modules
-
-### `positionsParser.ts`
-
-Input: raw Fidelity positions response.
-
-Output: `Holding[]`.
-
-Rules:
-
-* Include rows where `rowType` is likely a real position.
-* Prefer `rowData[].curVal.val` for market value.
-* Exclude account-total rows.
-* Decide whether to include/exclude core cash row:
-
- * For sector exposure chart, likely include as `Cash` or `Other`.
- * For sector-buy recommendations, avoid recommending buys into core cash.
-
-### `balancesParser.ts`
-
-Input: raw transaction balances response.
-
-Output: `CashBalance`.
-
-Rules:
-
-* Find `balanceCode === "AVAILABLE_TO_TRADE"`.
-* If unavailable, return `undefined` and render card with “cash unavailable.”
-* Do not guess from account value or core position.
-
-### `exposureProvider.ts`
-
-Input: `Holding`.
-
-Output: `SectorExposureItem[]`.
-
-Priority:
-
-1. User override, if configured.
-2. Mutual-fund endpoint by CUSIP if CUSIP available.
-3. ETF endpoint by symbol.
-4. Static fallback mapping for common money market/core/cash instruments.
-5. Return `Other` if unresolved.
-
-Add cache:
-
-```ts
-type ExposureCacheEntry = {
- symbol: string;
- cusip?: string;
- exposure: SectorExposureItem[];
- fetchedAt: string;
- source: "fidelity-etf" | "fidelity-mutual" | "manual" | "fallback";
-};
-```
-
-Reasonable TTL: 7 days.
-
-### `portfolioAggregator.ts`
-
-Input: `HoldingWithExposure[]`.
-
-Output: `SectorAggregate[]`.
-
-Logic:
-
-```text
-for each holding:
- for each sector exposure item:
- sectorDollars += holding.currentValue * item.percent / 100
-
-total = sum sectorDollars
-currentPercent = sectorDollars / total * 100
-```
-
-### `recommendationEngine.ts`
-
-Input:
-
-```ts
-SectorAggregate[]
-TargetAllocation[]
-availableToTrade: number
-```
-
-Output:
-
-```ts
-Recommendation[]
-```
-
-Initial algorithm:
-
-* Compute current sector percent.
-* Compute target percent.
-* Compute percent difference.
-* Allocate cash to most underweight sector until cash is exhausted.
-* Use a configurable increment, not necessarily $1. Suggested default: `$10` or `$25`.
-* Return recommendations sorted by `recommendedBuyDollars desc`.
-
-Important: the notebook currently increments one dollar at a time. That is simple but inefficient for larger balances. Port the behavior but use a tunable increment.
-
----
-
-## 10. UI design
-
-### 10.1 Placement
-
-Target the existing Fidelity **Asset allocation** summary card.
-
-Do not instantiate Fidelity Angular components. Instead, inject a self-contained DOM island that visually matches the card.
-
-Preferred MVP placement:
-
-```text
-Inside or directly below the existing Asset allocation card body
-```
-
-Avoid inserting a new top-level masonry item unless the agent also handles Fidelity’s masonry layout. The pasted markup shows inline masonry positioning with `top`, `left`, and `width`; injecting a sibling card could cause overlap.
-
-Candidate selectors:
-
-```css
-[data-content-name="AcctDataAssetAllocation"]
-[data-content-type="AssetAllocation"]
-[data-testid^="summary-panel--comp-flow--masonry-card-item"]
-```
-
-### 10.2 Visual structure
-
-```text
-Sector exposure
-As of MM/DD/YYYY
-
-[donut chart] Information Technology 27.1%
- Communication Services 17.1%
- Financials 16.3%
- Consumer Discretionary 14.9%
- Industrials 13.9%
- Health Care 10.8%
-
-Available to trade: $X
-Suggested buys: $Y SYMBOL, $Z SYMBOL
-
-Configure target exposure
-```
-
-### 10.3 Chart
-
-Use one of:
-
-1. Custom SVG donut chart — recommended for MVP.
-2. Bundled Highcharts — closer Fidelity visual match, larger dependency.
-
-Do not load charting libraries from a CDN.
-
-### 10.4 Styling
-
-Use a unique prefix, for example `se-`, to avoid collisions.
-
-Do not rely on `_ngcontent-*` attributes. They are generated by Angular and unstable.
-
-Use Fidelity-like colors, spacing, and typography where reasonable.
-
----
-
-## 11. Target allocation UX
-
-Settings should include:
-
-```ts
-type ExtensionSettings = {
- targetAllocations: TargetAllocation[];
- sectorColors: Record;
- includeCashInExposure: boolean;
- recommendationIncrementDollars: number;
- lastSelectedAccountIdHash?: string;
-};
-```
-
-Default target allocation can mirror the notebook’s current hardcoded target:
-
-```text
-Information Technology: 27.1%
-Communication Services: 17.1%
-Financials: 16.3%
-Consumer Discretionary: 14.9%
-Industrials: 13.9%
-Health Care: 10.8%
-```
-
-But this should be user-editable.
-
-Validation:
-
-* Target percentages must sum to 100%.
-* Warn, but do not crash, if they do not.
-* Allow zero-weight sectors.
-* Let user map each sector to a preferred buy ticker.
-
-Example sector-to-buy mapping:
-
-```ts
-{
- "Information Technology": "FSPTX",
- "Communication Services": "FBMPX",
- "Financials": "FIDSX",
- "Consumer Discretionary": "FSCPX",
- "Industrials": "FCYIX",
- "Health Care": "FBIOX"
-}
-```
-
----
-
-## 12. Privacy and security requirements
-
-This is critical because the extension touches financial-account data.
-
-Requirements:
-
-1. Do not ask for Fidelity username/password.
-2. Do not store cookies, CSRF tokens, account numbers, or raw HAR data.
-3. Do not send holdings, balances, account numbers, or recommendations to any third-party server.
-4. Keep computation local.
-5. Store settings locally using `chrome.storage.local`.
-6. Store only cached sector exposure data, not full account holdings.
-7. Redact account numbers in logs.
-8. Disable debug logging by default.
-9. Add a visible disclaimer: “This extension provides estimates and does not place trades.”
-10. Do not automate trade submission.
-
-The uploaded HAR files contain sensitive session/account material. They should be used only to infer endpoint shapes and should not be committed to the repo.
-
----
-
-## 13. Permissions
-
-Start narrow.
-
-Manifest permissions:
-
-```json
-{
- "permissions": ["storage"],
- "host_permissions": [
- "https://digital.fidelity.com/*",
- "https://research2relay.fidelity.wallst.com/*",
- "https://fundresearch.fidelity.com/*"
- ],
- "content_scripts": [
- {
- "matches": ["https://digital.fidelity.com/*"],
- "js": ["contentScript.js"],
- "css": ["styles.css"]
- }
- ]
-}
-```
-
-Avoid broad permissions like ``.
-
----
-
-## 14. Error states
-
-The card should handle:
-
-```text
-Not logged in
-No account selected
-Positions unavailable
-Available-to-trade unavailable
-Some sector exposures unavailable
-Target allocation not configured
-Target allocation does not sum to 100%
-Fidelity changed page/endpoint structure
-Network request failed
-```
-
-User-facing messages should be non-alarming and clear:
-
-```text
-Sector exposure unavailable. Open your Positions page once, then return to Summary.
-```
-
-```text
-Available-to-trade balance unavailable. Recommendations are hidden until cash data is available.
-```
-
-```text
-3 holdings could not be classified and were grouped as Other.
-```
-
----
-
-## 15. Testing plan
-
-### Unit tests
-
-Cover:
-
-* positions parser
-* balances parser
-* sector-name canonicalization
-* sector aggregation
-* target allocation validation
-* recommendation engine
-* exposure cache behavior
-
-### Fixture tests
-
-Use sanitized JSON fixtures derived from HAR response shapes.
-
-Never commit raw HAR files.
-
-### Browser tests
-
-Manual MVP test:
-
-1. Load unpacked extension.
-2. Visit Fidelity summary page.
-3. Confirm card injects once.
-4. Confirm no duplicate cards after SPA navigation.
-5. Visit positions page.
-6. Confirm holdings parse.
-7. Visit trade/balance page.
-8. Confirm available-to-trade parses.
-9. Return to summary.
-10. Confirm graph and recommendations render.
-
----
-
-## 16. Milestones
-
-### Milestone 1: Repo skeleton
-
-Deliver:
-
-* Manifest V3 extension scaffold.
-* TypeScript build.
-* Content script loads on `digital.fidelity.com`.
-* Placeholder card injects into summary page.
-* Basic scoped CSS.
-
-Acceptance:
-
-* Extension builds.
-* Placeholder “Sector exposure” card appears once on Fidelity summary page.
-
-### Milestone 2: Core calculation port
-
-Deliver:
-
-* Pure TypeScript implementation of:
-
- * holdings normalization
- * sector aggregation
- * target comparison
- * recommendation generation
-* Unit tests.
-
-Acceptance:
-
-* Given sample holdings/exposures/target/cash, engine returns expected sector percentages and buy recommendations.
-
-### Milestone 3: Fidelity data integration
-
-Deliver:
-
-* Positions parser for `/positions/poswebex/api/positions`.
-* Balance parser for `/api-unified-balances/api/tran-balances/v1`.
-* Safe page/session data bridge.
-* Sanitized fixtures.
-
-Acceptance:
-
-* Extension can obtain current holdings and available-to-trade value from a logged-in Fidelity session without asking for credentials.
-
-### Milestone 4: Sector exposure provider
-
-Deliver:
-
-* ETF exposure fetcher.
-* Mutual-fund exposure fetcher by CUSIP.
-* Canonical sector mapping.
-* Local cache.
-* Fallback `Other` handling.
-
-Acceptance:
-
-* Given real holdings, most symbols resolve to sector exposure.
-* Unresolved symbols are grouped as `Other`.
-
-### Milestone 5: Fidelity-style UI
-
-Deliver:
-
-* Donut chart.
-* Legend.
-* Available-to-trade callout.
-* Recommendation table.
-* “Configure target exposure” action.
-
-Acceptance:
-
-* Card visually resembles Fidelity’s Asset allocation card.
-* It does not break Fidelity’s layout.
-* It remains stable across SPA navigation.
-
-### Milestone 6: Target allocation editor
-
-Deliver:
-
-* Popup or injected modal for editing target percentages.
-* Sector-to-buy-symbol mapping.
-* Validation.
-* Local storage.
-
-Acceptance:
-
-* User can edit targets, save, refresh Fidelity page, and see updated recommendations.
-
----
-
-## 17. Non-goals for MVP
-
-Do not implement:
-
-* Trade execution.
-* Trade-ticket autofill.
-* Backend syncing.
-* Multi-user accounts.
-* Tax-aware selling.
-* Rebalancing via sells.
-* Options/margin-specific recommendations.
-* Mobile support.
-* Chrome Web Store publishing automation.
-
----
-
-## 18. Open questions for the coding agent
-
-1. Should cash/core position be shown as a sector slice or excluded from sector exposure?
-2. Should recommendations buy Fidelity sector mutual funds only, ETFs only, or user-selected tickers?
-3. Should target allocation be global or per Fidelity account?
-4. Should the card appear inside the existing Asset allocation card or as a separate panel below it?
-5. Should unavailable exposure data block recommendations or flow into `Other`?
-6. What minimum buy increment should be used: `$1`, `$10`, `$25`, or user-configurable?
-7. Should recommendations consider mutual-fund minimums, trading restrictions, or settlement restrictions?
-
----
-
-## 19. Implementation guidance
-
-Treat the Fidelity endpoints as private, unstable web-app endpoints. Code should be defensive and parser-driven, not tightly coupled to every raw field.
-
-The safest implementation strategy is:
-
-```text
-Build the calculation engine first.
-Use sanitized fixtures second.
-Inject placeholder UI third.
-Integrate live Fidelity data last.
-```
-
-The extension should be designed as a local-only augmentation layer over Fidelity’s UI, not as an external financial-data service.
diff --git a/package-lock.json b/package-lock.json
index 6ccb1b3..00e0d73 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
+ "@crxjs/vite-plugin": "^2.7.0",
"@eslint/js": "^9.0.0",
"@types/chrome": "^0.0.300",
"@types/node": "^25.9.1",
@@ -307,6 +308,62 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@crxjs/vite-plugin": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.7.0.tgz",
+ "integrity": "sha512-NN98fUgJOEiVBshELJnYCW54+K2Yy067++/K+RdxclHsyG08ea+Em3lJJTubtPTCl2bECyp4+K69oT+izilnNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@webcomponents/custom-elements": "^1.5.0",
+ "acorn-walk": "^8.3.5",
+ "convert-source-map": "^1.7.0",
+ "debug": "^4.3.3",
+ "es-module-lexer": "^0.10.0",
+ "fs-extra": "^10.0.1",
+ "jsesc": "^3.0.2",
+ "magic-string": "^0.30.12",
+ "node-html-parser": "^7.1.0",
+ "pathe": "^2.0.1",
+ "picocolors": "^1.1.1",
+ "rollup": "2.80.0",
+ "rxjs": "7.5.7",
+ "tinyglobby": "^0.2.17"
+ },
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/@crxjs/vite-plugin/node_modules/convert-source-map": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@crxjs/vite-plugin/node_modules/es-module-lexer": {
+ "version": "0.10.5",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.10.5.tgz",
+ "integrity": "sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@crxjs/vite-plugin/node_modules/rollup": {
+ "version": "2.80.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
+ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -1944,6 +2001,13 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@webcomponents/custom-elements": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz",
+ "integrity": "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1967,6 +2031,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -2037,6 +2114,13 @@
"node": ">=6.0.0"
}
},
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -2189,6 +2273,36 @@
"node": ">= 8"
}
},
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2221,6 +2335,65 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.364",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
@@ -2228,6 +2401,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
@@ -2564,6 +2750,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2615,6 +2816,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2625,6 +2833,16 @@
"node": ">=8"
}
},
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2769,6 +2987,19 @@
"node": ">=6"
}
},
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2882,6 +3113,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-html-parser": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz",
+ "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-select": "^5.1.0",
+ "he": "1.2.0"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
@@ -2892,6 +3134,19 @@
"node": ">=18"
}
},
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -3148,6 +3403,16 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rxjs": {
+ "version": "7.5.7",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz",
+ "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -3301,6 +3566,13 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3359,6 +3631,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
diff --git a/package.json b/package.json
index 3c40af3..c139f16 100644
--- a/package.json
+++ b/package.json
@@ -4,15 +4,10 @@
"private": true,
"type": "module",
"scripts": {
- "build:pages": "vite build",
- "build:content": "vite build --config vite.content.config.ts",
- "build:capture": "vite build --config vite.capture.config.ts",
- "build:sw": "vite build --config vite.sw.config.ts",
- "build:copy": "cp manifest.json dist/ && cp src/content/styles.css dist/",
- "build": "npm run build:pages && npm run build:content && npm run build:capture && npm run build:sw && npm run build:copy",
- "dev:pages": "vite",
- "dev:content": "vite build --watch --config vite.content.config.ts",
- "dev:sw": "vite build --watch --config vite.sw.config.ts",
+ "build": "vite build",
+ "package": "npm run build && rm -rf release && mkdir -p release/sectorscope-$npm_package_version && cp -R dist/. release/sectorscope-$npm_package_version/ && (cd release && zip -rq sectorscope-dist-v$npm_package_version.zip sectorscope-$npm_package_version) && echo \"packaged release/sectorscope-dist-v$npm_package_version.zip\"",
+ "release:upload": "gh release upload v$npm_package_version release/sectorscope-dist-v$npm_package_version.zip --clobber && echo \"uploaded to release v$npm_package_version\"",
+ "dev": "vite",
"typecheck": "tsc --noEmit && tsc --noEmit --project tsconfig.node.json",
"lint": "eslint src",
"test": "vitest run",
@@ -23,6 +18,7 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
+ "@crxjs/vite-plugin": "^2.7.0",
"@eslint/js": "^9.0.0",
"@types/chrome": "^0.0.300",
"@types/node": "^25.9.1",
diff --git a/src/content/cardController.ts b/src/content/cardController.ts
index c1b0d8e..1c64bba 100644
--- a/src/content/cardController.ts
+++ b/src/content/cardController.ts
@@ -74,7 +74,7 @@ export function startCardController(cardEl: HTMLElement): () => void {
}
let latestSettings: ExtensionSettings = DEFAULT_EXTENSION_SETTINGS
// The selected account for this card instance. The card is torn down and re-injected on every
- // URL change (see contentScript.ts), so a controller instance maps to exactly one selection;
+ // URL change (see contentScript.iife.ts), so a controller instance maps to exactly one selection;
// resolve it once at startup. `undefined` until resolved → the pipeline no-ops.
let selection: AccountSelection | undefined
let lastAccountsKey = ''
diff --git a/src/content/contentScript.ts b/src/content/contentScript.iife.ts
similarity index 100%
rename from src/content/contentScript.ts
rename to src/content/contentScript.iife.ts
diff --git a/src/content/pageDataCapture.ts b/src/content/pageDataCapture.iife.ts
similarity index 100%
rename from src/content/pageDataCapture.ts
rename to src/content/pageDataCapture.iife.ts
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 7f060c9..1750c57 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -8,14 +8,8 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
+ "resolveJsonModule": true,
"types": ["node", "vite/client"]
},
- "include": [
- "vite.config.ts",
- "vite.shared.ts",
- "vite.content.config.ts",
- "vite.capture.config.ts",
- "vite.sw.config.ts",
- "vitest.config.ts"
- ]
+ "include": ["vite.config.ts", "vitest.config.ts"]
}
diff --git a/vite.capture.config.ts b/vite.capture.config.ts
deleted file mode 100644
index b220d90..0000000
--- a/vite.capture.config.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { libConfig } from './vite.shared'
-
-// MAIN-world page-data capture script: single-file IIFE.
-export default libConfig({
- entry: 'src/content/pageDataCapture.ts',
- fileName: 'pageDataCapture.js',
- format: 'iife',
- name: 'SectorScopeFidelityCapture',
- inlineDynamicImports: true,
-})
diff --git a/vite.config.ts b/vite.config.ts
index cbbcc34..a06ab4b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,24 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
-import { resolve } from 'path'
-import { fileURLToPath } from 'url'
-
-// __dirname is undefined in ESM scope; reconstruct it from import.meta.url.
-const __dirname = fileURLToPath(new URL('.', import.meta.url))
+import { crx } from '@crxjs/vite-plugin'
+import manifest from './manifest.json' with { type: 'json' }
+// Single build config: @crxjs/vite-plugin reads manifest.json (which references source
+// entry points), bundles the service worker, content scripts, popup, and options page, and
+// emits a Chrome-ready dist/manifest.json with the built filenames.
+//
+// The MAIN-world capture script and the isolated-world content script use the `.iife.ts`
+// naming convention so CRXJS emits them as standalone, fully-inlined bundles that Chrome runs
+// directly — the default content-script loader relies on chrome.runtime, which is unavailable
+// in the MAIN world.
export default defineConfig({
- plugins: [react()],
- build: {
- outDir: 'dist',
- emptyOutDir: true, // first build clears dist; subsequent builds append
- rollupOptions: {
- // popup.html and options.html are at project root (not in src/).
- // With the default Vite root (project directory), files here output to dist/popup.html
- // and dist/options.html — matching what manifest.json references.
- input: {
- popup: resolve(__dirname, 'popup.html'),
- options: resolve(__dirname, 'options.html'),
- },
- },
- },
+ plugins: [react(), crx({ manifest })],
})
diff --git a/vite.content.config.ts b/vite.content.config.ts
deleted file mode 100644
index c9ac2d6..0000000
--- a/vite.content.config.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { libConfig } from './vite.shared'
-
-// Content script: single-file IIFE (global name not referenced externally).
-export default libConfig({
- entry: 'src/content/contentScript.ts',
- fileName: 'contentScript.js',
- format: 'iife',
- name: 'SectorScopeContent',
- inlineDynamicImports: true,
-})
diff --git a/vite.shared.ts b/vite.shared.ts
deleted file mode 100644
index 2b96d8b..0000000
--- a/vite.shared.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { defineConfig } from 'vite'
-import type { LibraryFormats } from 'vite'
-import { resolve } from 'path'
-import { fileURLToPath } from 'url'
-
-// __dirname is undefined in ESM scope; reconstruct it from import.meta.url.
-const root = fileURLToPath(new URL('.', import.meta.url))
-
-type LibTarget = {
- entry: string
- fileName: string
- format: LibraryFormats
- /** IIFE global name (required for 'iife', ignored for 'es'). */
- name?: string
- /** Force a single-file bundle (no dynamic-import splitting). */
- inlineDynamicImports?: boolean
-}
-
-/**
- * A single-entry library build appended into dist/ (which the pages build creates and clears).
- * Shared by the content, capture, and service-worker targets.
- */
-export function libConfig({ entry, fileName, format, name, inlineDynamicImports }: LibTarget) {
- return defineConfig({
- build: {
- outDir: 'dist',
- emptyOutDir: false, // append; dist is built/cleared by the pages target
- lib: {
- entry: resolve(root, entry),
- formats: [format],
- ...(name ? { name } : {}),
- fileName: () => fileName,
- },
- ...(inlineDynamicImports
- ? { rollupOptions: { output: { inlineDynamicImports: true } } }
- : {}),
- },
- })
-}
diff --git a/vite.sw.config.ts b/vite.sw.config.ts
deleted file mode 100644
index 07ffdbf..0000000
--- a/vite.sw.config.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { libConfig } from './vite.shared'
-
-// Service worker: ES module. No inlineDynamicImports — preserves the option for
-// dynamic-import code splitting in the SW.
-export default libConfig({
- entry: 'src/background/serviceWorker.ts',
- fileName: 'serviceWorker.js',
- format: 'es',
-})