diff --git a/build.mjs b/build.mjs index 5853bbd..83df197 100644 --- a/build.mjs +++ b/build.mjs @@ -1,5 +1,5 @@ import * as esbuild from 'esbuild'; -import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { copyFileSync, cpSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; const watch = process.argv.includes('--watch'); @@ -20,6 +20,8 @@ const entries = [ { entryPoints: ['src/popup/style.css'], outfile: 'dist/style.css' }, { entryPoints: ['src/options/index.ts'], outfile: 'dist/options.js' }, { entryPoints: ['src/options/style.css'], outfile: 'dist/options.css' }, + { entryPoints: ['src/sidebar/index.ts'], outfile: 'dist/sidebar.js' }, + { entryPoints: ['src/sidebar/style.css'], outfile: 'dist/sidebar.css' }, ]; function copyStatics() { @@ -28,6 +30,11 @@ function copyStatics() { writeFileSync('dist/manifest.json', manifest); copyFileSync('src/popup/index.html', 'dist/popup.html'); copyFileSync('src/options/index.html', 'dist/options.html'); + copyFileSync('src/sidebar/index.html', 'dist/sidebar.html'); + // Self-hosted fonts: fonts.css is loaded by all three surfaces; its url()s resolve to + // dist/fonts/*.woff2 (copied verbatim — not bundled, so esbuild never touches the woff2). + copyFileSync('src/styles/fonts.css', 'dist/fonts.css'); + cpSync('src/styles/fonts', 'dist/fonts', { recursive: true }); } if (watch) { diff --git a/manifest.json b/manifest.json index 8f5d490..ea8cce8 100644 --- a/manifest.json +++ b/manifest.json @@ -21,7 +21,12 @@ "default_popup": "dist/popup.html", "default_title": "expurge" }, - "permissions": ["storage", "tabs", "downloads", "scripting", "webNavigation"], + "sidebar_action": { + "default_panel": "dist/sidebar.html", + "default_title": "expurge", + "open_at_install": false + }, + "permissions": ["storage", "tabs", "downloads"], "host_permissions": [ "*://updates.expurge.dev/*" ], @@ -38,9 +43,9 @@ } }, "_notes": { - "webNavigation": "In permissions for background webNavigation.onErrorOccurred (load-error skipping). Not yet wired in background/index.ts — implement when adding M9 brokers.", + "webNavigation": "Removed — was reserved for background webNavigation.onErrorOccurred (load-error skipping) but never wired. Re-add to permissions when M9 wires webNavigation.onErrorOccurred.", "host_permissions_update_server": "updates.expurge.dev: confirm subdomain before M7 ships. Background fetch needs host_permissions (not optional) to bypass CORS without user consent. If URL changes, update here.", - "m9_new_brokers": "Each new broker domain needs TWO entries: (1) optional_host_permissions so browser.permissions.request() can offer it at run start; (2) content_scripts.matches so the overlay injects. Both are required — either alone does not work.", + "m9_new_brokers": "Each new broker domain needs TWO entries: (1) optional_host_permissions so browser.permissions.request() can offer it at run start; (2) content_scripts.matches so the headless challenge reporter injects (challenge detection). Both are required — either alone does not work. Verdict UI lives in the window-level sidebar (sidebar_action), which needs no per-domain entry.", "m8_no_new_permissions": "Persistence opt-ins (M8) use storage.local only — no new permissions needed.", "firefox_ignores_underscore_keys": "Firefox logs a warning for unknown top-level keys but loads the extension. Remove _notes before AMO submission if it causes review friction." } diff --git a/plan/expurge-progress.md b/plan/expurge-progress.md index 5fe309b..22d90d5 100644 --- a/plan/expurge-progress.md +++ b/plan/expurge-progress.md @@ -37,15 +37,24 @@ sections, redesigned popup (run-control-panel only). No persistence opt-ins. The popup currently contains the profile form and draft surfaces. Per the design interview, these move to the options page in M4+. The popup becomes a compact run control panel only. -### Planned: overlay → sidebar migration (decided 2026-07-01, not yet built) - -The shipped in-page shadow-DOM overlay (`src/content/index.ts`) and its Restore-Overlay / -PING / reinjection machinery are slated for replacement by a Firefox native **sidebar** — a -persistent run-wide checklist that drives navigation. Adds a first-class `deferred` work-item -state, a `MAX_OPEN_TABS=15` ceiling, per-broker `search.guidance`, and shrinks the content -script to a headless challenge reporter. Full plan in **`plan/sidebar-nav.md`**; rationale in -wherefore `2026-07-01-sidebar-run-navigation` (resolves Q-013, opens Q-015). The `content` / -`popup` / `background` rows above document the **current** overlay build, not the target. +### Done: overlay → sidebar migration (decided + built 2026-07-01) + +The in-page shadow-DOM overlay was **replaced by a Firefox native `sidebar_action`** — a +persistent, window-level run-wide checklist that drives navigation itself. Beyond what the +per-tab overlay could do, the sidebar adds an **interactive checklist** (grouped In progress / +Waiting / Done; click any non-terminal row to jump to that tab) and an always-available +**Defer** control. The migration also added a first-class `deferred` work-item state, a +`MAX_OPEN_TABS=15` ceiling, per-broker `search.guidance`, a shared `progressOf`/`isComplete` +definition, and shrank the content script to a **headless challenge reporter** (no UI, ~45 +lines). The reinjection machinery (Restore-Overlay / PING / `reinjectIfMissing` / `GET_ITEM`) +is gone, and the dead `scripting` permission was dropped. + +View truth lives in one pure function, `deriveView` (`src/sidebar/state.ts`); the background +builds its inputs and pushes `SIDEBAR_UPDATE`, and the sidebar renders without re-deriving. +Full plan (§-by-§ with commit refs) in **`plan/sidebar-nav.md`**; rationale in wherefore +`2026-07-01-sidebar-run-navigation` (resolves Q-013; Q-015 pending empirical Firefox check). +The `content` / `popup` / `background` rows above describe the **pre-migration** build and are +superseded by the sidebar architecture — see `sidebar-nav.md` for the current shape. --- diff --git a/plan/sidebar-nav.md b/plan/sidebar-nav.md index eec5074..1784716 100644 --- a/plan/sidebar-nav.md +++ b/plan/sidebar-nav.md @@ -59,7 +59,7 @@ Rides the existing bundled + Ed25519-signed remote dataset. **Not** subject to t ## Implementation -### 1. `manifest.json` +### 1. `manifest.json` — ✅ DONE (commit `1de0829`) Add: ```json @@ -67,61 +67,76 @@ Add: ``` No new `permissions` — `sidebarAction` is available whenever `sidebar_action` is present. Content-script `matches` stays (still needed for challenge detection). -### 2. `build.mjs` +### 2. `build.mjs` — ✅ DONE (commit `1de0829`) - esbuild entry: `src/sidebar/index.ts` → `dist/sidebar.js`, `src/sidebar/style.css` → `dist/sidebar.css`. - `copyStatics()` copies `src/sidebar/index.html` → `dist/sidebar.html`. -### 3. `src/sidebar/` (new — the checklist UI) +### 3. `src/sidebar/` (new — the checklist UI) — ✅ DONE (commits `1de0829`, `87a7179`) - **`index.html`** — links `sidebar.css` + `sidebar.js`. - **`style.css`** — `@import "../styles/tokens.css"` directly (no shadow-DOM isolation needed). Follow `design/STYLEGUIDE.md` for voice/components; reference tokens, never hard-code values. -- **`state.ts`** — **pure** state-derivation, extracted for tests (mirrors `coordinator.ts` / `classify.ts`). Maps `(run, activeItem, focusedTabUrl, challengeFlag)` → a tagged `SidebarView`. This is where the seven view states live (see below). +- **`state.ts`** — **pure** state-derivation, extracted for tests (mirrors `coordinator.ts` / `classify.ts`). Maps `(run, activeItem, focusedTabUrl, challengeFlag)` → a tagged `SidebarView`. This is where the eight view states live (see below). - **`index.ts`** — thin render layer. On load: `browser.windows.getCurrent()` → `windowId`, send `SIDEBAR_GET_STATE({ windowId })`. Subscribe to background-pushed `SIDEBAR_UPDATE`. Render the checklist (grouped **In progress / Waiting / Done**) plus the active-item detail (broker `guidance`, "look for" chips, verdict cluster, separate Defer control). Wire the buttons to send messages. **All rendering via `textContent`** for any dataset-sourced text. + - **Decision A — checklist data source (settled in Slice 6).** The active-item **detail** and which **view** come from the pushed `SidebarView` (background's `deriveView`; the UI never re-derives). The grouped **checklist** is rendered from `GET_RUN_STATE`, re-fetched on each `SIDEBAR_UPDATE`. Skew is negligible — pushes happen at the end of a serialized background mutation, so the run the checklist re-fetches already reflects it. No contract change. The revisit button reads the first `deferred` item from that same `GET_RUN_STATE` for its `FOCUS_ITEM`. **PII invariant:** the checklist shows broker name (dataset), a generic `alternate name` tag for AKA variants (from `nameVariant`, never the actual name), and a `listed` marker — never `variantFirst`/`variantLast`/`renderedUrl`/`listingUrl`. **Sidebar views** (the `state.ts` tagged union): - `no-run` — no active run in this window - `guidance` — active tab on the results page: `search.guidance` + "look for" chips + a **Not found / no results** action (records `clear` without visiting a details page) + a paste-URL fallback - `verdict` — active tab on a details page: four verdicts (hit / clear / unknown / skip) - `challenge` — active tab showing a CAPTCHA/challenge: explanation + **Skip this site** +- `offsite` — the broker tab wandered off the broker's host (address bar / a link / a redirect): **no** verdict controls (a listing can't be confirmed on, e.g., google.com) + a **Back to my results** action + Defer. Gated on `isOnHost(tabUrl, renderedUrl)`, checked before results/details so a lookalike host at the results pathname can't read as `guidance` either. Challenge outranks it (a Cloudflare interstitial is legitimately off-host mid-check) - `saving` — action sent, awaiting ACK - `recorded` — ACK received (tab closes 800 ms later for terminal verdicts) -- `revisit` — main pass empty, deferred items remain: "N sites waiting — revisit" (click focuses first deferred tab) +- `revisit` — main pass empty, deferred items remain: "N sites waiting — revisit". Carries `focusId` (first `deferred`, else first `pending` — the blocked-behind-deferred case, opened via `FOCUS_ITEM`→`ensureItemTab`; `null` only if neither remains). The button `FOCUS_ITEM`s `focusId`, so it works on the sidebar's very first render without re-fetching run state +- `done` — run finished naturally (no `pending`/`open`/`deferred` remain): terminal summary from `progressOf` (done / total / hits). Distinct from `no-run` (never started / no run in this window) +- `stopped` — run finished via **Stop** (`isComplete` AND some item has `skipReason: 'run_stopped'`): honest "Scan stopped — checked X of Y" summary, where `checked` **excludes** the abandoned `run_stopped` items (they're still counted in `total`). Chosen over `done` so a stopped run doesn't claim "all clear" + +The pure `deriveView` (Slice 3) returns only the eight **resting** views — `no-run` / `guidance` / `verdict` / `challenge` / `offsite` / `revisit` / `done` / `stopped`. `saving` and `recorded` are **transient** interaction states, not derivable from run state; the sidebar UI layer (Slice 6) sets them imperatively around a verdict send. They stay in the `SidebarView` union for completeness. The **Defer** control is present alongside the active-item detail in `guidance`/`verdict`/`challenge`, visually separated from the verdict cluster, labeled with what it does ("Still loading — set aside, come back at the end"). -### 4. `src/background/coordinator.ts` (pure — extend, don't rewrite) +### 4. `src/background/coordinator.ts` (pure — extend, don't rewrite) — ✅ DONE (commit `5398e5a`) + +- **`selectBatch`** — counts only `open` against the window (exclude `deferred`), still one-per-broker. Ceiling: `slots = min(batchSize − open, MAX_OPEN_TABS − heldTabs)` where `heldTabs = open + deferred` — opens up to remaining headroom (not a hard zero at the ceiling). New constant `MAX_OPEN_TABS = 15`. **`claimed` now includes deferred brokers** (a deferred tab is still live — no second variant against it). +- **`applyDefer(run, itemId)`** — `open → deferred` only; never re-defers, never touches pending, never overrides a verdict. Pure, unit-tested. +- **`applyStop`** — also sweeps `deferred` → `run_stopped` (was not in the original plan; required, else a stopped run strands deferred items as non-terminal). +- **Completion** — `isComplete(run)` (no `pending`/`open`/`deferred` remain) and `progressOf(run)` (`deferred` counts toward `total`, not `done`; `missing:` skips excluded from both) — one shared definition for popup + options + sidebar. +- Untouched: `buildItems`, `withVerdict`, `applySkip`, `applyMarkSent`. + +**⚠ Carried forward from the Slice-1 review — handle these in §5/§7, they're not yet done:** -- **`selectBatch`** — count only `open` items against the window (exclude `deferred`), still one-per-broker. **Add the ceiling:** if `(open + deferred) >= MAX_OPEN_TABS`, open nothing. New constant `MAX_OPEN_TABS = 15`. -- **New transition `applyDefer(run, itemId)`** — `open → deferred` (only from `open`; never overrides a verdict). Pure, unit-tested. -- **Completion** — a run is done only when **no** `pending`/`open`/`deferred` items remain. Add a helper (e.g. `isComplete(run)` / `progressOf(run)`) so popup + options + sidebar share one definition. `deferred` counts toward `total`, not `done`. -- Untouched: `buildItems`, `withVerdict`, `applySkip`, `applyStop`, `applyMarkSent` (the pure judgments survive). +1. **Wire `progressOf` into `background/index.ts:217`, not just the popup.** The background's `ITEM_INFO` handler still computes progress inline as `done = all verdicted`, `total = run.items.length` — which *includes* `missing:` skips, unlike `progressOf`. When you route the sidebar's progress through `progressOf`, replace the inline math in **both** `background/index.ts:217` **and** `popup/index.ts`. Expect the visible counter to shift (missing-field skips drop out of done/total). That's the intended single definition — just make it deliberate. +2. **The revisit trigger must handle "pending blocked behind a deferred sibling."** Because `selectBatch` claims deferred brokers, a broker with `primary=deferred, aka_0=pending` leaves `aka_0` unopenable while nothing else is open — a state with *both* pending and deferred items and no open tab (reachable today: TruePeopleSearch + one AKA). A naïve `pending.length === 0 && deferred.length > 0` revisit check **misses this** and shows nothing actionable. The §5 focus-drive/revisit logic must route "nothing open, only deferred (or deferred-blocked-pending) remain" → the Waiting/revisit view. Resolving the deferred item unblocks the pending AKA on the next `openNextBatch`. Add a test for it. +3. **Decide what `deferred` does on resume/rehydrate.** `saveRun` strips `tabId`, so a resumed `deferred` item keeps its status but has no live tab — you can't `tabs.update` a tabId that's gone. Per the plan, revisiting a resumed deferred item must open a **fresh** tab from its `renderedUrl`. The §5 background work (and the resume note under Open questions) must handle this explicitly; the pure coordinator doesn't preclude it. -### 5. `src/background/index.ts` (coordination — the real surgery) +### 5. `src/background/index.ts` (coordination — the real surgery) — ✅ DONE (commits `057f878`, `55f7d0a`, `913ba8e`, `83fd2c6`, `46502a4`) - **Thread `windowId`.** `handleStartRun` receives `windowId` (captured at the Start click), stores it in session run state, and `openNextBatch` creates tabs with `browser.tabs.create({ url, active, windowId })`. - **Drive focus.** After a terminal verdict/skip (tab closes) *or* a defer (tab stays open), activate the next `pending`/`open` item's tab (`tabs.update(nextTabId, { active: true })`), opening one if a slot freed. If none remain but deferred exist → push the `revisit` view; if nothing remains → done. - **`SIDEBAR_GET_STATE({ windowId })`** — resolve window → active broker tab → item; return the `ItemInfoMsg` fields (`itemId`, `brokerId`, `exposes`, `guidance`, `renderedUrl`, `progress { done, total, hits }`) **plus** `pageType` (results/details, via the moved `isResultsPage`) and the current view. Adapt `findActiveBrokerTab` into a window-scoped variant that prefers the active tab. - **Push `SIDEBAR_UPDATE`** on: `tabs.onActivated` (focus moved to a tracked broker tab), `tabs.onUpdated` complete (broker tab finished navigating → recompute page-type; keep the existing off-host redirect guard), and challenge messages. + - **Sticky-view contract (from the Slice-3 review).** When focus moves to a **non-broker** tab, **do NOT push** an update — leave the sidebar showing the last broker item. `deriveView` returns `revisit` for *any* unfocused-and-incomplete run (it can't tell "genuinely nothing open" from "user glanced at their email"), so only call it with `focus.item = null` when there is genuinely no open broker tab. Concretely: build `SidebarFocus` from the active tab **only if that tab is a tracked broker tab in the run's window**; otherwise skip the push. This keeps `revisit` meaning its intended thing (deferred/blocked-pending await) instead of firing every time the user looks away mid-run. Add an integration check for "activate a non-broker tab mid-run → sidebar unchanged." - **`DEFER` handler** — `applyDefer`, keep the tab, drive focus, then `openNextBatch` (a slot freed). - **`VERDICT` handler** — unchanged pipeline (`withVerdict` → `selectBatch`); sidebar sends `windowId`, background resolves the broker `tabId` to drop its tracking key + close it. - **`CLOSE_TAB`** — now carries `windowId` (the sidebar is not in the broker tab, so `sender.tab?.id` no longer identifies it); background resolves the tracked tab. -- **Forward challenge** — `CHALLENGE_DETECTED` / `CHALLENGE_RESOLVED` from the content script → push `SIDEBAR_UPDATE`. +- **Forward challenge** — `CHALLENGE_DETECTED` / `CHALLENGE_RESOLVED` from the content script → set/clear a per-tab challenge flag → push `SIDEBAR_UPDATE`. + - **The content script reports challenge state on every load, so the flag is authoritative per-load** (revised in the Slice-5 review). Background clears the flag ONLY on `CHALLENGE_RESOLVED` (or on tab close). It does **not** guess challenge state from `tabs.onUpdated` navigation — that heuristic misfired on an *on-host* Cloudflare interstitial (`broker.com/cdn-cgi/...`): the content script set the flag at `document_end`, then the later `onUpdated complete` cleared it on the same load, so the sidebar showed `verdict` over a "checking your browser" page. The on-host interstitial now reports `CHALLENGE_DETECTED` and stays challenged until the redirect to the real page — a fresh load that reports `CHALLENGE_RESOLVED`. No race, single per-load source of truth. (`tabs.onUpdated` still fires `pushActiveView` to recompute page-type on results→details.) - **Remove:** `reinjectIfMissing`, `REINJECT_OVERLAY` handler, `PING`/`PONG` handler, and the reinject body of `tabs.onUpdated` (repurposed to push updates). Retain `findActiveBrokerTab`'s tab-scan logic (adapted for window→tab resolution). `tabs.onRemoved → skipped/tab_closed` stays (closing a deferred tab = skip, same as an open one). -### 6. `src/content/index.ts` — strip to a headless challenge reporter (~50 lines) +### 6. `src/content/index.ts` — strip to a headless challenge reporter (~50 lines) — ✅ DONE (commits `6bf6270`, `9b33823`) **Remove** (~630 lines): all styles, all panel builders (`buildVerdictPanel` / `buildGuidancePanel` / `buildChallengePanel` / `showMainPanel`), `sendVerdict`, `closeSelfTab`, the `GET_ITEM` call, and the `PING` listener. **Keep / new:** - Reuse the already-extracted, tested **`detectChallenge()`** from `classify.ts`. -- On load: if `detectChallenge()`, send `{ type: 'CHALLENGE_DETECTED' }`. -- `MutationObserver` on `document.documentElement`; when the challenge clears (250 ms debounce, lifted from the old `buildChallengePanel`), send `{ type: 'CHALLENGE_RESOLVED' }`. +- On load, report challenge state **either way** — `{ type: 'CHALLENGE_DETECTED' }` if `detectChallenge()`, else `{ type: 'CHALLENGE_RESOLVED' }`. This makes the content script the single per-load source of truth: an on-host interstitial reports `DETECTED`; its redirect to the real page is a fresh load that reports `RESOLVED`. +- When challenged, also set a `MutationObserver` on `document.documentElement` to catch an **in-page** clear (e.g. Turnstile solved inline, no navigation): 250 ms debounce (lifted from the old `buildChallengePanel`), then send `{ type: 'CHALLENGE_RESOLVED' }`. (Out of scope: a challenge *appearing* after a clean load without a navigation.) - **No UI at all.** The content script never touches the page DOM. **Move** `isResultsPage()` / `brokerHostname()` out of `content/classify.ts` into `src/shared/` (e.g. `src/shared/url.ts`) — the *background* now needs them to classify page-type, and the sidebar/content boundary shouldn't own shared pure helpers. `detectChallenge()` (DOM-dependent) stays in `content/classify.ts`. Move their tests with them. -### 7. Popup + options — remove "Restore overlay", add sidebar-open on Start +### 7. Popup + options — remove "Restore overlay", add sidebar-open on Start — ✅ DONE (commit `8442273`) - Delete the "Restore overlay" button + `REINJECT_OVERLAY` handler from **all four** spots: `src/popup/index.html:28`, `src/popup/index.ts:41`, `src/options/index.html:51`, `src/options/index.ts:~989`. - In the **Start-run click handler** (popup and options), call `browser.sidebarAction.open()` **synchronously in the same tick**, capture `windowId`, then send `START_RUN` with it. Do **not** route the open through background. diff --git a/src/background/coordinator.test.ts b/src/background/coordinator.test.ts index b191d18..b87a521 100644 --- a/src/background/coordinator.test.ts +++ b/src/background/coordinator.test.ts @@ -3,10 +3,16 @@ import { buildItems, withVerdict, applySkip, + applyDefer, + promoteToOpen, applyStop, applyMarkSent, + isComplete, + progressOf, selectBatch, + nextFocusTarget, BATCH_SIZE, + MAX_OPEN_TABS, } from './coordinator'; import { BROKERS } from '../shared/brokers'; import { makeProfile as profile, makeBroker as broker, makeItem as item, makeRun as run } from '../test-support/fixtures'; @@ -108,18 +114,134 @@ describe('applySkip', () => { }); }); +describe('applyDefer', () => { + it('moves an open item to deferred without giving it a verdict', () => { + const r = applyDefer(run([item({ status: 'open' })]), 'b:primary'); + expect(r.items[0].status).toBe('deferred'); + expect(r.items[0].verdict).toBeUndefined(); + }); + + it('only defers from open — a pending item is left untouched', () => { + const pending = item({ status: 'pending' }); + const r = applyDefer(run([pending]), 'b:primary'); + expect(r.items[0]).toEqual(pending); + }); + + it('never overrides an already-verdicted item (no-wedge)', () => { + const verdicted = item({ status: 'verdicted', verdict: 'hit' }); + const r = applyDefer(run([verdicted]), 'b:primary'); + expect(r.items[0]).toEqual(verdicted); + }); + + it('re-deferring an already-deferred item is a no-op', () => { + const deferred = item({ status: 'deferred' }); + const r = applyDefer(run([deferred]), 'b:primary'); + expect(r.items[0]).toEqual(deferred); + }); + + it('leaves other items untouched', () => { + const two = run([item({ id: 'b:primary', status: 'open' }), item({ id: 'b:aka_0', status: 'open' })]); + const r = applyDefer(two, 'b:primary'); + expect(r.items[1]).toEqual(two.items[1]); + }); +}); + +describe('promoteToOpen', () => { + it('moves a deferred item to open (the inverse of applyDefer)', () => { + const r = promoteToOpen(run([item({ status: 'deferred' })]), 'b:primary'); + expect(r.items[0].status).toBe('open'); + }); + + it('no-op on an open item', () => { + const open = item({ status: 'open' }); + expect(promoteToOpen(run([open]), 'b:primary').items[0]).toEqual(open); + }); + + it('no-op on a pending item (must go through ensureItemTab to get a tab)', () => { + const pending = item({ status: 'pending' }); + expect(promoteToOpen(run([pending]), 'b:primary').items[0]).toEqual(pending); + }); + + it('never revives an already-verdicted item', () => { + const verdicted = item({ status: 'verdicted', verdict: 'hit' }); + expect(promoteToOpen(run([verdicted]), 'b:primary').items[0]).toEqual(verdicted); + }); + + it('leaves other items untouched', () => { + const two = run([item({ id: 'b:primary', status: 'deferred' }), item({ id: 'b:aka_0', status: 'deferred' })]); + const r = promoteToOpen(two, 'b:primary'); + expect(r.items[1]).toEqual(two.items[1]); + }); +}); + describe('applyStop', () => { - it('skips pending and open items but leaves verdicted ones alone', () => { + it('skips pending, open, and deferred items but leaves verdicted ones alone', () => { const r = applyStop( run([ item({ id: 'a', status: 'pending' }), item({ id: 'b', status: 'open' }), + item({ id: 'd', status: 'deferred' }), item({ id: 'c', status: 'verdicted', verdict: 'hit' }), ]), ); expect(r.items[0]).toMatchObject({ status: 'verdicted', verdict: 'skipped', skipReason: 'run_stopped' }); expect(r.items[1]).toMatchObject({ status: 'verdicted', verdict: 'skipped', skipReason: 'run_stopped' }); - expect(r.items[2]).toMatchObject({ verdict: 'hit' }); + expect(r.items[2]).toMatchObject({ status: 'verdicted', verdict: 'skipped', skipReason: 'run_stopped' }); + expect(r.items[3]).toMatchObject({ verdict: 'hit' }); + }); +}); + +describe('isComplete', () => { + it('is false while any pending, open, or deferred item remains', () => { + expect(isComplete(run([item({ status: 'pending' })]))).toBe(false); + expect(isComplete(run([item({ status: 'open' })]))).toBe(false); + expect(isComplete(run([item({ status: 'deferred' })]))).toBe(false); + }); + + it('is true only when every item is verdicted', () => { + expect( + isComplete(run([ + item({ id: 'a', status: 'verdicted', verdict: 'clear' }), + item({ id: 'b', status: 'verdicted', verdict: 'skipped', skipReason: 'missing:city' }), + ])), + ).toBe(true); + }); + + it('a lone deferred item keeps the run incomplete (deferred is non-terminal)', () => { + expect( + isComplete(run([ + item({ id: 'a', status: 'verdicted', verdict: 'hit' }), + item({ id: 'b', status: 'deferred' }), + ])), + ).toBe(false); + }); +}); + +describe('progressOf', () => { + it('excludes missing: skips from done and total, but counts them as neither', () => { + const p = progressOf(run([ + item({ id: 'a', status: 'verdicted', verdict: 'clear' }), + item({ id: 'b', status: 'pending' }), + item({ id: 'm', status: 'verdicted', verdict: 'skipped', skipReason: 'missing:city' }), + ])); + expect(p).toEqual({ done: 1, total: 2, hits: 0 }); + }); + + it('counts deferred toward total but not done', () => { + const p = progressOf(run([ + item({ id: 'a', status: 'verdicted', verdict: 'hit' }), + item({ id: 'd', status: 'deferred' }), + ])); + expect(p).toEqual({ done: 1, total: 2, hits: 1 }); + }); + + it('counts hits across all items', () => { + const p = progressOf(run([ + item({ id: 'a', status: 'verdicted', verdict: 'hit' }), + item({ id: 'b', status: 'verdicted', verdict: 'hit' }), + item({ id: 'c', status: 'verdicted', verdict: 'clear' }), + ])); + expect(p.hits).toBe(2); }); }); @@ -183,7 +305,86 @@ describe('selectBatch', () => { expect(updated.items[0].status).toBe('verdicted'); }); - it('BATCH_SIZE default is 5', () => { + it('deferred items free their slot: a deferred broker leaves the full batch window open', () => { + const items = [ + item({ id: 'b1:a', brokerId: 'b1', status: 'deferred' }), + item({ id: 'b2:a', brokerId: 'b2', status: 'pending' }), + item({ id: 'b3:a', brokerId: 'b3', status: 'pending' }), + ]; + // deferred b1 holds a tab but no slot → a batch of 2 still opens both pending brokers + expect(selectBatch(run(items), 2).toOpen.map(i => i.id)).toEqual(['b2:a', 'b3:a']); + }); + + it('claims a deferred item\'s broker: no second variant opens against it', () => { + const items = [ + item({ id: 'b1:a', brokerId: 'b1', status: 'deferred' }), + item({ id: 'b1:b', brokerId: 'b1', status: 'pending' }), + item({ id: 'b2:a', brokerId: 'b2', status: 'pending' }), + ]; + expect(selectBatch(run(items), 5).toOpen.map(i => i.id)).toEqual(['b2:a']); + }); + + it('pauses the batch window when open + deferred reaches the ceiling', () => { + const items = [ + item({ id: 'b1:a', brokerId: 'b1', status: 'deferred' }), + item({ id: 'b2:a', brokerId: 'b2', status: 'deferred' }), + item({ id: 'b3:a', brokerId: 'b3', status: 'deferred' }), + item({ id: 'b4:a', brokerId: 'b4', status: 'pending' }), + ]; + // 3 deferred == ceiling of 3 → open nothing despite an idle batch window + expect(selectBatch(run(items), 5, 3).toOpen).toHaveLength(0); + }); + + it('opens only up to the headroom left below the ceiling, counting open + deferred', () => { + const items = [ + item({ id: 'b1:a', brokerId: 'b1', status: 'open' }), + item({ id: 'b2:a', brokerId: 'b2', status: 'open' }), + item({ id: 'b3:a', brokerId: 'b3', status: 'deferred' }), + item({ id: 'b4:a', brokerId: 'b4', status: 'deferred' }), + item({ id: 'b5:a', brokerId: 'b5', status: 'pending' }), + item({ id: 'b6:a', brokerId: 'b6', status: 'pending' }), + ]; + // heldTabs = 2 open + 2 deferred = 4; ceiling 5 → 1 slot of headroom, batch window has 3. + expect(selectBatch(run(items), 5, 5).toOpen.map(i => i.id)).toEqual(['b5:a']); + }); + + it('applies the default MAX_OPEN_TABS ceiling of 15 when none is passed', () => { + const deferred = Array.from({ length: 15 }, (_, i) => + item({ id: `d${i}`, brokerId: `bd${i}`, status: 'deferred' })); + const items = [...deferred, item({ id: 'p', brokerId: 'bp', status: 'pending' })]; + expect(selectBatch(run(items), 5).toOpen).toHaveLength(0); + }); + + it('BATCH_SIZE default is 5, MAX_OPEN_TABS default is 15', () => { expect(BATCH_SIZE).toBe(5); + expect(MAX_OPEN_TABS).toBe(15); + }); +}); + +describe('nextFocusTarget', () => { + it('returns the first open item id', () => { + const r = run([ + item({ id: 'a', status: 'verdicted', verdict: 'hit' }), + item({ id: 'b', status: 'open' }), + item({ id: 'c', status: 'open' }), + ]); + expect(nextFocusTarget(r)).toBe('b'); + }); + + it('ignores pending — only an open tab is focus-ready (openNextBatch runs first)', () => { + const r = run([item({ id: 'p', status: 'pending' }), item({ id: 'o', status: 'open' })]); + expect(nextFocusTarget(r)).toBe('o'); + }); + + it('null when only deferred + blocked-pending remain → caller shows revisit (finding #2)', () => { + const r = run([ + item({ id: 'b:primary', status: 'deferred' }), + item({ id: 'b:aka_0', nameVariant: 'aka_0', status: 'pending' }), + ]); + expect(nextFocusTarget(r)).toBeNull(); + }); + + it('null when nothing non-terminal remains', () => { + expect(nextFocusTarget(run([item({ status: 'verdicted', verdict: 'clear' })]))).toBeNull(); }); }); diff --git a/src/background/coordinator.ts b/src/background/coordinator.ts index 5ec9559..d0157ec 100644 --- a/src/background/coordinator.ts +++ b/src/background/coordinator.ts @@ -8,6 +8,11 @@ import { normalizeAkas, renderUrl } from '../shared/transforms'; export const BATCH_SIZE = 5; +// Soft ceiling on tabs a run holds open at once (open + deferred). Without it, deferring +// every slow site would open the entire broker list in parallel. The batch window pauses +// here until deferred tabs are cleared at the end of the run. +export const MAX_OPEN_TABS = 15; + // Expand a profile into (broker × name-variant) work items. Variants: primary name // first, then each AKA (first/last frozen on the item so drafts never re-parse the // mutable profile). Missing a required field pre-verdicts the item as a `missing:` skip @@ -95,12 +100,42 @@ export function applySkip(run: RunState, itemId: string, skipReason: SkipReason) }; } -// Stop the run: every still-pending/open item becomes skipped:run_stopped. +// Set an open item aside: open → deferred. Non-terminal — the tab stays open (the caller +// leaves it), but the item frees its batch slot so another broker can open while this one +// finishes loading. Only from 'open': never re-defers, never touches a pending item, and +// never overrides a verdict (no-wedge — a recorded verdict wins over a later defer event). +export function applyDefer(run: RunState, itemId: string): RunState { + return { + ...run, + items: run.items.map(i => + i.id === itemId && i.status === 'open' + ? { ...i, status: 'deferred' as WorkItemStatus } + : i, + ), + }; +} + +// The inverse of applyDefer: bring a set-aside item back, deferred → open, so it rejoins the +// normal verdict/defer flow. Only from 'deferred' — a pending item must instead go through the +// tab-creating path (ensureItemTab); promoteToOpen never conjures an `open` item with no live +// tab. No-op on open/pending/verdicted. Pure; the caller (FOCUS_ITEM) activates the tab. +export function promoteToOpen(run: RunState, itemId: string): RunState { + return { + ...run, + items: run.items.map(i => + i.id === itemId && i.status === 'deferred' + ? { ...i, status: 'open' as WorkItemStatus } + : i, + ), + }; +} + +// Stop the run: every still-pending/open/deferred item becomes skipped:run_stopped. export function applyStop(run: RunState): RunState { return { ...run, items: run.items.map(i => - i.status === 'pending' || i.status === 'open' + i.status === 'pending' || i.status === 'open' || i.status === 'deferred' ? { ...i, status: 'verdicted' as WorkItemStatus, verdict: 'skipped' as Verdict, skipReason: 'run_stopped' as SkipReason } : i, ), @@ -119,19 +154,53 @@ export function applyMarkSent(run: RunState, itemId: string, nowIso: string): Ru }; } +// A run is done only when nothing is still in flight: no pending, open, or deferred items +// remain (deferred is non-terminal — its tab is open and its verdict isn't in yet). Popup, +// options, and the sidebar share this one definition so their "done" states can't drift. +export function isComplete(run: RunState): boolean { + return !run.items.some( + i => i.status === 'pending' || i.status === 'open' || i.status === 'deferred', + ); +} + +// Shared run progress. `done`/`total` exclude pre-verdicted `missing:` skips (the user never +// sees those as work to do); `deferred` counts toward `total` but not `done` (tab open, +// verdict pending). `hits` is over all items — a `missing:` skip is never a hit. Every +// progress readout (popup, options, sidebar, ITEM_INFO) reads from here so they stay in sync. +export function progressOf(run: RunState): { done: number; total: number; hits: number } { + const checkable = run.items.filter( + i => !(typeof i.skipReason === 'string' && i.skipReason.startsWith('missing:')), + ); + return { + done: checkable.filter(i => i.status === 'verdicted').length, + total: checkable.length, + hits: run.items.filter(i => i.verdict === 'hit').length, + }; +} + // Pure batch selection: choose up to (batchSize − openCount) pending items to open, at // most one per broker (counting already-open brokers) so we never hammer one site with -// parallel AKA queries. Returns the items to open plus the run with them flipped to -// 'open'; the caller performs the actual tab creation. +// parallel AKA queries. Only `open` items count against the batch window — `deferred` ones +// freed their slot — but both hold a real tab, so `open + deferred` is capped at +// `maxOpenTabs`: when that ceiling is hit the window pauses (opens nothing) rather than +// letting a defer-everything run open the whole broker list at once. Returns the items to +// open plus the run with them flipped to 'open'; the caller performs the actual tab creation. export function selectBatch( run: RunState, batchSize: number = BATCH_SIZE, + maxOpenTabs: number = MAX_OPEN_TABS, ): { toOpen: WorkItem[]; run: RunState } { const openCount = run.items.filter(i => i.status === 'open').length; - const slots = batchSize - openCount; + const heldTabs = openCount + run.items.filter(i => i.status === 'deferred').length; + // Bound by both the batch window (slots left) and the tab ceiling (headroom left). + const slots = Math.min(batchSize - openCount, maxOpenTabs - heldTabs); if (slots <= 0) return { toOpen: [], run }; - const claimed = new Set(run.items.filter(i => i.status === 'open').map(i => i.brokerId)); + // A deferred tab is still a live tab on that site, so its broker is claimed too — don't + // open a second variant against a broker we already have open or set aside. + const claimed = new Set( + run.items.filter(i => i.status === 'open' || i.status === 'deferred').map(i => i.brokerId), + ); const toOpen: WorkItem[] = []; for (const item of run.items) { if (toOpen.length >= slots) break; @@ -148,3 +217,13 @@ export function selectBatch( }; return { toOpen, run: updated }; } + +// The item to move focus to after an action — the first `open` item (a loaded tab, ready to +// judge now), or null. Callers run selectBatch/openNextBatch FIRST, so any *openable* pending +// is already `open`; a leftover `pending` is blocked behind a deferred/open sibling broker +// (one-per-broker) and must NOT be force-opened (finding #2) — null routes deriveView to the +// revisit view instead. `deferred` is never an auto-focus target (revisit it deliberately). +// Pure; the caller resolves the id to a live tab. +export function nextFocusTarget(run: RunState): string | null { + return run.items.find(i => i.status === 'open')?.id ?? null; +} diff --git a/src/background/index.ts b/src/background/index.ts index 816b8d0..856baf1 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,6 +1,8 @@ import browser from 'webextension-polyfill'; -import type { Profile, RunState, WorkItemStatus, Verdict, SkipReason } from '../shared/types'; -import { getBroker } from '../shared/brokers'; +import type { Profile, RunState, WorkItemStatus, Verdict, SkipReason, SidebarView, SidebarUpdateMsg } from '../shared/types'; +import { BROKERS, getBroker } from '../shared/brokers'; +import { isOnHost, isResultsPage } from '../shared/url'; +import { deriveView, type SidebarFocus } from '../sidebar/state'; import { evaluateGate } from '../shared/gate'; import { buildDraft } from '../shared/templates'; import { @@ -8,9 +10,12 @@ import { buildItems, withVerdict, applySkip, + applyDefer, + promoteToOpen, applyStop, applyMarkSent, selectBatch, + nextFocusTarget, } from './coordinator'; // ── serial write queue ──────────────────────────────────────────────────────── @@ -78,9 +83,9 @@ async function updateBadge(run: RunState): Promise { // ── batch open ─────────────────────────────────────────────────────────────── // Selection is pure (coordinator.selectBatch); this owns only the tab I/O. -async function openNextBatch(run: RunState, focusFirst = false): Promise { +async function openNextBatch(run: RunState, focusFirst = false): Promise { const { toOpen, run: updated } = selectBatch(run, BATCH_SIZE); - if (toOpen.length === 0) return; + if (toOpen.length === 0) return run; await saveRun(updated); @@ -88,44 +93,110 @@ async function openNextBatch(run: RunState, focusFirst = false): Promise { for (const item of toOpen) { const active = focusFirst && first; first = false; - const tab = await browser.tabs.create({ url: item.renderedUrl, active }); + // Pin new broker tabs to the run's window so they share its sidebar (window-level surface). + const tab = await browser.tabs.create({ url: item.renderedUrl, active, windowId: run.windowId }); if (tab.id !== undefined) { await browser.storage.session.set({ [`expurge_tab_${tab.id}`]: item.id }); } } + return updated; } // ── handlers ───────────────────────────────────────────────────────────────── -async function handleStartRun(profile: Profile): Promise { +async function handleStartRun(profile: Profile, windowId?: number): Promise { await saveProfile(profile); return serialWrite(async () => { + // Pin the run to the Start-click's window. §7 wires popup/options to pass windowId + // explicitly (captured synchronously alongside the sidebar open); until then, fall back + // to the sender's window or the last-focused one. + const resolvedWindowId = windowId ?? (await browser.windows.getLastFocused()).id; const runId = crypto.randomUUID(); const items = buildItems(profile); - const run: RunState = { runId, createdAt: new Date().toISOString(), items }; + const run: RunState = { runId, createdAt: new Date().toISOString(), items, windowId: resolvedWindowId }; // Persist before opening tabs so content scripts can find their items on load. await saveRun(run); await updateBadge(run); - await openNextBatch(run, true); + const afterBatch = await openNextBatch(run, true); + // Init-race insurance (Slice-5 review): a sidebar that opened on the Start click may have + // sent SIDEBAR_GET_STATE before the run was saved (→ got no-run). Push the real view now + // that the first batch is open so it corrects itself without waiting for a focus change. + await pushActiveView(afterBatch); }); } -// Verdict from a live broker tab: record it, drop the tab's tracking key, and -// advance the run. -async function handleVerdict(itemId: string, verdict: Verdict, listingUrl?: string, tabId?: number): Promise { +// Verdict from the sidebar (no longer from the broker tab): resolve the item's broker tab, +// capture its listingUrl if on a details page, record, drop tracking, advance focus, then +// close the tab. The tab has no UI to linger for; the sidebar's 800 ms `recorded` animation +// is a pure UI transient (Slice 6) and does not gate this close. +async function handleVerdict(itemId: string, verdict: Verdict, explicitListingUrl?: string): Promise { return serialWrite(async () => { const run = await loadRun(); if (!run) return; + // No-wedge: an already-recorded verdict wins over a later duplicate — a retry of a + // landed-but-ack-lost verdict, or a fast second click (Yes then No) clobbering a recorded + // hit. The message listener still returns {type:'ACK'}, so the retry is idempotent (it + // re-ACKs without re-recording, re-advancing, or re-closing the tab). The guard lives here, + // NOT in withVerdict — handleReverdict deliberately re-verdicts already-verdicted items. + const target = run.items.find(i => i.id === itemId); + if (!target || target.status === 'verdicted') return; + + const brokerTabId = await tabIdForItem(itemId); + const listingUrl = await captureListingUrl(run, itemId, brokerTabId, explicitListingUrl); + const updated = withVerdict(run, itemId, verdict, listingUrl); await saveRun(updated); + await updateBadge(updated); - if (tabId !== undefined) { - await browser.storage.session.remove(`expurge_tab_${tabId}`); + if (brokerTabId !== null) { + await browser.storage.session.remove(`expurge_tab_${brokerTabId}`); } - await updateBadge(updated); - await openNextBatch(updated); + await advance(updated); + + // Close AFTER focus moved, so the browser doesn't auto-activate a random tab in the gap. + // The key is already gone → onRemoved won't re-skip it. + if (brokerTabId !== null) { + await browser.tabs.remove(brokerTabId).catch(() => {}); + } + }); +} + +// Defer from the sidebar: set the active item aside (its tab stays open, untracked-for-focus +// but still tracked), then fill the freed slot and advance focus. +async function handleDefer(itemId: string): Promise { + return serialWrite(async () => { + const run = await loadRun(); + if (!run) return; + const updated = applyDefer(run, itemId); + await saveRun(updated); + await advance(updated); + }); +} + +// Jump to an item on request from the sidebar — a checklist row click, or the revisit button +// (which targets the first deferred item). The sidebar names the item; background activates +// its tab. This is the manual-override path (decision 5): focus any item the user clicks. +async function handleFocusItem(itemId: string, windowId: number): Promise { + return serialWrite(async () => { + const run = await loadRun(); + if (!run || run.windowId !== windowId) return; + const target = run.items.find(i => i.id === itemId); + // Already terminal → nothing to focus (defensive; the sidebar won't make verdicted rows + // clickable). + if (!target || target.status === 'verdicted') return; + + // Reopen a lost tab from renderedUrl (resume / finding #3), flipping a tabless item to + // `open`; then promote a still-alive `deferred` item to `open` so the normal verdict/defer + // flow applies (without this, a re-defer during revisit would no-op on a deferred item). + const { run: run2, tabId } = await ensureItemTab(run, itemId); + if (tabId === null) return; + const promoted = promoteToOpen(run2, itemId); + await saveRun(promoted); + + await browser.tabs.update(tabId, { active: true }).catch(() => {}); + await pushView(windowId, deriveView(promoted, await focusForTab(tabId, promoted), BROKERS)); }); } @@ -160,7 +231,7 @@ async function handleSkip(itemId: string, skipReason: SkipReason, tabId?: number await browser.storage.session.remove(`expurge_tab_${tabId}`); } - await openNextBatch(updated); + await advance(updated); }); } @@ -179,6 +250,13 @@ async function handleStopRun(): Promise { if (tabKeys.length > 0) { await browser.storage.session.remove(tabKeys); } + + // Stop can come from the popup/options, not the sidebar — so push the resting view or the + // sidebar keeps showing live verdict/guidance controls for a run that's over. A stopped run + // isComplete, so deriveView yields `done` (deriveView, not a hardcoded view — one source). + if (updated.windowId !== undefined) { + await pushView(updated.windowId, deriveView(updated, null, BROKERS)); + } }); } @@ -188,6 +266,178 @@ async function itemIdForTab(tabId: number): Promise { return (r[key] as string) ?? null; } +// ── per-tab challenge flag ───────────────────────────────────────────────────── +// Set by CHALLENGE_DETECTED, cleared by CHALLENGE_RESOLVED / tab close / on-host nav. +// Stored in session storage (keyed by tabId) so it survives event-page spindown mid- +// challenge — an in-memory Map would lose it. Feeds SidebarFocus.challenge → deriveView. + +const challengeKey = (tabId: number) => `expurge_challenge_${tabId}`; + +async function setChallengeFlag(tabId: number, on: boolean): Promise { + if (on) await browser.storage.session.set({ [challengeKey(tabId)]: true }); + else await browser.storage.session.remove(challengeKey(tabId)); +} + +async function isChallenged(tabId: number): Promise { + const key = challengeKey(tabId); + const r = await browser.storage.session.get(key); + return r[key] === true; +} + +// ── focus resolution (SidebarFocus builders) ─────────────────────────────────── +// deriveView (sidebar/state.ts) is the single source of view truth; the background only +// BUILDS its inputs. A SidebarFocus pairs the focused broker tab's work item with its URL +// (results-vs-details) and challenge flag; null focus → deriveView yields revisit/done/no-run. + +async function focusForTab(tabId: number, run: RunState): Promise { + const itemId = await itemIdForTab(tabId); + if (!itemId) return null; // not a tracked broker tab + const item = run.items.find(i => i.id === itemId) ?? null; + if (!item) return null; + const tab = await browser.tabs.get(tabId).catch(() => null); + return { item, tabUrl: tab?.url ?? null, challenge: await isChallenged(tabId) }; +} + +// The window's broker tab to reflect: prefer the active tab if it's tracked, else any tracked +// broker tab in the window (prefer on-host; keep a mid-redirect off-host tab as a fallback so +// the challenges.cloudflare.com hop doesn't make us open a duplicate). Prunes stale tab keys. +// Retains the old findActiveBrokerTab scan, scoped to one window and active-preferring. +async function findWindowBrokerTab(windowId: number, run: RunState): Promise { + const [active] = await browser.tabs.query({ windowId, active: true }); + if (active?.id !== undefined && await itemIdForTab(active.id)) return active.id; + + const all = await browser.storage.session.get(null) as Record; + let fallbackTabId: number | null = null; + for (const key of Object.keys(all)) { + if (!key.startsWith('expurge_tab_')) continue; + const tabId = parseInt(key.slice('expurge_tab_'.length), 10); + if (isNaN(tabId)) continue; + let tab: browser.Tabs.Tab; + try { tab = await browser.tabs.get(tabId); } + catch { await browser.storage.session.remove(key); continue; } // stale — tab closed + if (tab.windowId !== windowId) continue; + const item = run.items.find(i => i.id === (all[key] as string)); + if (item && tab.url && !isOnHost(tab.url, item.renderedUrl)) { + if (fallbackTabId === null) fallbackTabId = tabId; // mid-redirect, don't prune + continue; + } + return tabId; + } + return fallbackTabId; +} + +// PULL focus (SIDEBAR_GET_STATE): the window's broker tab, active-preferred with fallback. +async function buildFocus(windowId: number, run: RunState): Promise { + const tabId = await findWindowBrokerTab(windowId, run); + return tabId === null ? null : focusForTab(tabId, run); +} + +// PUSH focus: the window's ACTIVE tab only. Returns null when the active tab isn't a broker +// tab — the sticky-view contract: a glance at another tab must not flip the sidebar. +async function activeBrokerFocus(windowId: number, run: RunState): Promise { + const [active] = await browser.tabs.query({ windowId, active: true }); + if (active?.id === undefined) return null; + return focusForTab(active.id, run); +} + +// ── sidebar push ─────────────────────────────────────────────────────────────── + +async function pushView(windowId: number, view: SidebarView): Promise { + const msg: SidebarUpdateMsg = { type: 'SIDEBAR_UPDATE', windowId, view }; + // No sidebar listening (not yet built, or window has none) is fine — swallow the reject. + await browser.runtime.sendMessage(msg).catch(() => {}); +} + +// Push the view for the run window's ACTIVE broker tab. Honors the sticky-view contract: +// if the active tab isn't a broker tab, leave the sidebar showing its last broker item. +async function pushActiveView(run: RunState): Promise { + if (run.windowId === undefined) return; + const focus = await activeBrokerFocus(run.windowId, run); + if (!focus) return; + await pushView(run.windowId, deriveView(run, focus, BROKERS)); +} + +// ── focus driving ────────────────────────────────────────────────────────────── +// After any action, move focus to the next actionable item and push the resulting view. + +// The live tab tracking an item, or null (reverse of the expurge_tab_ → itemId map). +async function tabIdForItem(itemId: string): Promise { + const all = await browser.storage.session.get(null) as Record; + for (const key of Object.keys(all)) { + if (key.startsWith('expurge_tab_') && all[key] === itemId) { + const tabId = parseInt(key.slice('expurge_tab_'.length), 10); + if (!isNaN(tabId)) return tabId; + } + } + return null; +} + +// Ensure an item has a live tab; open a fresh one from its renderedUrl if not. Covers a +// resumed `deferred`/`open` item whose tab was lost (finding #3) and a pending item being +// promoted. Returns the (possibly updated) run and the tabId. +async function ensureItemTab(run: RunState, itemId: string): Promise<{ run: RunState; tabId: number | null }> { + const existing = await tabIdForItem(itemId); + if (existing !== null) return { run, tabId: existing }; + + const item = run.items.find(i => i.id === itemId); + if (!item) return { run, tabId: null }; + + const tab = await browser.tabs.create({ url: item.renderedUrl, active: true, windowId: run.windowId }); + if (tab.id === undefined) return { run, tabId: null }; + await browser.storage.session.set({ [`expurge_tab_${tab.id}`]: item.id }); + // It now has a live tab → it's open. + const updated: RunState = { + ...run, + items: run.items.map(i => (i.id === itemId ? { ...i, status: 'open' as WorkItemStatus } : i)), + }; + await saveRun(updated); + return { run: updated, tabId: tab.id }; +} + +// Move focus to the next actionable item (nextFocusTarget) and push its view; if none → +// push the focus=null view (revisit while deferred/blocked-pending remain, else done). +async function driveFocus(run: RunState): Promise { + const windowId = run.windowId; + if (windowId === undefined) return; + + const targetId = nextFocusTarget(run); + if (targetId === null) { + await pushView(windowId, deriveView(run, null, BROKERS)); + return; + } + + const { run: run2, tabId } = await ensureItemTab(run, targetId); + if (tabId !== null) await browser.tabs.update(tabId, { active: true }).catch(() => {}); + const focus = tabId !== null ? await focusForTab(tabId, run2) : null; + await pushView(windowId, deriveView(run2, focus, BROKERS)); +} + +// The standard post-action advance: fill the freed batch slot, then drive focus. +async function advance(run: RunState): Promise { + const afterBatch = await openNextBatch(run); + await driveFocus(afterBatch); +} + +// listingUrl for a verdict: an explicit one from the sidebar (paste-URL fallback) wins; else, +// for a details-page verdict, capture the broker tab's own current URL (the sidebar can't +// self-identify the broker tab). Results-page verdicts (e.g. "not found" → clear) carry none. +async function captureListingUrl( + run: RunState, + itemId: string, + brokerTabId: number | null, + explicit?: string, +): Promise { + if (explicit !== undefined) return explicit; + if (brokerTabId === null) return undefined; + const item = run.items.find(i => i.id === itemId); + const tab = await browser.tabs.get(brokerTabId).catch(() => null); + if (!item || !tab?.url) return undefined; + try { + if (!isResultsPage(new URL(tab.url).pathname, item.renderedUrl)) return tab.url; + } catch { /* malformed — no listingUrl */ } + return undefined; +} + // ── message listener ───────────────────────────────────────────────────────── browser.runtime.onMessage.addListener( @@ -195,7 +445,10 @@ browser.runtime.onMessage.addListener( const m = msg as Record; if (m.type === 'START_RUN') { - await handleStartRun(m.profile as Profile); + // Prefer the window the sidebar was opened in (passed explicitly by popup/options in §7), + // else the message sender's window. + const windowId = (m.windowId as number | undefined) ?? sender.tab?.windowId; + await handleStartRun(m.profile as Profile, windowId); return { ok: true }; } @@ -204,39 +457,65 @@ browser.runtime.onMessage.addListener( return { run }; } - if (m.type === 'GET_ITEM') { - const tabId = sender.tab?.id; - if (tabId === undefined) return null; - const itemId = await itemIdForTab(tabId); - if (!itemId) return null; + // PULL: the sidebar asks for its window's current view on load. + if (m.type === 'SIDEBAR_GET_STATE') { + const windowId = m.windowId as number; const run = await loadRun(); - if (!run) return null; - const item = run.items.find(i => i.id === itemId); - if (!item) return null; - const broker = getBroker(item.brokerId); - const done = run.items.filter(i => i.status === 'verdicted').length; - const hits = run.items.filter(i => i.verdict === 'hit').length; - return { - type: 'ITEM_INFO', - itemId: item.id, - brokerId: item.brokerId, - exposes: broker?.search.exposes ?? [], - renderedUrl: item.renderedUrl, - progress: { done, total: run.items.length, hits }, - }; + // A sidebar in a window without the run (idle window, or run pinned elsewhere) → no-run. + if (!run || run.windowId !== windowId) { + return { type: 'SIDEBAR_UPDATE', windowId, view: { view: 'no-run' } } satisfies SidebarUpdateMsg; + } + const focus = await buildFocus(windowId, run); + return { type: 'SIDEBAR_UPDATE', windowId, view: deriveView(run, focus, BROKERS) } satisfies SidebarUpdateMsg; } - if (m.type === 'VERDICT') { + // A broker tab's content script reports a bot-challenge appearing / clearing (in-page). + // Set/clear the per-tab flag and refresh the sidebar if that tab is the active one. + if (m.type === 'CHALLENGE_DETECTED' || m.type === 'CHALLENGE_RESOLVED') { const tabId = sender.tab?.id; + if (tabId !== undefined) { + await setChallengeFlag(tabId, m.type === 'CHALLENGE_DETECTED'); + const run = await loadRun(); + if (run) await pushActiveView(run); + } + return { ok: true }; + } + + if (m.type === 'VERDICT') { + // Sent by the sidebar now — sender.tab is the sidebar, so background resolves the broker + // tab from the item id. listingUrl is set only for the paste-URL fallback. await handleVerdict( m.itemId as string, m.verdict as Verdict, m.listingUrl as string | undefined, - tabId, ); return { type: 'ACK', itemId: m.itemId }; } + if (m.type === 'DEFER') { + await handleDefer(m.itemId as string); + return { ok: true }; + } + + if (m.type === 'FOCUS_ITEM') { + await handleFocusItem(m.itemId as string, m.windowId as number); + return { ok: true }; + } + + if (m.type === 'NAVIGATE_BROKER_TAB') { + // Paste-URL fallback: point the PASTED ITEM's own broker tab at the listing (via + // tabIdForItem, not the active-preferred findWindowBrokerTab — the active tab may have + // changed since the guidance view rendered, so the paste can't land in the wrong tab). + // The ensuing onUpdated recomputes page-type (results → details) and pushes verdict. + const windowId = m.windowId as number; + const run = await loadRun(); + if (run && run.windowId === windowId) { + const tabId = await tabIdForItem(m.itemId as string); + if (tabId !== null) await browser.tabs.update(tabId, { url: m.url as string }).catch(() => {}); + } + return { ok: true }; + } + if (m.type === 'REVERDICT') { await handleReverdict( m.itemId as string, @@ -251,47 +530,6 @@ browser.runtime.onMessage.addListener( return { ok: true }; } - if (m.type === 'PING') { - return { type: 'PONG', hasOverlay: false }; - } - - if (m.type === 'REINJECT_OVERLAY') { - const existingTabId = await findActiveBrokerTab(); - if (existingTabId !== null) { - try { - await browser.tabs.update(existingTabId, { active: true }); - await reinjectIfMissing(existingTabId); - return { ok: true }; - } catch { - await browser.storage.session.remove(`expurge_tab_${existingTabId}`); - // Fall through to open-next-item logic. - } - } - - // No live broker tab (or tab closed between find and update) — open the next item. - const run = await loadRun(); - if (!run) return { ok: false }; - const item = - run.items.find(i => i.status === 'pending') ?? - run.items.find(i => i.status === 'open'); - if (!item) return { ok: false }; - - const tab = await browser.tabs.create({ url: item.renderedUrl, active: true }); - if (tab.id !== undefined) { - await browser.storage.session.set({ [`expurge_tab_${tab.id}`]: item.id }); - if (item.status === 'pending') { - const updated: RunState = { - ...run, - items: run.items.map(i => - i.id === item.id ? { ...i, status: 'open' as WorkItemStatus } : i - ), - }; - await saveRun(updated); - } - } - return { ok: true }; - } - if (m.type === 'GET_DRAFT') { const run = await loadRun(); const profile = await loadProfile(); @@ -338,18 +576,26 @@ browser.runtime.onMessage.addListener( } if (m.type === 'CLOSE_TAB') { - const tabId = sender.tab?.id; - if (tabId !== undefined) { - await browser.tabs.remove(tabId).catch(() => {}); + // Vestigial: background now closes verdicted tabs itself (handleVerdict). Kept for + // completeness — if the sidebar ever asks, close the window's current broker tab. + const windowId = m.windowId as number | undefined; + const run = await loadRun(); + if (windowId !== undefined && run) { + const tabId = await findWindowBrokerTab(windowId, run); + if (tabId !== null) await browser.tabs.remove(tabId).catch(() => {}); } return { ok: true }; } if (m.type === 'DELETE_ALL') { + // Capture the run's window before wiping session storage so we can send the sidebar back + // to no-run (delete-all can come from the options page, not the sidebar). + const wid = (await loadRun())?.windowId; await serialWrite(async () => { await browser.storage.session.clear(); }); await browser.storage.local.clear(); + if (wid !== undefined) await pushView(wid, { view: 'no-run' }); return { ok: true }; } @@ -360,75 +606,20 @@ browser.runtime.onMessage.addListener( // ── tab closed → skipped/tab_closed ───────────────────────────────────────── browser.tabs.onRemoved.addListener(async (tabId: number) => { + await setChallengeFlag(tabId, false); // drop any challenge flag for the now-gone tab const itemId = await itemIdForTab(tabId); if (!itemId) return; await handleSkip(itemId, 'tab_closed', tabId); }); - -// ── overlay re-injection ───────────────────────────────────────────────────── - -async function findActiveBrokerTab(): Promise { - const [all, run] = await Promise.all([ - browser.storage.session.get(null) as Promise>, - loadRun(), - ]); - // A tab temporarily at a Cloudflare (or other challenge-provider) redirect URL will have a - // hostname that doesn't match the broker. We don't prune it — it may redirect back shortly. - // Keep it as a fallback so "Restore Overlay" focuses the existing tab rather than opening a - // fresh one that would trigger a new Cloudflare session. - let fallbackTabId: number | null = null; - for (const key of Object.keys(all)) { - if (!key.startsWith('expurge_tab_')) continue; - const tabId = parseInt(key.slice('expurge_tab_'.length), 10); - if (isNaN(tabId)) continue; - try { - const tab = await browser.tabs.get(tabId); - if (run && tab.url) { - const itemId = all[key] as string; - const item = run.items.find(i => i.id === itemId); - if (item) { - try { - const brokerHost = new URL(item.renderedUrl).hostname; - const tabHost = new URL(tab.url).hostname; - if (tabHost !== brokerHost && !tabHost.endsWith('.' + brokerHost)) { - if (fallbackTabId === null) fallbackTabId = tabId; // mid-redirect, don't prune - continue; - } - } catch { - await browser.storage.session.remove(key); // URL parse failed - continue; - } - } - } - return tabId; - } catch { - await browser.storage.session.remove(key); // stale — tab was closed - } - } - return fallbackTabId; -} - -async function reinjectIfMissing(tabId: number): Promise { - const TIMEOUT_MS = 2_000; - try { - const pong = await Promise.race([ - browser.tabs.sendMessage(tabId, { type: 'PING' }), - new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), TIMEOUT_MS)), - ]) as { type?: string; hasOverlay?: boolean } | null; - - if (pong?.hasOverlay) return; // overlay present — nothing to do - // Content script alive but overlay missing — fall through to inject - } catch { - // PING timed out or content script not running — inject - } - - try { - await browser.scripting.executeScript({ target: { tabId }, files: ['dist/content.js'] }); - } catch { - // Tab may be on a restricted URL or closed — ignore - } -} +// Focus moved within the run's window → refresh the sidebar for the newly-active tab. +// Sticky-view contract: pushActiveView is a no-op when that tab isn't a broker tab, so a +// glance at another tab leaves the sidebar on its last broker item. +browser.tabs.onActivated.addListener(async ({ windowId }) => { + const run = await loadRun(); + if (!run || run.windowId !== windowId) return; + await pushActiveView(run); +}); // ── first install → open options page ──────────────────────────────────────── @@ -439,20 +630,15 @@ browser.runtime.onInstalled.addListener(({ reason }) => { browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => { if (changeInfo.status !== 'complete') return; const itemId = await itemIdForTab(tabId); - if (!itemId) return; + if (!itemId) return; // not a tracked broker tab - // Skip reinject for off-host pages (e.g. challenges.cloudflare.com during redirects). - // On-host CDN paths (broker.com/cdn-cgi/...) still reach executeScript which ignores them. const run = await loadRun(); - const item = run?.items.find(i => i.id === itemId); - if (item?.renderedUrl) { - try { - const tab = await browser.tabs.get(tabId); - const brokerHost = new URL(item.renderedUrl).hostname; - const tabHost = new URL(tab.url ?? '').hostname; - if (tabHost !== brokerHost && !tabHost.endsWith('.' + brokerHost)) return; - } catch { /* malformed URL — fall through to reinject */ } - } - - await reinjectIfMissing(tabId); + if (!run) return; + + // Broker tab finished navigating (e.g. results → details, or a challenge redirect landing + // back on the real page) → recompute the active tab's page-type and push. The challenge flag + // is the content script's job now: it reports RESOLVED on the clean load, so background does + // NOT guess challenge state from navigation here (that misfired on on-host challenge pages, + // clearing the flag the content script had just set on the same load). + await pushActiveView(run); }); diff --git a/src/content/classify.test.ts b/src/content/classify.test.ts index bbfea41..f85fc69 100644 --- a/src/content/classify.test.ts +++ b/src/content/classify.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, beforeEach } from 'vitest'; -import { detectChallenge, isResultsPage, brokerHostname } from './classify'; +import { detectChallenge } from './classify'; beforeEach(() => { document.body.innerHTML = ''; @@ -61,29 +61,3 @@ describe('detectChallenge', () => { expect(detectChallenge()).toBe(true); }); }); - -describe('isResultsPage', () => { - it('matching pathname → true (results page)', () => { - expect(isResultsPage('/results', 'https://b.com/results?name=x')).toBe(true); - }); - - it('different pathname → false (details page)', () => { - expect(isResultsPage('/person/123', 'https://b.com/results?name=x')).toBe(false); - }); - - it('malformed rendered URL → false', () => { - expect(isResultsPage('/results', 'not a url')).toBe(false); - }); -}); - -describe('brokerHostname', () => { - it('returns the hostname of the rendered URL', () => { - expect(brokerHostname('https://www.truepeoplesearch.com/results?x=1')).toBe( - 'www.truepeoplesearch.com', - ); - }); - - it('malformed URL → empty string', () => { - expect(brokerHostname('::::')).toBe(''); - }); -}); diff --git a/src/content/classify.ts b/src/content/classify.ts index c42c36f..0fc7799 100644 --- a/src/content/classify.ts +++ b/src/content/classify.ts @@ -32,24 +32,3 @@ export function detectChallenge(): boolean { 'iframe[src*="geo.captcha-delivery.com"]', ].some((sel) => document.querySelector(sel) !== null); } - -// The rendered search URL points at a results listing. If the current page's pathname -// matches, we're on the results page (show guidance); otherwise it's a details page (show -// verdict buttons). Takes the pathname directly (callers pass window.location.pathname, -// which never throws); only the rendered URL is parsed, and a malformed one → not-results. -export function isResultsPage(currentPathname: string, renderedUrl: string): boolean { - try { - return currentPathname === new URL(renderedUrl).pathname; - } catch { - return false; - } -} - -// The broker's hostname from its rendered search URL, or '' if it can't be parsed. -export function brokerHostname(renderedUrl: string): string { - try { - return new URL(renderedUrl).hostname; - } catch { - return ''; - } -} diff --git a/src/content/index.ts b/src/content/index.ts index 5b7a857..db87814 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,573 +1,29 @@ import browser from 'webextension-polyfill'; -import type { Verdict, ItemInfoMsg } from '../shared/types'; -import { detectChallenge, isResultsPage, brokerHostname } from './classify'; - -// ── Shadow DOM overlay ─────────────────────────────────────────────────────── -// The overlay NEVER injects the user's profile data into the page DOM. -// It shows only the broker's generic exposes[] list ("full name", "age", etc.) -// so page scripts cannot read the user's actual values from the DOM. - -const OVERLAY_STYLES = ` -:host { - position: fixed; - bottom: 20px; - right: 20px; - z-index: 2147483647; - - /* ── tokens: light ── */ - --surface: #FBF6EE; - --fill: #ECE3D4; - --border: #ECE3D4; - --text: #211D18; - --text-muted: #6B6053; - --text-faint: #A99B8A; - --primary: #2C5446; - --primary-hover: #244839; - --on-primary: #FBF6EE; - --accent: #B25C3C; - --strip-bg: #2C5446; - --strip-ko: #FBF6EE; - --focus-shadow: rgba(44,84,70,0.14); - - --font-display: "Newsreader", Georgia, serif; - --font-ui: "Hanken Grotesk", system-ui, sans-serif; - --font-mono: "IBM Plex Mono", ui-monospace, monospace; -} - -@media (prefers-color-scheme: dark) { - :host { - --surface: #2A2620; - --fill: #2E2A24; - --border: #3A342C; - --text: #FBF6EE; - --text-muted: #C9C2B6; - --text-faint: #9C9388; - --primary: #7FB89C; - --primary-hover: #93C7AC; - --on-primary: #211D18; - --accent: #C9744E; - --strip-bg: #7FB89C; - --strip-ko: #211D18; - --focus-shadow: rgba(127,184,156,0.2); - } -} - -/* ── card ── */ - -.panel { - background: var(--surface); - border-radius: 14px; - width: 292px; - overflow: hidden; - box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.10); - font-family: var(--font-ui); - font-size: 14px; - line-height: 1.5; - color: var(--text); -} - -/* ── top strip (brand identifier) ── */ - -.strip { - background: var(--strip-bg); - padding: 6px 14px 5px; - border-bottom: 1.5px dashed var(--surface); - display: flex; - align-items: center; - justify-content: space-between; -} - -.wordmark { - font-family: var(--font-display); - font-weight: 600; - font-size: 15px; - letter-spacing: -0.01em; - color: var(--strip-ko); - line-height: 1; -} - -.progress { - font-family: var(--font-mono); - font-size: 10px; - color: var(--strip-ko); - opacity: 0.7; - letter-spacing: 0.05em; - line-height: 1; -} - -/* ── body ── */ - -.body { - padding: 12px 14px 14px; -} - -/* ── labels ── */ - -.label { - font-family: var(--font-mono); - font-size: 10px; - font-weight: 400; - text-transform: uppercase; - letter-spacing: 0.14em; - color: var(--text-muted); - margin-bottom: 7px; -} - -/* ── exposes chips ── */ - -.exposes { - list-style: none; - padding: 0; - margin: 0 0 12px; - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.exposes li { - background: var(--fill); - color: var(--text-muted); - border-radius: 9999px; - padding: 2px 9px; - font-size: 12px; - line-height: 1.5; -} - -/* ── question ── */ - -.question { - font-size: 13px; - color: var(--text-muted); - margin: 0 0 12px; - line-height: 1.5; -} - -/* ── buttons ── */ - -.buttons { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 6px; -} - -.btn { - padding: 9px 6px; - border-radius: 9px; - font-family: var(--font-ui); - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: background 0.12s; - min-height: 44px; -} -.btn:disabled { opacity: 0.4; cursor: not-allowed; } - -.btn-hit { - background: var(--primary); - color: var(--on-primary); - border: none; -} -.btn-hit:not(:disabled):hover { background: var(--primary-hover); } - -.btn-clear { - background: transparent; - color: var(--primary); - border: 1.5px solid var(--primary); -} -.btn-clear:not(:disabled):hover { background: var(--fill); } - -.btn-unknown { - background: transparent; - color: var(--primary); - border: none; -} -.btn-unknown:not(:disabled):hover { background: var(--fill); } - -.btn-skip { - background: transparent; - color: var(--text-faint); - border: none; - font-weight: 400; -} -.btn-skip:not(:disabled):hover { background: var(--fill); } - -/* ── status ── */ - -.status { - margin-top: 10px; - font-size: 12px; - min-height: 18px; - color: var(--text-faint); - text-align: center; -} - -@keyframes spin { to { transform: rotate(360deg); } } - -.status.saving { - color: var(--text-muted); - display: flex; - align-items: center; - justify-content: center; - gap: 7px; -} -.status.saving::before { - content: ""; - flex-shrink: 0; - width: 10px; - height: 10px; - border: 1.5px solid var(--fill); - border-top-color: var(--primary); - border-radius: 50%; - animation: spin 0.7s linear infinite; -} - -.status.recorded { color: var(--primary); font-weight: 600; } - -/* ── guidance panel ── */ - -.guidance-msg { - font-size: 13px; - color: var(--text-muted); - margin: 0 0 10px; - line-height: 1.5; -} - -.toggle-link { - font-family: var(--font-ui); - font-size: 12px; - color: var(--text-faint); - cursor: pointer; - background: none; - border: none; - padding: 0; - text-decoration: underline; - display: block; - margin-top: 4px; -} -.toggle-link:hover { color: var(--text-muted); } - -.paste-section { margin-top: 10px; } - -.paste-input { - width: 100%; - box-sizing: border-box; - padding: 8px 10px; - background: var(--fill); - border: 1.5px solid var(--border); - border-radius: 9px; - color: var(--text); - font-family: var(--font-ui); - font-size: 12px; - outline: none; - margin-bottom: 6px; -} -.paste-input:focus { - border-color: var(--primary); - box-shadow: 0 0 0 3px var(--focus-shadow); -} - -.paste-warning { - font-size: 11px; - color: var(--accent); - margin-bottom: 6px; - display: none; -} -.paste-warning.visible { display: block; } -`; - -// ── shared overlay scaffold ─────────────────────────────────────────────────── - -interface OverlayShell { host: HTMLElement; panel: HTMLDivElement; } - -function createOverlayShell(): OverlayShell { - const host = document.createElement('div'); - host.id = 'expurge-host'; - const shadow = host.attachShadow({ mode: 'open' }); - const style = document.createElement('style'); - style.textContent = OVERLAY_STYLES; - const panel = document.createElement('div'); - panel.className = 'panel'; - shadow.appendChild(style); - shadow.appendChild(panel); - return { host, panel }; -} - -type OverlayState = 'unjudged' | 'saving' | 'recorded'; - -interface OverlayRefs { - buttons: HTMLElement; - btnHit: HTMLButtonElement; - btnClear: HTMLButtonElement; - btnUnknown: HTMLButtonElement; - btnSkip: HTMLButtonElement; - status: HTMLElement; -} - -function setOverlayState(refs: OverlayRefs, state: OverlayState, label = ''): void { - const disabled = state !== 'unjudged'; - refs.btnHit.disabled = disabled; - refs.btnClear.disabled = disabled; - refs.btnUnknown.disabled = disabled; - refs.btnSkip.disabled = disabled; - - refs.status.className = `status ${state === 'unjudged' ? '' : state}`; - refs.status.textContent = - state === 'saving' ? 'Saving your answer…' : - state === 'recorded' ? `✓ ${label}` : - ''; -} - -// ── verdict panel (details / profile page) ─────────────────────────────────── - -function buildVerdictPanel( - exposes: string[], - progress: { done: number; total: number } | null, -): { host: HTMLElement; refs: OverlayRefs } { - const { host, panel } = createOverlayShell(); - - const progressText = progress ? `${progress.done} / ${progress.total}` : ''; - - panel.innerHTML = ` -
- expurge - ${progressText} -
-
-
Look for
-
    -

    Could this listing be you?

    -
    - - - - -
    -
    -
    - `; - - const list = panel.querySelector('#exp-list')!; - for (const item of exposes) { - const li = document.createElement('li'); - li.textContent = item; - list.appendChild(li); - } - - const refs: OverlayRefs = { - buttons: panel.querySelector('#verdict-btns') as HTMLElement, - btnHit: panel.querySelector('#btn-hit') as HTMLButtonElement, - btnClear: panel.querySelector('#btn-clear') as HTMLButtonElement, - btnUnknown: panel.querySelector('#btn-unknown') as HTMLButtonElement, - btnSkip: panel.querySelector('#btn-skip') as HTMLButtonElement, - status: panel.querySelector('#overlay-status') as HTMLElement, - }; - - return { host, refs }; -} - -// ── guidance panel (results page) ──────────────────────────────────────────── - -function buildGuidancePanel( - exposes: string[], - hostname: string, - progress: { done: number; total: number } | null, - onVerdict: (verdict: Verdict, listingUrl: string) => void, -): HTMLElement { - const { host, panel } = createOverlayShell(); - - const progressText = progress ? `${progress.done} / ${progress.total}` : ''; - - panel.innerHTML = ` -
    - expurge - ${progressText} -
    -
    -
    Look for
    -
      -

      - Found yourself? Click View Details → on your listing, - then confirm on that page. -

      - - -
      -
      - `; - - // Populate exposes with textContent — same pattern as buildVerdictPanel. - const exposesList = panel.querySelector('#exp-list')!; - for (const item of exposes) { - const li = document.createElement('li'); - li.textContent = item; - exposesList.appendChild(li); - } - - const toggleBtn = panel.querySelector('#toggle-paste') as HTMLButtonElement; - const pasteSection = panel.querySelector('#paste-section') as HTMLElement; - const pasteInput = panel.querySelector('#paste-input') as HTMLInputElement; - const pasteWarn = panel.querySelector('#paste-warning') as HTMLElement; - const pasteBtns = panel.querySelector('#paste-btns') as HTMLElement; - const statusEl = panel.querySelector('#overlay-status') as HTMLElement; - - toggleBtn.addEventListener('click', () => { - pasteSection.style.display = pasteSection.style.display === 'none' ? 'block' : 'none'; - toggleBtn.textContent = pasteSection.style.display === 'none' - ? 'Can\'t reach the details page? →' - : 'Can\'t reach the details page? ↓'; - }); - - pasteInput.addEventListener('input', () => { - const val = pasteInput.value.trim(); - if (!val) { - pasteBtns.style.display = 'none'; - pasteWarn.classList.remove('visible'); - return; - } - - // Show verdict buttons as soon as field is non-empty. - pasteBtns.style.display = 'grid'; - - // Same-domain check — warning only, never blocks. - try { - const parsed = new URL(val); - const matches = - parsed.hostname === hostname || - parsed.hostname.endsWith('.' + hostname); - pasteWarn.classList.toggle('visible', !matches); - } catch { - pasteWarn.classList.add('visible'); - } - }); - - const castFromPaste = async (verdict: Verdict) => { - const listingUrl = pasteInput.value.trim(); - pasteBtns.querySelectorAll('button').forEach(b => { (b as HTMLButtonElement).disabled = true; }); - statusEl.className = 'status saving'; - try { - await onVerdict(verdict, listingUrl); - } catch { - statusEl.className = 'status'; - statusEl.textContent = 'Save failed — try again.'; - pasteBtns.querySelectorAll('button').forEach(b => { (b as HTMLButtonElement).disabled = false; }); - } - }; - - panel.querySelector('#btn-hit')!.addEventListener('click', () => void castFromPaste('hit')); - panel.querySelector('#btn-clear')!.addEventListener('click', () => void castFromPaste('clear')); - panel.querySelector('#btn-unknown')!.addEventListener('click', () => void castFromPaste('unknown')); - panel.querySelector('#btn-skip')!.addEventListener('click', () => void castFromPaste('skipped')); - - return host; -} - -// ── post-verdict status copy ───────────────────────────────────────────────── - -function verdictMsg(verdict: Verdict, ok: boolean): string { - if (verdict === 'hit') { - return ok - ? '✓ Listed — open expurge to send your opt-out request.' - : '✓ Listed — saved locally; open expurge to send your opt-out request.'; - } - if (verdict === 'clear') return '✓ Not listed.'; - if (verdict === 'unknown') return '✓ Not sure — open expurge to continue.'; - return '✓ Skipped.'; -} - -// ── verdict send + ack with retry ──────────────────────────────────────────── - -async function sendVerdict( - itemId: string, - verdict: Verdict, - listingUrl: string, - attempt = 0, -): Promise { - const TIMEOUT_MS = 6_000; - const MAX_ATTEMPTS = 3; - - try { - const race = await Promise.race([ - browser.runtime.sendMessage({ type: 'VERDICT', itemId, verdict, listingUrl }), - new Promise((_, rej) => - setTimeout(() => rej(new Error('timeout')), TIMEOUT_MS) - ), - ]); - return (race as { type?: string })?.type === 'ACK'; - } catch { - if (attempt < MAX_ATTEMPTS - 1) { - return sendVerdict(itemId, verdict, listingUrl, attempt + 1); - } - return false; +import type { ChallengeDetectedMsg, ChallengeResolvedMsg } from '../shared/types'; +import { detectChallenge } from './classify'; + +// Headless challenge reporter. The sidebar owns all verdict UI (later slices), so this +// content script has NO UI and never touches the page DOM. Its only job: report whether the +// broker page is gated behind a bot-challenge. Background identifies the tab via sender.tab.id +// — this script runs in the broker tab. + +// Report the page's challenge state on EVERY load, either way — the content script is the +// single per-load source of truth (background no longer guesses challenge state from +// navigation). An on-host Cloudflare interstitial reports DETECTED and stays challenged; +// the redirect to the real page is a fresh load that reports RESOLVED and clears the flag. +// (Out of scope: a challenge APPEARING after a clean load without a navigation — e.g. a +// mid-run rate-limit that swaps the page in place. The load-time report wouldn't catch it.) +function reportChallenges(): void { + if (!detectChallenge()) { + browser.runtime.sendMessage({ type: 'CHALLENGE_RESOLVED' } satisfies ChallengeResolvedMsg).catch(() => {}); + return; } -} - -// Ask the background to close this tab (a content script can't reliably close a -// tab it didn't open). The short delay lets the user read the "✓ recorded" status -// before the tab disappears. The latch makes the close idempotent regardless of -// whether callers disabled their buttons, so rapid actions can't stack timers. -let closingSelf = false; -function closeSelfTab(): void { - if (closingSelf) return; - closingSelf = true; - setTimeout(() => { - browser.runtime.sendMessage({ type: 'CLOSE_TAB' }).catch(() => {}); - }, 800); -} - -// ── PING handler (background → content) ───────────────────────────────────── -// Guarded by a window flag so each executeScript reinject doesn't stack another -// listener — all would respond identically but they accumulate across reinjections. - -const w = window as Window & { __expurgePingBound?: boolean }; -if (!w.__expurgePingBound) { - w.__expurgePingBound = true; - browser.runtime.onMessage.addListener((msg: unknown) => { - const m = msg as Record; - if (m.type === 'PING') { - return Promise.resolve({ - type: 'PONG', - hasOverlay: !!document.getElementById('expurge-host'), - }); - } - return undefined; - }); -} - -// ── challenge panel ────────────────────────────────────────────────────────── - -function buildChallengePanel(info: ItemInfoMsg, onResolved: () => void): void { - const { host, panel } = createOverlayShell(); - const progressText = `${info.progress.done} / ${info.progress.total}`; - panel.innerHTML = ` -
      - expurge - ${progressText} -
      -
      -
      Security check
      -

      This site is running a security check. Complete it, then expurge will show your results.

      -
      - -
      -
      -
      - `; - - document.documentElement.appendChild(host); + browser.runtime.sendMessage({ type: 'CHALLENGE_DETECTED' } satisfies ChallengeDetectedMsg).catch(() => {}); + // Also watch for an IN-PAGE clear (e.g. Turnstile solved inline, no navigation). The 250 ms + // debounce guards against CAPTCHA widgets briefly detaching their container mid-transition, + // which would read as "resolved" for an instant. Lifted from the old buildChallengePanel. let dismissTimer: ReturnType | null = null; const observer = new MutationObserver(() => { if (detectChallenge()) { @@ -575,104 +31,22 @@ function buildChallengePanel(info: ItemInfoMsg, onResolved: () => void): void { return; } if (dismissTimer !== null) return; - // Wait 250 ms before acting — CAPTCHA libraries sometimes briefly detach their container - // during internal state transitions, which would trigger a false positive immediately. dismissTimer = setTimeout(() => { dismissTimer = null; if (!detectChallenge()) { observer.disconnect(); - host.remove(); - onResolved(); + browser.runtime.sendMessage({ type: 'CHALLENGE_RESOLVED' } satisfies ChallengeResolvedMsg).catch(() => {}); } }, 250); }); observer.observe(document.documentElement, { childList: true, subtree: true }); - - const skipBtn = panel.querySelector('#btn-challenge-skip') as HTMLButtonElement; - const statusEl = panel.querySelector('#overlay-status') as HTMLElement; - - skipBtn.addEventListener('click', async () => { - if (dismissTimer !== null) { clearTimeout(dismissTimer); dismissTimer = null; } - observer.disconnect(); - skipBtn.disabled = true; - statusEl.className = 'status saving'; - statusEl.textContent = 'Skipping…'; - const ok = await sendVerdict(info.itemId, 'skipped', ''); - if (ok) { - statusEl.className = 'status recorded'; - statusEl.textContent = '✓ Skipped.'; - closeSelfTab(); - } else { - statusEl.className = 'status'; - statusEl.textContent = 'Save failed — try again.'; - skipBtn.disabled = false; - } - }); -} - -// ── main panel (verdict or guidance) ──────────────────────────────────────── - -function showMainPanel(info: ItemInfoMsg): void { - const { itemId, exposes, renderedUrl, progress } = info; - - const onResults = isResultsPage(window.location.pathname, renderedUrl); - const hostname = brokerHostname(renderedUrl); - - if (onResults) { - const onVerdict = async (verdict: Verdict, listingUrl: string) => { - const host = document.getElementById('expurge-host')!; - const shadow = host.shadowRoot!; - const statusEl = shadow.querySelector('#overlay-status') as HTMLElement; - const ok = await sendVerdict(itemId, verdict, listingUrl); - statusEl.className = 'status recorded'; - statusEl.textContent = verdictMsg(verdict, ok); - if (ok) closeSelfTab(); - }; - - const host = buildGuidancePanel(exposes, hostname, progress, onVerdict); - document.documentElement.appendChild(host); - } else { - const { host, refs } = buildVerdictPanel(exposes, progress); - document.documentElement.appendChild(host); - - const onVerdict = async (verdict: Verdict) => { - setOverlayState(refs, 'saving'); - const ok = await sendVerdict(itemId, verdict, window.location.href); - setOverlayState(refs, 'recorded', verdictMsg(verdict, ok)); - if (ok) closeSelfTab(); - }; - - refs.btnHit.addEventListener('click', () => onVerdict('hit')); - refs.btnClear.addEventListener('click', () => onVerdict('clear')); - refs.btnUnknown.addEventListener('click', () => onVerdict('unknown')); - refs.btnSkip.addEventListener('click', () => onVerdict('skipped')); - } } -// ── init ───────────────────────────────────────────────────────────────────── - -async function init(): Promise { - if (document.getElementById('expurge-host')) return; - - let info: ItemInfoMsg | null = null; - try { - info = await browser.runtime.sendMessage({ type: 'GET_ITEM' }) as ItemInfoMsg | null; - } catch { - return; - } - - if (!info) return; - - // Re-check after the async yield — another concurrent injection may have already appended. - if (document.getElementById('expurge-host')) return; - - const itemInfo = info; - - if (detectChallenge()) { - buildChallengePanel(itemInfo, () => showMainPanel(itemInfo)); - } else { - showMainPanel(itemInfo); - } +// Idempotency guard: the manifest auto-injects the content script on every navigation. Without +// this latch a re-injection would stack a second MutationObserver. Mirrors the old +// __expurgePingBound flag. +const w = window as Window & { __expurgeReporterBound?: boolean }; +if (!w.__expurgeReporterBound) { + w.__expurgeReporterBound = true; + reportChallenges(); } - -init(); diff --git a/src/options/index.html b/src/options/index.html index 91e71d4..6c8046f 100644 --- a/src/options/index.html +++ b/src/options/index.html @@ -4,9 +4,7 @@ expurge - - - + @@ -48,7 +46,7 @@

      Scan in progress

      - +
      diff --git a/src/options/index.ts b/src/options/index.ts index 0eabe6e..0cfee38 100644 --- a/src/options/index.ts +++ b/src/options/index.ts @@ -4,6 +4,7 @@ import type { Draft, EmailDraft, FormDraft } from '../shared/templates'; import { mailtoUrl, toEml, toCopyText } from '../shared/templates'; import { normalizeAkas } from '../shared/transforms'; import { BROKERS, getBroker } from '../shared/brokers'; +import { progressOf, isComplete } from '../background/coordinator'; import { buildAkaRow, addAkaRow, @@ -69,7 +70,7 @@ function safeHttpUrl(url: string | undefined): string { function runDisplayState(profile: Profile | null, run: RunState | null): RunDisplayState { if (!profile) return 'welcome'; if (!run) return 'ready'; - if (run.items.every(i => i.status === 'verdicted')) return 'done'; + if (isComplete(run)) return 'done'; return 'active'; } @@ -124,12 +125,7 @@ function showRunDisplayState(state: RunDisplayState, run?: RunState | null): voi } function renderRunActive(run: RunState): void { - const checkable = run.items.filter( - i => !(typeof i.skipReason === 'string' && i.skipReason.startsWith('missing:')) - ); - const done = checkable.filter(i => i.status === 'verdicted').length; - const total = checkable.length; - const hits = run.items.filter(i => i.verdict === 'hit').length; + const { done, total, hits } = progressOf(run); document.getElementById('run-active-desc')!.textContent = `${done} / ${total} checked${hits > 0 ? ` · ${hits} found` : ''}`; @@ -896,6 +892,23 @@ async function handleStartRun(): Promise { const errEl = document.getElementById('start-error')!; errEl.classList.add('hidden'); const btn = document.getElementById('btn-start') as HTMLButtonElement; + + // Guard synchronously so we don't open a sidebar (or prompt for permission) with no profile. + if (!currentProfile) { + errEl.textContent = 'Profile required — fill in your profile before starting a scan.'; + errEl.classList.remove('hidden'); + return; + } + + // Open the sidebar SYNCHRONOUSLY, inside this click's user gesture, BEFORE any await — + // sidebarAction.open() requires a gesture and opens in the active window (Q-015). Background + // never opens it (an async call from there fails silently). The run is then pinned to this + // window via the windowId captured below. + // NOTE (Q-015 empirical, verify in Firefox 140+): this gesture now drives BOTH + // sidebarAction.open() and, just below, permissions.request(). If Firefox rejects the second, + // the open() is already done — reorder only if needed and record the finding. + browser.sidebarAction.open().catch(() => {}); + btn.disabled = true; btn.textContent = 'Requesting access…'; @@ -920,16 +933,10 @@ async function handleStartRun(): Promise { return; } - if (!currentProfile) { - errEl.textContent = 'Profile required — fill in your profile before starting a scan.'; - errEl.classList.remove('hidden'); - btn.disabled = false; - btn.textContent = 'Start scan'; - return; - } - btn.textContent = 'Starting…'; - await browser.runtime.sendMessage({ type: 'START_RUN', profile: currentProfile }); + // Pin the run to this window so batch tabs open alongside the sidebar we just opened. + const windowId = (await browser.windows.getCurrent()).id; + await browser.runtime.sendMessage({ type: 'START_RUN', profile: currentProfile, windowId }); const res = await browser.runtime.sendMessage({ type: 'GET_RUN_STATE' }) as { run?: RunState }; currentRun = res.run ?? null; showRunDisplayState(runDisplayState(currentProfile, currentRun), currentRun); @@ -986,20 +993,10 @@ document.querySelectorAll('[data-nav]').forEach(btn => { document.getElementById('btn-start')!.addEventListener('click', () => { handleStartRun().catch(console.error); }); -document.getElementById('btn-restore-overlay')!.addEventListener('click', async () => { - const btn = document.getElementById('btn-restore-overlay') as HTMLButtonElement; - btn.disabled = true; - try { - const res = await browser.runtime.sendMessage({ type: 'REINJECT_OVERLAY' }) as { ok?: boolean }; - if (!res?.ok) { - btn.textContent = 'Nothing left to check'; - setTimeout(() => { btn.textContent = 'Restore overlay'; btn.disabled = false; }, 2000); - } else { - btn.disabled = false; - } - } catch { - btn.disabled = false; - } +// Re-open a closed sidebar. Synchronous in the click gesture; opens in the active window, +// which then SIDEBAR_GET_STATEs (shows the run if this is the run's window, else no-run). +document.getElementById('btn-show-sidebar')!.addEventListener('click', () => { + browser.sidebarAction.open().catch(() => {}); }); document.getElementById('btn-stop')!.addEventListener('click', async () => { diff --git a/src/popup/index.html b/src/popup/index.html index 5364492..6b5e163 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -4,9 +4,7 @@ expurge - - - + @@ -25,7 +23,7 @@

      expurge

      diff --git a/src/popup/index.ts b/src/popup/index.ts index 6efa8e6..9bf67c2 100644 --- a/src/popup/index.ts +++ b/src/popup/index.ts @@ -1,10 +1,17 @@ import browser from 'webextension-polyfill'; import type { RunState } from '../shared/types'; +import { progressOf, isComplete } from '../background/coordinator'; function openDashboard(): void { browser.runtime.openOptionsPage().catch(console.error); } +// Re-open a closed sidebar. Must run synchronously in the click gesture; opens in the active +// window, which then SIDEBAR_GET_STATEs (shows the run if this is the run's window, else no-run). +function showSidebar(): void { + browser.sidebarAction.open().catch(() => {}); +} + async function init(): Promise { const res = await browser.runtime.sendMessage({ type: 'GET_RUN_STATE' }) as { run?: RunState }; const run = res.run ?? null; @@ -14,15 +21,9 @@ async function init(): Promise { return; } - const checkable = run.items.filter( - i => !(typeof i.skipReason === 'string' && i.skipReason.startsWith('missing:')) - ); - const done = checkable.filter(i => i.status === 'verdicted').length; - const total = checkable.length; - const hits = run.items.filter(i => i.verdict === 'hit').length; - const allDone = run.items.every(i => i.status === 'verdicted'); + const { done, total, hits } = progressOf(run); - if (allDone) { + if (isComplete(run)) { document.getElementById('popup-done')!.classList.remove('hidden'); document.getElementById('popup-done-summary')!.textContent = hits > 0 ? `Found on ${hits} site${hits !== 1 ? 's' : ''}. ${done} checked.` @@ -38,21 +39,8 @@ document.getElementById('btn-open-dashboard')!.addEventListener('click', openDas document.getElementById('btn-open-dashboard-active')!.addEventListener('click', openDashboard); document.getElementById('btn-open-dashboard-done')!.addEventListener('click', openDashboard); -document.getElementById('btn-restore-overlay')!.addEventListener('click', async () => { - const btn = document.getElementById('btn-restore-overlay') as HTMLButtonElement; - btn.disabled = true; - try { - const res = await browser.runtime.sendMessage({ type: 'REINJECT_OVERLAY' }) as { ok?: boolean }; - if (!res?.ok) { - btn.textContent = 'Nothing to restore'; - setTimeout(() => { btn.textContent = 'Restore overlay'; btn.disabled = false; }, 2000); - } else { - btn.disabled = false; - } - } catch { - btn.disabled = false; - } -}); +document.getElementById('btn-show-sidebar-active')!.addEventListener('click', showSidebar); +document.getElementById('btn-show-sidebar-done')!.addEventListener('click', showSidebar); document.getElementById('btn-stop-run')!.addEventListener('click', async () => { await browser.runtime.sendMessage({ type: 'STOP_RUN' }); diff --git a/src/shared/brokers.ts b/src/shared/brokers.ts index c373b2a..a700642 100644 --- a/src/shared/brokers.ts +++ b/src/shared/brokers.ts @@ -23,6 +23,7 @@ export interface Broker { url: string; // template with {field|transform} tokens requires: string[]; // raw profile fields needed; missing → skip exposes: string[]; // what the site shows (drives overlay guidance — never gates) + guidance?: string; // generic, PII-free "how to find your listing" note; results-state only }; optout: BrokerChannel[]; // ordered list; first verified+unexpired wins } diff --git a/src/shared/types.ts b/src/shared/types.ts index 7201964..b6c7715 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -6,7 +6,9 @@ export type SkipReason = | 'run_stopped' | 'permission_denied' | `missing:${string}`; -export type WorkItemStatus = 'pending' | 'open' | 'verdicted'; +// pending → not yet opened. open → tab open, holds a batch slot. deferred → tab open but +// set aside (non-terminal, frees its slot, revisited at run end). verdicted → terminal. +export type WorkItemStatus = 'pending' | 'open' | 'deferred' | 'verdicted'; export interface WorkItem { id: string; // "{brokerId}:{nameVariant}" @@ -28,8 +30,16 @@ export interface RunState { runId: string; // UUID createdAt: string; // ISO timestamp items: WorkItem[]; + windowId?: number; // window the run is pinned to (§Decision 7). Session-only, but — + // unlike tabId — a windowId isn't a recycled-id hazard, so it's + // safe to persist in session storage (survives spindown). } +// Run progress counts, computed by coordinator.progressOf and shared by every readout +// (popup, options, sidebar, ITEM_INFO). `done`/`total` exclude `missing:` skips; `deferred` +// counts toward `total` but not `done`. +export interface RunProgress { done: number; total: number; hits: number } + // One additional name to search, captured as separate atomic fields (mirrors the // primary name, which requires both first and last). middle is stored but not yet // used in search URLs — see normalizeAkas. @@ -55,17 +65,35 @@ export interface Profile { // ── messages popup/content → background ───────────────────────────────────── -export interface StartRunMsg { type: 'START_RUN'; profile: Profile } +export interface StartRunMsg { type: 'START_RUN'; profile: Profile; windowId?: number } export interface GetRunStateMsg { type: 'GET_RUN_STATE' } export interface GetDraftMsg { type: 'GET_DRAFT'; itemId: string } -export interface GetItemMsg { type: 'GET_ITEM' } -export interface VerdictMsg { type: 'VERDICT'; itemId: string; verdict: Verdict; skipReason?: SkipReason; listingUrl?: string } +export interface VerdictMsg { type: 'VERDICT'; itemId: string; verdict: Verdict; skipReason?: SkipReason; listingUrl?: string; windowId?: number } export interface ReverdictMsg { type: 'REVERDICT'; itemId: string; verdict: Verdict; listingUrl?: string } export interface SaveProfileMsg { type: 'SAVE_PROFILE'; profile: Profile } export interface GetProfileMsg { type: 'GET_PROFILE' } export interface MarkSentMsg { type: 'MARK_SENT'; itemId: string } export interface DeleteAllMsg { type: 'DELETE_ALL' } -export interface CloseTabMsg { type: 'CLOSE_TAB' } +export interface CloseTabMsg { type: 'CLOSE_TAB'; windowId?: number } + +// ── messages sidebar → background ─────────────────────────────────────────── +// The sidebar lives in its own document (not a broker tab), so it can't rely on +// `sender.tab` to identify the run — it passes the pinned `windowId` explicitly. + +export interface SidebarGetStateMsg { type: 'SIDEBAR_GET_STATE'; windowId: number } +export interface DeferMsg { type: 'DEFER'; itemId: string; windowId: number } +// One message for both a checklist row click and the revisit button (revisit = FOCUS_ITEM on +// the first deferred item). The sidebar can't focus a tab itself (tab ids are background-only), +// so it names the item and background activates its tab. +export interface FocusItemMsg { type: 'FOCUS_ITEM'; itemId: string; windowId: number } +export interface NavigateBrokerTabMsg { type: 'NAVIGATE_BROKER_TAB'; windowId: number; itemId: string; url: string } + +// ── messages content → background ─────────────────────────────────────────── +// The headless content script only reports whether a bot-challenge is up; the +// human casts every verdict from the sidebar, so no per-tab identity is needed. + +export interface ChallengeDetectedMsg { type: 'CHALLENGE_DETECTED' } +export interface ChallengeResolvedMsg { type: 'CHALLENGE_RESOLVED' } // ── messages background → content/popup ───────────────────────────────────── @@ -74,16 +102,59 @@ export interface ItemInfoMsg { itemId: string; brokerId: string; exposes: string[]; + guidance?: string; // broker's generic search.guidance note, when present (results-state) renderedUrl: string; - progress: { done: number; total: number; hits: number }; + progress: RunProgress; } export interface AckMsg { type: 'ACK'; itemId: string } -export interface PongMsg { type: 'PONG'; hasOverlay: boolean } -export interface PingMsg { type: 'PING' } -export interface ReinjMsg { type: 'REINJECT_OVERLAY'; tabId?: number } export interface StopRunMsg { type: 'STOP_RUN' } export type ToBackground = - | StartRunMsg | GetRunStateMsg | GetDraftMsg | GetItemMsg | VerdictMsg | ReverdictMsg - | PingMsg | ReinjMsg | StopRunMsg | SaveProfileMsg | GetProfileMsg | MarkSentMsg | DeleteAllMsg | CloseTabMsg; + | StartRunMsg | GetRunStateMsg | GetDraftMsg | VerdictMsg | ReverdictMsg + | StopRunMsg | SaveProfileMsg | GetProfileMsg | MarkSentMsg | DeleteAllMsg | CloseTabMsg + | SidebarGetStateMsg | DeferMsg | FocusItemMsg | NavigateBrokerTabMsg | ChallengeDetectedMsg | ChallengeResolvedMsg; + +// ── sidebar view model ────────────────────────────────────────────────────── +// The sidebar's display is derived purely from run state + focus (src/sidebar/state.ts). + +// Which half of a broker's flow the focused tab is on: the results listing (show guidance) +// or a details/profile page (show the verdict cluster). +export type PageType = 'results' | 'details'; + +// Everything the sidebar needs to render an active broker item — the ItemInfoMsg payload +// (sans message `type`) plus the derived page-type. `guidance` is present only when the +// broker defines search.guidance. +export interface ActiveItemInfo { + itemId: string; + brokerId: string; + exposes: string[]; + guidance?: string; + renderedUrl: string; + pageType: PageType; + progress: RunProgress; +} + +// The sidebar's current display. The six resting views are produced by deriveView from run +// state + focus; `saving`/`recorded` are transient interaction states the UI layer sets +// imperatively around a verdict send (never derived), kept here for union completeness. +export type SidebarView = + | { view: 'no-run' } + | { view: 'guidance'; item: ActiveItemInfo } + | { view: 'verdict'; item: ActiveItemInfo } + | { view: 'challenge'; item: ActiveItemInfo } + // The broker tab wandered off the broker's host (address bar, a link, a redirect). No + // verdict/guidance controls — a listing can't be confirmed on, e.g., google.com. + | { view: 'offsite'; item: ActiveItemInfo } + | { view: 'revisit'; waiting: number; focusId: string | null; progress: RunProgress } + | { view: 'done'; progress: RunProgress } + // A stopped run is `isComplete` (everything's verdicted), but the run_stopped items were + // abandoned, not checked — so `checked` excludes them (they're still counted in `total`). + | { view: 'stopped'; checked: number; total: number; hits: number } + | { view: 'saving'; item: ActiveItemInfo } + | { view: 'recorded'; item: ActiveItemInfo }; + +// windowId scopes the push: runtime.sendMessage broadcasts to every open sidebar (one per +// window), so each sidebar ignores updates whose windowId isn't its own — an idle window's +// sidebar never adopts the run window's view. +export interface SidebarUpdateMsg { type: 'SIDEBAR_UPDATE'; windowId: number; view: SidebarView } diff --git a/src/shared/url.test.ts b/src/shared/url.test.ts new file mode 100644 index 0000000..74f20b2 --- /dev/null +++ b/src/shared/url.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { isResultsPage, brokerHostname, isOnHost } from './url'; + +describe('isResultsPage', () => { + it('matching pathname → true (results page)', () => { + expect(isResultsPage('/results', 'https://b.com/results?name=x')).toBe(true); + }); + + it('different pathname → false (details page)', () => { + expect(isResultsPage('/person/123', 'https://b.com/results?name=x')).toBe(false); + }); + + it('malformed rendered URL → false', () => { + expect(isResultsPage('/results', 'not a url')).toBe(false); + }); +}); + +describe('brokerHostname', () => { + it('returns the hostname of the rendered URL', () => { + expect(brokerHostname('https://www.truepeoplesearch.com/results?x=1')).toBe( + 'www.truepeoplesearch.com', + ); + }); + + it('malformed URL → empty string', () => { + expect(brokerHostname('::::')).toBe(''); + }); +}); + +describe('isOnHost', () => { + const rendered = 'https://www.truepeoplesearch.com/results?name=x'; + + it('exact host match → true', () => { + expect(isOnHost('https://www.truepeoplesearch.com/find/person/1', rendered)).toBe(true); + }); + + it('subdomain of the broker host → true', () => { + expect(isOnHost('https://cdn.www.truepeoplesearch.com/x', rendered)).toBe(true); + }); + + it('off-host challenge detour → false', () => { + expect(isOnHost('https://challenges.cloudflare.com/turnstile', rendered)).toBe(false); + }); + + it('a bare-suffix lookalike is not a subdomain → false', () => { + // "nottruepeoplesearch.com" endsWith "truepeoplesearch.com" but isn't a subdomain; + // the leading dot in the check prevents the false match. + expect(isOnHost('https://nottruepeoplesearch.com/x', rendered)).toBe(false); + }); + + it('malformed URL on either side → false', () => { + expect(isOnHost('::::', rendered)).toBe(false); + expect(isOnHost('https://www.truepeoplesearch.com/x', 'not a url')).toBe(false); + }); +}); diff --git a/src/shared/url.ts b/src/shared/url.ts new file mode 100644 index 0000000..c778e07 --- /dev/null +++ b/src/shared/url.ts @@ -0,0 +1,39 @@ +// Pure URL helpers shared across the extension — no DOM, no browser, no side effects. +// The background uses these to classify a broker tab's page-type (results vs details); +// they moved out of content/classify.ts so the content/sidebar boundary doesn't own +// shared pure logic. `detectChallenge` (DOM-dependent) stays in content/classify.ts. + +// The rendered search URL points at a results listing. If the current page's pathname +// matches, we're on the results page (show guidance); otherwise it's a details page (show +// verdict buttons). Takes the pathname directly (callers pass window.location.pathname, +// which never throws); only the rendered URL is parsed, and a malformed one → not-results. +export function isResultsPage(currentPathname: string, renderedUrl: string): boolean { + try { + return currentPathname === new URL(renderedUrl).pathname; + } catch { + return false; + } +} + +// The broker's hostname from its rendered search URL, or '' if it can't be parsed. +export function brokerHostname(renderedUrl: string): string { + try { + return new URL(renderedUrl).hostname; + } catch { + return ''; + } +} + +// Is `tabUrl` on the broker's own host (exact host or a subdomain of it)? Used to tell a +// real broker page from an off-host detour like a `challenges.cloudflare.com` interstitial: +// the background clears a tab's challenge flag only once it lands back on-host, and won't +// treat the CDN hop itself as the broker page. A malformed URL on either side → false. +export function isOnHost(tabUrl: string, renderedUrl: string): boolean { + try { + const brokerHost = new URL(renderedUrl).hostname; + const tabHost = new URL(tabUrl).hostname; + return tabHost === brokerHost || tabHost.endsWith('.' + brokerHost); + } catch { + return false; + } +} diff --git a/src/sidebar/index.html b/src/sidebar/index.html new file mode 100644 index 0000000..bda7e51 --- /dev/null +++ b/src/sidebar/index.html @@ -0,0 +1,19 @@ + + + + + + expurge + + + + +
      +

      expurge

      +

      +
      +
      +
      + + + diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts new file mode 100644 index 0000000..9ceed45 --- /dev/null +++ b/src/sidebar/index.ts @@ -0,0 +1,354 @@ +import browser from 'webextension-polyfill'; +import type { RunState, WorkItem, Verdict, SidebarView, SidebarUpdateMsg, ActiveItemInfo } from '../shared/types'; +import { getBroker } from '../shared/brokers'; +import { brokerHostname } from '../shared/url'; +import { wirePasteWarning } from './paste'; +import { progressOf } from '../background/coordinator'; + +// The sidebar is a thin render layer over the view the background derives (deriveView) — it +// never re-derives. Init order matters (Slice-5 review): attach the push listener FIRST, then +// resolve our windowId, then PULL the current view — so a push landing between them isn't missed. +// +// DATA-INJECTION INVARIANT (STYLEGUIDE §0): the sidebar shows ONLY generic broker data — +// broker names/slugs, generic `exposes` chips, and the broker's `guidance` note. It NEVER +// renders the user's real profile data: `variantFirst`/`variantLast` (the searched name), +// `renderedUrl`/`listingUrl` (carry the name in the query) are deliberately never displayed. +// All dataset-sourced text goes through textContent, never innerHTML. + +let windowId: number | undefined; +// While a verdict is mid-flight we own the detail panel (saving → recorded) and suppress +// incoming pushes, so the background's next-item push doesn't yank the animation away. +let transient = false; +let lastVerdict: Verdict | null = null; + +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); +const send = (msg: unknown): void => { browser.runtime.sendMessage(msg).catch(() => {}); }; + +// ── init ───────────────────────────────────────────────────────────────────── + +browser.runtime.onMessage.addListener((msg: unknown) => { + const m = msg as Partial; + if (m?.type !== 'SIDEBAR_UPDATE') return; + // Ignore other windows (runtime.sendMessage broadcasts to every sidebar) and don't clobber + // an in-progress saving/recorded animation. + if (windowId === undefined || m.windowId !== windowId || transient) return; + renderView(m.view!); +}); + +async function init(): Promise { + const win = await browser.windows.getCurrent(); + windowId = win.id; + const res = await browser.runtime.sendMessage({ type: 'SIDEBAR_GET_STATE', windowId }) as SidebarUpdateMsg; + renderView(res.view); +} + +// Re-pull the resting view after a transient animation ends (we ignored pushes during it). +async function pullState(): Promise { + if (windowId === undefined) return; + const res = await browser.runtime.sendMessage({ type: 'SIDEBAR_GET_STATE', windowId }) as SidebarUpdateMsg; + renderView(res.view); +} + +// ── DOM helpers (textContent-only) ───────────────────────────────────────────── + +function make(tag: K, cls?: string, text?: string): HTMLElementTagNameMap[K] { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined) e.textContent = text; + return e; +} + +function button(label: string, cls: string, onClick: () => void): HTMLButtonElement { + const b = make('button', cls, label); + b.addEventListener('click', onClick); + return b; +} + +const detail = () => document.getElementById('detail')!; + +// ── view dispatch ────────────────────────────────────────────────────────────── + +function renderView(view: SidebarView): void { + const d = detail(); + d.replaceChildren(); + switch (view.view) { + case 'no-run': renderNoRun(d); break; + case 'guidance': renderGuidance(d, view.item); break; + case 'verdict': renderVerdict(d, view.item); break; + case 'challenge': renderChallenge(d, view.item); break; + case 'offsite': renderOffsite(d, view.item); break; + case 'revisit': renderRevisit(d, view.waiting, view.focusId); break; + case 'done': renderDone(d, view.progress.done, view.progress.total, view.progress.hits); break; + case 'stopped': renderStopped(d, view.checked, view.total, view.hits); break; + case 'saving': renderSaving(d); break; + case 'recorded': renderRecorded(d); break; + } + // The detail view is windowId-scoped, but GET_RUN_STATE returns the GLOBAL run. A `no-run` + // view means THIS window has no run, so showing another window's checklist would render + // rows whose FOCUS_ITEM{windowId} the background rejects (dead clicks) — clear it instead. + // For any other view, the single pinned run IS this window's run, so the fetch is correct. + if (view.view === 'no-run') clearChecklist(); + else void refreshChecklist(); +} + +// ── active-item detail views ──────────────────────────────────────────────────── + +// Generic "look for" chips + optional broker guidance. Never the user's own data. +function lookFor(d: HTMLElement, item: ActiveItemInfo): void { + d.appendChild(make('div', 'label', 'Look for')); + const ul = make('ul', 'exposes'); + for (const chip of item.exposes) ul.appendChild(make('li', undefined, chip)); // textContent — generic + d.appendChild(ul); + if (item.guidance) d.appendChild(make('p', 'guidance-msg', item.guidance)); // textContent — dataset +} + +// Separate, clearly-labelled Defer control (decision 8): non-terminal, keeps the tab. +function deferControl(d: HTMLElement, itemId: string): void { + const wrap = make('div', 'defer'); + wrap.appendChild(make('p', 'defer-note', 'Still loading? Set it aside and come back at the end.')); + wrap.appendChild(button('Set aside', 'btn-quiet', () => send({ type: 'DEFER', itemId, windowId }))); + d.appendChild(wrap); +} + +function renderGuidance(d: HTMLElement, item: ActiveItemInfo): void { + lookFor(d, item); + d.appendChild(make('p', 'question', 'Find yourself in the list, then open your details page to confirm.')); + d.appendChild(button('Not found / no results', 'btn-secondary wide', () => castVerdict(item.itemId, 'clear'))); + + // Paste-URL fallback: navigate THIS item's broker tab to a listing the user pastes. Warn (never + // block) when the pasted host isn't the broker's — the hostname is safe to show; the full + // renderedUrl (which carries the searched name) is not, so only its host is used. + const host = brokerHostname(item.renderedUrl); + const paste = make('div', 'paste'); + const input = make('input', 'paste-input'); + input.type = 'text'; + input.placeholder = 'Or paste a link to your listing…'; + input.autocomplete = 'off'; + const warn = make('p', 'paste-warning', `This doesn't look like a ${host} URL — double-check before confirming.`); + wirePasteWarning(input, warn, item.renderedUrl); + const go = button('Go to my listing', 'btn-quiet wide', () => { + const url = input.value.trim(); + if (url) send({ type: 'NAVIGATE_BROKER_TAB', windowId, itemId: item.itemId, url }); + }); + paste.appendChild(input); + paste.appendChild(warn); + paste.appendChild(go); + d.appendChild(paste); + + deferControl(d, item.itemId); +} + +function renderVerdict(d: HTMLElement, item: ActiveItemInfo): void { + lookFor(d, item); + d.appendChild(make('p', 'question', 'Could this listing be you?')); + const grid = make('div', 'verdicts'); + grid.appendChild(button('Yes, this is me', 'btn-hit', () => castVerdict(item.itemId, 'hit'))); + grid.appendChild(button('No, not me', 'btn-clear', () => castVerdict(item.itemId, 'clear'))); + grid.appendChild(button('Not sure', 'btn-unknown', () => castVerdict(item.itemId, 'unknown'))); + grid.appendChild(button('Skip', 'btn-skip', () => castVerdict(item.itemId, 'skipped'))); + d.appendChild(grid); + deferControl(d, item.itemId); +} + +function renderChallenge(d: HTMLElement, item: ActiveItemInfo): void { + d.appendChild(make('div', 'label', 'Security check')); + d.appendChild(make('p', 'question', 'This site is running a security check. Complete it on the page, then expurge will show your results.')); + d.appendChild(button('Skip this site', 'btn-skip wide', () => castVerdict(item.itemId, 'skipped'))); + deferControl(d, item.itemId); +} + +// The tab left the broker's site — no verdict controls (you can't confirm a listing on another +// site). Offer to go back to the results (renderedUrl is used as a nav target, never displayed). +function renderOffsite(d: HTMLElement, item: ActiveItemInfo): void { + const name = getBroker(item.brokerId)?.name ?? 'this site'; + d.appendChild(make('p', 'question', `This tab isn't on ${name} right now.`)); + d.appendChild(make('p', 'empty-sub', `expurge only checks ${name}'s own pages, so there's nothing to confirm here. Go back to your results, or set this aside for later.`)); + d.appendChild(button('Back to my results', 'btn-primary wide', () => + send({ type: 'NAVIGATE_BROKER_TAB', windowId, itemId: item.itemId, url: item.renderedUrl }))); + deferControl(d, item.itemId); +} + +function renderNoRun(d: HTMLElement): void { + d.appendChild(make('p', 'empty', 'No active scan in this window.')); + d.appendChild(make('p', 'empty-sub', "Start a scan from the expurge dashboard whenever you're ready — there's no rush.")); +} + +// focusId comes from the view (deriveView), so the button works even when revisit is the +// sidebar's very first render (reopen mid-revisit / after resume) — no dependency on the +// async checklist fetch having landed yet. +function renderRevisit(d: HTMLElement, waiting: number, focusId: string | null): void { + d.appendChild(make('p', 'question', `${waiting} site${waiting !== 1 ? 's' : ''} waiting for you.`)); + d.appendChild(make('p', 'empty-sub', "These were set aside while they loaded. Pick them back up whenever you're ready.")); + const b = button('Revisit set-aside sites', 'btn-primary wide', () => { + if (focusId) send({ type: 'FOCUS_ITEM', itemId: focusId, windowId }); + }); + if (!focusId) b.disabled = true; + d.appendChild(b); +} + +function renderDone(d: HTMLElement, done: number, total: number, hits: number): void { + const headline = hits > 0 + ? `Found on ${hits} site${hits !== 1 ? 's' : ''}.` + : 'No listings found here.'; + d.appendChild(make('p', 'question', headline)); + const sub = hits > 0 + ? `Checked ${done} of ${total}. Open the dashboard to send your opt-out requests.` + : `Checked ${done} of ${total}. You're all clear here.`; + d.appendChild(make('p', 'empty-sub', sub)); + d.appendChild(button('View results', 'btn-primary wide', () => { browser.runtime.openOptionsPage().catch(() => {}); })); +} + +// Stop leaves the run isComplete but with abandoned items — honest copy, no "all clear". +function renderStopped(d: HTMLElement, checked: number, total: number, hits: number): void { + d.appendChild(make('p', 'question', 'Scan stopped.')); + const found = hits > 0 ? ` Found on ${hits} site${hits !== 1 ? 's' : ''}.` : ''; + d.appendChild(make('p', 'empty-sub', `Checked ${checked} of ${total}.${found} The rest are still on your list — start again anytime.`)); + d.appendChild(button('View results', 'btn-primary wide', () => { browser.runtime.openOptionsPage().catch(() => {}); })); +} + +// ── transient interaction states (UI-owned, never derived) ────────────────────── + +function renderSaving(d: HTMLElement): void { + const s = make('div', 'status saving'); + s.appendChild(make('span', 'spinner')); + s.appendChild(make('span', undefined, 'Saving your answer…')); + d.appendChild(s); +} + +function renderRecorded(d: HTMLElement): void { + d.appendChild(make('p', 'status recorded', recordedMsg(lastVerdict))); +} + +function recordedMsg(v: Verdict | null): string { + if (v === 'hit') return '✓ Marked as yours — we\'ll prepare an opt-out.'; + if (v === 'clear') return '✓ Not listed here.'; + if (v === 'unknown') return '✓ Marked "not sure."'; + return '✓ Skipped.'; +} + +function renderVerdictError(): void { + // Appended below the re-pulled view, so the verdict controls stay usable for a retry. + detail().appendChild(make('p', 'status error', "Couldn't save just now — check your connection and try again.")); +} + +// Send a verdict and confirm the background wrote it: race each send against a 6s timeout, up +// to 3 attempts, true iff the reply is the {type:'ACK'} handshake (CLAUDE.md verdict contract). +// The write is idempotent (handleVerdict no-wedge guard), so a retry after a landed-but-lost +// ACK re-ACKs without re-recording. +async function sendVerdictAck(itemId: string, verdict: Verdict, attempt = 0): Promise { + const TIMEOUT_MS = 6_000; + const MAX_ATTEMPTS = 3; + try { + const reply = await Promise.race([ + browser.runtime.sendMessage({ type: 'VERDICT', itemId, verdict, windowId }), + new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), TIMEOUT_MS)), + ]); + return (reply as { type?: string })?.type === 'ACK'; + } catch { + if (attempt < MAX_ATTEMPTS - 1) return sendVerdictAck(itemId, verdict, attempt + 1); + return false; + } +} + +// Cast a verdict: own the panel through saving → recorded (~800 ms), then yield to the resting +// view (the background already recorded, advanced focus, and closed the tab). NEVER shows +// recorded unless the ACK confirmed the write; on failure it re-pulls the true state and leaves +// the controls usable so the user can retry. +async function castVerdict(itemId: string, verdict: Verdict): Promise { + if (windowId === undefined) return; + transient = true; + lastVerdict = verdict; + + const d = detail(); + d.replaceChildren(); + renderSaving(d); + + const ok = await sendVerdictAck(itemId, verdict); + + if (!ok) { + transient = false; // failure: restore the controls so the user can retry + await pullState().catch(() => {}); // reflect reality (verdict may not have landed) + renderVerdictError(); + return; + } + + // Keep the latch closed THROUGH the recorded animation: handleVerdict pushes the next item's + // SIDEBAR_UPDATE before returning the ACK, so an unguarded window here would let that push + // clobber the "✓ recorded" confirmation. pullState below re-derives the true latest state + // (next item, or a Stop/Delete that arrived during the suppressed window), so nothing is lost. + d.replaceChildren(); + renderRecorded(d); + await delay(800); + transient = false; + await pullState().catch(() => {}); // guarded so a rejected re-pull can't dead-end the panel +} + +// ── checklist (Decision A: rendered from GET_RUN_STATE, re-fetched on each update) ── + +async function refreshChecklist(): Promise { + const res = await browser.runtime.sendMessage({ type: 'GET_RUN_STATE' }) as { run?: RunState }; + const run = res.run ?? null; + renderChecklist(run); + renderProgress(run); +} + +// Used for the no-run view (this window has no run) — don't show the global run's checklist. +function clearChecklist(): void { + document.getElementById('checklist')!.replaceChildren(); + document.getElementById('progress')!.textContent = ''; +} + +function renderProgress(run: RunState | null): void { + const p = document.getElementById('progress')!; + if (!run) { p.textContent = ''; return; } + const { done, total, hits } = progressOf(run); + p.textContent = `${done} / ${total} checked${hits > 0 ? ` · ${hits} found` : ''}`; +} + +const isMissing = (i: WorkItem): boolean => + typeof i.skipReason === 'string' && i.skipReason.startsWith('missing:'); + +function renderChecklist(run: RunState | null): void { + const c = document.getElementById('checklist')!; + c.replaceChildren(); + if (!run) return; + + const groups: Array<[string, WorkItem[]]> = [ + ['In progress', run.items.filter(i => i.status === 'open')], + ['Waiting', run.items.filter(i => i.status === 'deferred')], + ['Done', run.items.filter(i => i.status === 'verdicted' && !isMissing(i))], + ]; + + for (const [title, items] of groups) { + if (items.length === 0) continue; + c.appendChild(make('div', 'group-label', `${title} · ${items.length}`)); + const ul = make('ul', 'rows'); + for (const item of items) ul.appendChild(row(item)); + c.appendChild(ul); + } +} + +// A checklist row: broker name (dataset) + a GENERIC "alternate name" tag for AKA variants +// (nameVariant is 'primary'/'aka_N' — never the actual name) + a "listed" marker for hits. +// Non-terminal rows are clickable → FOCUS_ITEM (manual override, decision 5). +function row(item: WorkItem): HTMLLIElement { + const li = make('li', 'row'); + const broker = getBroker(item.brokerId); + li.appendChild(make('span', 'row-name', broker?.name ?? item.brokerId)); // dataset/slug — textContent + if (item.nameVariant !== 'primary') li.appendChild(make('span', 'row-tag', 'alternate name')); + if (item.verdict === 'hit') li.appendChild(make('span', 'row-hit', 'listed')); + + if (item.status !== 'verdicted') { + li.classList.add('clickable'); + li.setAttribute('role', 'button'); + li.tabIndex = 0; + const jump = (): void => send({ type: 'FOCUS_ITEM', itemId: item.id, windowId }); + li.addEventListener('click', jump); + li.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); jump(); } + }); + } + return li; +} + +init().catch(() => {}); diff --git a/src/sidebar/paste.test.ts b/src/sidebar/paste.test.ts new file mode 100644 index 0000000..ff79f26 --- /dev/null +++ b/src/sidebar/paste.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { shouldWarnOffHost, wirePasteWarning } from './paste'; + +const RENDERED = 'https://www.truepeoplesearch.com/results?name=Jane%20Doe&citystatezip=Reno'; + +describe('shouldWarnOffHost', () => { + it('off-host URL → warn', () => { + expect(shouldWarnOffHost('https://www.google.com/x', RENDERED)).toBe(true); + }); + + it('on-host URL (exact or subdomain) → no warn', () => { + expect(shouldWarnOffHost('https://www.truepeoplesearch.com/find/person/1', RENDERED)).toBe(false); + expect(shouldWarnOffHost('https://cdn.www.truepeoplesearch.com/x', RENDERED)).toBe(false); + }); + + it('empty / whitespace → no warn', () => { + expect(shouldWarnOffHost('', RENDERED)).toBe(false); + expect(shouldWarnOffHost(' ', RENDERED)).toBe(false); + }); + + it("unparseable paste → warn (can't confirm it's the broker)", () => { + expect(shouldWarnOffHost('not a url', RENDERED)).toBe(true); + }); +}); + +describe('wirePasteWarning — the DOM event path', () => { + const setup = () => { + const input = document.createElement('input'); + const warn = document.createElement('p'); + wirePasteWarning(input, warn, RENDERED); + return { input, warn }; + }; + const enter = (input: HTMLInputElement, value: string) => { + input.value = value; // what a paste does to the field… + input.dispatchEvent(new Event('input')); // …then fires the input event + }; + + it('starts hidden (empty field)', () => { + expect(setup().warn.hidden).toBe(true); + }); + + it('an off-host paste shows the warning', () => { + const { input, warn } = setup(); + enter(input, 'https://www.google.com/x'); + expect(warn.hidden).toBe(false); + }); + + it('replacing it with an on-host URL hides the warning again', () => { + const { input, warn } = setup(); + enter(input, 'https://www.google.com/x'); + enter(input, 'https://www.truepeoplesearch.com/find/person/1'); + expect(warn.hidden).toBe(true); + }); + + it('clearing the field hides the warning', () => { + const { input, warn } = setup(); + enter(input, 'https://evil.example/x'); + enter(input, ''); + expect(warn.hidden).toBe(true); + }); +}); diff --git a/src/sidebar/paste.ts b/src/sidebar/paste.ts new file mode 100644 index 0000000..cb4034d --- /dev/null +++ b/src/sidebar/paste.ts @@ -0,0 +1,18 @@ +import { isOnHost } from '../shared/url'; + +// Should the off-host paste warning show? True when a non-empty pasted URL isn't on the +// broker's own host (exact or subdomain) — including an unparseable paste, which we can't +// confirm is the broker. Pure: the warn/never-block decision, unit-tested. +export function shouldWarnOffHost(pastedUrl: string, renderedUrl: string): boolean { + const url = pastedUrl.trim(); + return url !== '' && !isOnHost(url, renderedUrl); +} + +// Wire an input's live off-host warning: toggle `warn.hidden` on every input event (paste +// included) and set the initial hidden state. Extracted so the DOM event path — paste → input +// event → warning visibility — is testable in jsdom (the behaviour a QA pass flagged as missing). +export function wirePasteWarning(input: HTMLInputElement, warn: HTMLElement, renderedUrl: string): void { + const update = (): void => { warn.hidden = !shouldWarnOffHost(input.value, renderedUrl); }; + input.addEventListener('input', update); + update(); +} diff --git a/src/sidebar/state.test.ts b/src/sidebar/state.test.ts new file mode 100644 index 0000000..5f88868 --- /dev/null +++ b/src/sidebar/state.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { deriveView, type SidebarFocus } from './state'; +import type { SidebarView } from '../shared/types'; +import { makeBroker as broker, makeItem as item, makeRun as run } from '../test-support/fixtures'; + +// Assert the view tag and return the narrowed variant so payload fields are type-checked. +function expectView(v: SidebarView, tag: T): Extract { + expect(v.view).toBe(tag); + return v as Extract; +} + +const focus = (over: Partial = {}): SidebarFocus => ({ + item: item(), + tabUrl: 'https://b.com/x', // pathname '/x' — matches makeItem's renderedUrl → results page + challenge: false, + ...over, +}); + +// makeItem's renderedUrl is https://b.com/x (pathname '/x'); a tab on another pathname is a +// details page. +const DETAILS_URL = 'https://b.com/find/person/123'; + +const brokers = [broker()]; + +describe('deriveView — no-run', () => { + it('null run → no-run, regardless of focus', () => { + expect(deriveView(null, null, brokers)).toEqual({ view: 'no-run' }); + expect(deriveView(null, focus(), brokers)).toEqual({ view: 'no-run' }); + }); +}); + +describe('deriveView — done', () => { + it('every item verdicted → done with progress', () => { + const r = run([item({ status: 'verdicted', verdict: 'hit' })]); + const v = expectView(deriveView(r, null, brokers), 'done'); + expect(v.progress).toEqual({ done: 1, total: 1, hits: 1 }); + }); + + it('done outranks a still-focused item (precedence 2 over 3–5)', () => { + const r = run([item({ status: 'verdicted', verdict: 'clear' })]); + // Tab still focused + even flagged as a challenge — done still wins. + expect(deriveView(r, focus({ challenge: true }), brokers).view).toBe('done'); + }); +}); + +describe('deriveView — stopped', () => { + it('isComplete with a run_stopped item → stopped; checked excludes the abandoned ones', () => { + const r = run([ + item({ id: 'a', status: 'verdicted', verdict: 'hit' }), + item({ id: 'b', status: 'verdicted', verdict: 'clear' }), + item({ id: 'c', status: 'verdicted', verdict: 'skipped', skipReason: 'run_stopped' }), + ]); + const v = expectView(deriveView(r, null, brokers), 'stopped'); + expect(v).toMatchObject({ checked: 2, total: 3, hits: 1 }); + }); + + it('a fully-verdicted run with no run_stopped item stays done', () => { + expect(deriveView(run([item({ status: 'verdicted', verdict: 'clear' })]), null, brokers).view).toBe('done'); + }); + + it('stopped wins over a still-focused item (isComplete precedence)', () => { + const r = run([item({ status: 'verdicted', verdict: 'skipped', skipReason: 'run_stopped' })]); + expect(deriveView(r, focus({ challenge: true }), brokers).view).toBe('stopped'); + }); + + it('total excludes missing: skips, checked excludes run_stopped', () => { + const r = run([ + item({ id: 'm', status: 'verdicted', verdict: 'skipped', skipReason: 'missing:city' }), + item({ id: 'a', status: 'verdicted', verdict: 'clear' }), + item({ id: 's', status: 'verdicted', verdict: 'skipped', skipReason: 'run_stopped' }), + ]); + const v = expectView(deriveView(r, null, brokers), 'stopped'); + expect(v).toMatchObject({ checked: 1, total: 2, hits: 0 }); + }); +}); + +describe('deriveView — challenge / guidance / verdict (focused item)', () => { + const incomplete = run([item({ status: 'open' })]); + + it('challenge flag → challenge view, outranking page-type', () => { + // On the results page but a challenge is up → challenge, not guidance. + const v = expectView(deriveView(incomplete, focus({ challenge: true }), brokers), 'challenge'); + expect(v.item.itemId).toBe('b:primary'); + }); + + it('results page (matching pathname) → guidance', () => { + const v = expectView(deriveView(incomplete, focus({ tabUrl: 'https://b.com/x?name=Jane' }), brokers), 'guidance'); + expect(v.item.pageType).toBe('results'); + }); + + it('details page (different pathname) → verdict', () => { + const v = expectView(deriveView(incomplete, focus({ tabUrl: DETAILS_URL }), brokers), 'verdict'); + expect(v.item.pageType).toBe('details'); + }); + + it("null tab URL → offsite (can't confirm the tab is on the broker)", () => { + expect(deriveView(incomplete, focus({ tabUrl: null }), brokers).view).toBe('offsite'); + }); + + it('carries the broker exposes + guidance into the active-item payload', () => { + const bk = broker({ search: { url: 'https://b.com/x', requires: [], exposes: ['full name', 'age'], guidance: 'Scroll past the sponsored results.' } }); + const v = expectView(deriveView(incomplete, focus({ tabUrl: DETAILS_URL }), [bk]), 'verdict'); + expect(v.item.exposes).toEqual(['full name', 'age']); + expect(v.item.guidance).toBe('Scroll past the sponsored results.'); + expect(v.item.renderedUrl).toBe('https://b.com/x'); + }); + + it('omits guidance when the broker defines none', () => { + const v = expectView(deriveView(incomplete, focus({ tabUrl: DETAILS_URL }), brokers), 'verdict'); + expect(v.item.guidance).toBeUndefined(); + }); + + it('unknown broker (not in the dataset) → empty exposes, still derives', () => { + const v = expectView(deriveView(incomplete, focus({ tabUrl: DETAILS_URL }), []), 'verdict'); + expect(v.item.exposes).toEqual([]); + expect(v.item.guidance).toBeUndefined(); + }); +}); + +describe('deriveView — revisit', () => { + it('a lone deferred item, nothing focused → revisit (not no-run)', () => { + const v = expectView(deriveView(run([item({ status: 'deferred' })]), null, brokers), 'revisit'); + expect(v.waiting).toBe(1); + expect(v.focusId).toBe('b:primary'); + expect(v.progress).toEqual({ done: 0, total: 1, hits: 0 }); + }); + + // Finding #2: selectBatch claims deferred brokers, so a pending AKA behind a deferred + // sibling has no open tab to act on. focus=null must route to revisit, never no-run. + it('pending blocked behind a deferred sibling broker, focus=null → revisit', () => { + const r = run([ + item({ id: 'b:primary', status: 'deferred' }), + item({ id: 'b:aka_0', nameVariant: 'aka_0', status: 'pending' }), + ]); + const v = deriveView(r, null, brokers); + expect(v.view).not.toBe('no-run'); + const rv = expectView(v, 'revisit'); + expect(rv.waiting).toBe(2); + expect(rv.focusId).toBe('b:primary'); // first deferred, not the blocked pending sibling + }); + + it('focusId falls back to the first pending when no deferred remain', () => { + const v = expectView(deriveView(run([item({ status: 'pending' })]), null, brokers), 'revisit'); + expect(v.focusId).toBe('b:primary'); + }); + + it('focus present but on a non-broker tab (item=null), run incomplete → revisit', () => { + const r = run([item({ status: 'open' })]); + const v = deriveView(r, { item: null, tabUrl: 'https://mail.example/inbox', challenge: false }, brokers); + expect(v.view).toBe('revisit'); + }); + + it('challenge flag without a mapped item does not force challenge → revisit', () => { + const r = run([item({ status: 'open' })]); + expect(deriveView(r, { item: null, tabUrl: null, challenge: true }, brokers).view).toBe('revisit'); + }); + + it('excludes missing: skips from the waiting count', () => { + const r = run([ + item({ id: 'b:primary', status: 'deferred' }), + item({ id: 'b:aka_0', nameVariant: 'aka_0', status: 'verdicted', verdict: 'skipped', skipReason: 'missing:city' }), + ]); + const v = expectView(deriveView(r, null, brokers), 'revisit'); + expect(v.waiting).toBe(1); + expect(v.progress.total).toBe(1); + }); +}); + +describe('deriveView — results↔details boundary', () => { + const incomplete = run([item({ status: 'open' })]); + + it('flips guidance↔verdict on the tab URL alone', () => { + expect(deriveView(incomplete, focus({ tabUrl: 'https://b.com/x?q=1' }), brokers).view).toBe('guidance'); + expect(deriveView(incomplete, focus({ tabUrl: 'https://b.com/x/2' }), brokers).view).toBe('verdict'); + }); +}); + +describe('deriveView — offsite (tab left the broker host)', () => { + const incomplete = run([item({ status: 'open' })]); // makeItem renderedUrl = https://b.com/x + + it('an off-host tab → offsite, not verdict (no confirming a listing off the broker)', () => { + const v = expectView(deriveView(incomplete, focus({ tabUrl: 'https://www.google.com/' }), brokers), 'offsite'); + expect(v.item.itemId).toBe('b:primary'); + }); + + it('a lookalike host with the results pathname is still offsite (checks host, not just path)', () => { + // renderedUrl pathname is /x; an off-host page at /x must not read as the results page. + expect(deriveView(incomplete, focus({ tabUrl: 'https://evil.example/x' }), brokers).view).toBe('offsite'); + }); + + it('an unparseable tab URL → offsite (garbage host can\'t be confirmed on the broker)', () => { + expect(deriveView(incomplete, focus({ tabUrl: 'not a url' }), brokers).view).toBe('offsite'); + }); + + it('challenge still wins even when off-host (Cloudflare interstitial)', () => { + expect(deriveView(incomplete, focus({ tabUrl: 'https://challenges.cloudflare.com/x', challenge: true }), brokers).view).toBe('challenge'); + }); + + it('on-host pages are unaffected: results → guidance, details → verdict', () => { + expect(deriveView(incomplete, focus({ tabUrl: 'https://b.com/x?q=1' }), brokers).view).toBe('guidance'); + expect(deriveView(incomplete, focus({ tabUrl: 'https://b.com/find/1' }), brokers).view).toBe('verdict'); + }); +}); + +describe('deriveView — only ever returns resting views', () => { + const resting = new Set(['no-run', 'guidance', 'verdict', 'challenge', 'offsite', 'revisit', 'done', 'stopped']); + const cases: Array<[RunOrNull, SidebarFocus | null]> = [ + [null, null], + [run([item({ status: 'verdicted', verdict: 'hit' })]), focus()], + [run([item({ status: 'open' })]), focus({ challenge: true })], + [run([item({ status: 'open' })]), focus()], + [run([item({ status: 'open' })]), focus({ tabUrl: DETAILS_URL })], + [run([item({ status: 'open' })]), focus({ tabUrl: 'https://www.google.com/' })], + [run([item({ status: 'deferred' })]), null], + [run([item({ status: 'verdicted', verdict: 'skipped', skipReason: 'run_stopped' })]), null], + ]; + it('never emits the transient saving/recorded states', () => { + for (const [r, f] of cases) { + expect(resting.has(deriveView(r, f, brokers).view)).toBe(true); + } + }); +}); + +type RunOrNull = ReturnType | null; diff --git a/src/sidebar/state.ts b/src/sidebar/state.ts new file mode 100644 index 0000000..d9e6715 --- /dev/null +++ b/src/sidebar/state.ts @@ -0,0 +1,107 @@ +// Pure sidebar view-derivation — no DOM, no browser, no side effects (mirrors +// coordinator.ts / classify.ts). Maps a run snapshot + the focused tab's context to a +// resting SidebarView, so the sidebar's display logic is unit-testable in isolation. +// The UI layer (index.ts, Slice 6) owns rendering and the transient saving/recorded states. + +import type { RunState, WorkItem, SidebarView, ActiveItemInfo, PageType, RunProgress } from '../shared/types'; +import { BROKERS, type Broker } from '../shared/brokers'; +import { isResultsPage, isOnHost } from '../shared/url'; +import { progressOf, isComplete } from '../background/coordinator'; + +// The focused tab's contribution to the view: the work item it maps to (null if the active +// tab isn't a tracked broker tab), its current URL (parsed for results-vs-details), and +// whether it's showing a bot-challenge. Background builds this from the active tab. +export interface SidebarFocus { + item: WorkItem | null; + tabUrl: string | null; + challenge: boolean; +} + +// Derive the resting sidebar view. Precedence (highest first): +// 1. no run → no-run +// 2. run complete → done +// 3. focused item + challenge → challenge +// 4. focused item + results page → guidance +// 5. focused item + details page → verdict +// 6. nothing actionable focused → revisit (work still waiting) +// `brokers` is injectable for tests (mirrors buildItems); the active-item views pull the +// broker's exposes/guidance from it. +export function deriveView( + run: RunState | null, + focus: SidebarFocus | null, + brokers: readonly Broker[] = BROKERS, +): SidebarView { + if (!run) return { view: 'no-run' }; + + const progress = progressOf(run); + if (isComplete(run)) { + // A stopped run is "complete" (nothing pending/open/deferred), but its run_stopped items + // were abandoned by the Stop, not checked. Show an honest `stopped` summary whose `checked` + // count excludes them (they're all verdicted, so they sit inside progress.total). + const stoppedCount = run.items.filter(i => i.skipReason === 'run_stopped').length; + if (stoppedCount > 0) { + return { view: 'stopped', checked: progress.total - stoppedCount, total: progress.total, hits: progress.hits }; + } + return { view: 'done', progress }; + } + + // A focused broker tab → show its active-item detail. Challenge outranks everything: a CAPTCHA + // hides the listing (and its interstitial is often off-host), so there's nothing to judge yet. + if (focus?.item) { + const item = activeItemInfo(focus.item, focus.tabUrl, progress, brokers); + if (focus.challenge) return { view: 'challenge', item }; + // The verdict/guidance controls act on the broker's OWN page — never let them apply to an + // off-host page the tab wandered to (address bar, a link, a redirect), or a listing could be + // "confirmed" on, e.g., google.com. Gate both on the tab being on the broker's host. + if (!isOnHost(focus.tabUrl ?? '', focus.item.renderedUrl)) return { view: 'offsite', item }; + return item.pageType === 'results' + ? { view: 'guidance', item } + : { view: 'verdict', item }; + } + + // Nothing actionable is focused, but the run isn't done — work is waiting. Covers the + // deferred pile and (Slice-1 review finding #2) pending items stranded behind a deferred + // sibling broker: no open tab exists to act on, yet the run isn't complete. `waiting` is the + // count of non-terminal items (== total − done, since missing: skips are already excluded + // from both). `focusId` is the item the revisit button jumps to — first deferred, else first + // pending (the blocked-behind-deferred case; FOCUS_ITEM's ensureItemTab opens a pending one). + // Carried in the view so the sidebar needn't re-fetch run state to find it. + const focusId = + run.items.find(i => i.status === 'deferred')?.id ?? + run.items.find(i => i.status === 'pending')?.id ?? + null; + return { view: 'revisit', waiting: progress.total - progress.done, focusId, progress }; +} + +// Assemble the render payload for a focused broker item: identity + rendered URL from the +// item, exposes/guidance from the broker record, page-type derived from the tab URL. +function activeItemInfo( + item: WorkItem, + tabUrl: string | null, + progress: RunProgress, + brokers: readonly Broker[], +): ActiveItemInfo { + const broker = brokers.find(b => b.id === item.brokerId); + const pageType: PageType = + isResultsPage(pathnameOf(tabUrl), item.renderedUrl) ? 'results' : 'details'; + return { + itemId: item.id, + brokerId: item.brokerId, + exposes: broker?.search.exposes ?? [], + ...(broker?.search.guidance ? { guidance: broker.search.guidance } : {}), + renderedUrl: item.renderedUrl, + pageType, + progress, + }; +} + +// The pathname of a full URL, or '' if it's null/unparseable. '' never matches a rendered +// search URL's pathname, so a missing tab URL falls through to the details/verdict view. +function pathnameOf(url: string | null): string { + if (!url) return ''; + try { + return new URL(url).pathname; + } catch { + return ''; + } +} diff --git a/src/sidebar/style.css b/src/sidebar/style.css new file mode 100644 index 0000000..5f88a5a --- /dev/null +++ b/src/sidebar/style.css @@ -0,0 +1,293 @@ +@import "../styles/tokens.css"; + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { height: 100%; } + +body { + font-family: var(--font-ui); + font-size: var(--fs-small); + line-height: var(--lh-small); + background: var(--bg); + color: var(--text); + display: flex; + flex-direction: column; +} + +/* ── header ──────────────────────────────────────────────────────────────── */ + +header { + padding: var(--s-3) var(--s-4); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.xpg-wordmark { + font-family: var(--font-display); + font-weight: var(--fw-semibold); + font-size: 20px; + letter-spacing: var(--tracking-display); + color: var(--text); + line-height: 1; +} +.xpg-strip { + position: relative; + isolation: isolate; + color: var(--strip-knockout); + margin: 0 0.065em 0 0.05em; +} +.xpg-strip::before { + content: ""; + position: absolute; + z-index: -1; + left: -0.02em; right: -0.035em; + top: 0.24em; bottom: -0.05em; + background: var(--strip-bg); + border-radius: var(--r-strip); + border-top: 0.045em dashed var(--bg); + border-bottom: 0.045em dashed var(--bg); + box-sizing: border-box; +} + +.progress-line { + font-family: var(--font-mono); + font-size: var(--fs-mono); + letter-spacing: 0.05em; + color: var(--text-muted); + margin-top: var(--s-2); + min-height: 1em; +} + +/* ── detail ──────────────────────────────────────────────────────────────── */ + +#detail { padding: var(--s-4); flex-shrink: 0; } + +/* ── detail: labels, chips, copy ─────────────────────────────────────────── */ + +.label { + font-family: var(--font-mono); + font-size: var(--fs-mono); + text-transform: uppercase; + letter-spacing: var(--tracking-mono); + color: var(--text-muted); + margin-bottom: var(--s-2); +} + +.exposes { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: var(--s-1); + margin-bottom: var(--s-3); +} +.exposes li { + background: var(--fill); + color: var(--text-muted); + border-radius: var(--r-pill); + padding: 2px var(--s-2); + font-size: var(--fs-small); +} + +.question { + font-size: var(--fs-body); + color: var(--text); + margin-bottom: var(--s-3); + line-height: var(--lh-body); +} + +.guidance-msg { + font-size: var(--fs-small); + color: var(--text-muted); + margin-bottom: var(--s-3); + line-height: var(--lh-small); +} + +.empty { font-size: var(--fs-body); color: var(--text); margin-bottom: var(--s-2); } +.empty-sub { font-size: var(--fs-small); color: var(--text-muted); line-height: var(--lh-small); margin-bottom: var(--s-3); } + +/* ── buttons ─────────────────────────────────────────────────────────────── */ + +button { + font-family: var(--font-ui); + font-size: var(--fs-small); + cursor: pointer; + min-height: 44px; + border-radius: var(--r-control); + transition: background 0.12s; +} +button:disabled { opacity: 0.5; cursor: not-allowed; } +button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus-ring-shadow); } + +.wide { display: block; width: 100%; } + +.verdicts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--s-2); +} + +.btn-hit { + background: var(--primary); + color: var(--on-primary); + border: none; + font-weight: var(--fw-semibold); +} +.btn-hit:hover:not(:disabled) { background: var(--primary-hover); } + +.btn-clear { + background: transparent; + color: var(--primary); + border: 1.5px solid var(--primary); + font-weight: var(--fw-semibold); +} +.btn-clear:hover:not(:disabled) { background: var(--fill); } + +.btn-unknown { + background: transparent; + color: var(--primary); + border: none; +} +.btn-unknown:hover:not(:disabled) { background: var(--fill); } + +.btn-skip { + background: transparent; + color: var(--text-faint); + border: none; +} +.btn-skip:hover:not(:disabled) { background: var(--fill); } + +.btn-primary { + background: var(--primary); + color: var(--on-primary); + border: none; + font-weight: var(--fw-semibold); + padding: var(--s-3) var(--s-4); + margin-top: var(--s-2); +} +.btn-primary:hover:not(:disabled) { background: var(--primary-hover); } +.btn-primary:disabled { background: var(--fill); color: var(--text-faint); } + +.btn-secondary { + background: transparent; + color: var(--primary); + border: 1.5px solid var(--primary); + font-weight: var(--fw-semibold); + padding: var(--s-2) var(--s-4); +} +.btn-secondary:hover:not(:disabled) { background: var(--fill); } + +.btn-quiet { + background: none; + border: none; + color: var(--text-muted); + padding: var(--s-2); +} +.btn-quiet:hover:not(:disabled) { background: var(--fill); } + +/* ── defer control ───────────────────────────────────────────────────────── */ + +.defer { + margin-top: var(--s-4); + padding-top: var(--s-3); + border-top: 1px dashed var(--border); +} +.defer-note { + font-size: var(--fs-small); + color: var(--text-faint); + margin-bottom: var(--s-2); + line-height: var(--lh-small); +} + +/* ── paste fallback ──────────────────────────────────────────────────────── */ + +.paste { margin-top: var(--s-3); } +.paste-input { + width: 100%; + padding: var(--s-2) var(--s-3); + background: var(--input-bg); + border: 1.5px solid var(--input-border); + border-radius: var(--r-control); + color: var(--text); + font-family: var(--font-ui); + font-size: var(--fs-small); + margin-bottom: var(--s-2); +} +.paste-input:focus { outline: none; border-color: var(--focus-ring); box-shadow: 0 0 0 3px var(--focus-ring-shadow); } + +/* Off-host paste warning — a single small accent detail (STYLEGUIDE §2), never a blocker. */ +.paste-warning { + color: var(--accent); + font-size: var(--fs-small); + font-weight: var(--fw-medium); + line-height: var(--lh-small); + margin-bottom: var(--s-2); +} + +/* ── transient status ────────────────────────────────────────────────────── */ + +@keyframes spin { to { transform: rotate(360deg); } } + +.status { font-size: var(--fs-small); text-align: center; padding: var(--s-4) 0; } +.status.saving { + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + gap: var(--s-2); +} +.spinner { + width: 12px; + height: 12px; + border: 1.5px solid var(--fill); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +.status.recorded { color: var(--primary); font-weight: var(--fw-semibold); } +.status.error { color: var(--text-muted); font-weight: var(--fw-medium); } + +/* ── checklist ───────────────────────────────────────────────────────────── */ + +#checklist { + padding: 0 var(--s-4) var(--s-4); + overflow-y: auto; + flex: 1; + border-top: 1px solid var(--border); +} + +.group-label { + font-family: var(--font-mono); + font-size: var(--fs-mono); + text-transform: uppercase; + letter-spacing: var(--tracking-mono); + color: var(--text-faint); + margin: var(--s-4) 0 var(--s-2); +} + +.rows { list-style: none; } + +.row { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-2) var(--s-2); + border-radius: var(--r-control); + min-height: 36px; +} +.row-name { color: var(--text); } +.row-tag { + font-family: var(--font-mono); + font-size: var(--fs-mono); + color: var(--text-faint); +} +.row-hit { + margin-left: auto; + font-family: var(--font-mono); + font-size: var(--fs-mono); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--accent); +} +.row.clickable { cursor: pointer; } +.row.clickable:hover { background: var(--fill); } +.row.clickable:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus-ring-shadow); } diff --git a/src/styles/fonts.css b/src/styles/fonts.css new file mode 100644 index 0000000..483589b --- /dev/null +++ b/src/styles/fonts.css @@ -0,0 +1,29 @@ +/* expurge — self-hosted fonts (privacy: no external CDN request when a surface opens). + Loaded by popup, options, and sidebar via a plain so the woff2 url()s resolve + against dist/ (esbuild never sees these — fonts.css is copied verbatim, not bundled). + Latin-subset woff2 from Google Fonts; glyphs outside the subset fall back to the system + stacks in tokens.css. All three families are SIL OFL 1.1 — see fonts/LICENSE.md. */ + +@font-face { + font-family: "Hanken Grotesk"; + font-style: normal; + font-weight: 400 600; /* variable wght axis — one file covers 400/500/600 */ + font-display: swap; + src: url("fonts/hanken-grotesk.woff2") format("woff2"); +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("fonts/ibm-plex-mono-400.woff2") format("woff2"); +} + +@font-face { + font-family: "Newsreader"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("fonts/newsreader-600.woff2") format("woff2"); +} diff --git a/src/styles/fonts/LICENSE.md b/src/styles/fonts/LICENSE.md new file mode 100644 index 0000000..6abbaa8 --- /dev/null +++ b/src/styles/fonts/LICENSE.md @@ -0,0 +1,113 @@ +# Bundled fonts + +expurge self-hosts these fonts so no surface (popup, options, sidebar) makes an +external request to a font CDN when opened. Each is licensed under the SIL Open +Font License, Version 1.1 (OFL-1.1); redistribution here is permitted. + +Latin-subset woff2 files, retrieved via the Google Fonts `css2` API: + +- **Hanken Grotesk** (`hanken-grotesk.woff2`, variable wght axis) — + Copyright 2021 The Hanken Grotesk Project Authors + (https://github.com/marcologous/hanken-grotesk) +- **IBM Plex Mono** (`ibm-plex-mono-400.woff2`) — + Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + (https://github.com/IBM/plex) +- **Newsreader** (`newsreader-600.woff2`) — + Copyright 2020 The Newsreader Project Authors + (https://github.com/productiontype/Newsreader) + +Full license text (identical for all three) follows. + +--- + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/styles/fonts/hanken-grotesk.woff2 b/src/styles/fonts/hanken-grotesk.woff2 new file mode 100644 index 0000000..518e2c4 Binary files /dev/null and b/src/styles/fonts/hanken-grotesk.woff2 differ diff --git a/src/styles/fonts/ibm-plex-mono-400.woff2 b/src/styles/fonts/ibm-plex-mono-400.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/src/styles/fonts/ibm-plex-mono-400.woff2 differ diff --git a/src/styles/fonts/newsreader-600.woff2 b/src/styles/fonts/newsreader-600.woff2 new file mode 100644 index 0000000..d72ee9c Binary files /dev/null and b/src/styles/fonts/newsreader-600.woff2 differ diff --git a/vitest.config.ts b/vitest.config.ts index 894281f..2a57b85 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,9 +22,11 @@ export default defineConfig({ 'src/shared/types.ts', // type-only, no runtime 'src/test-support/**', // shared test fixtures 'src/background/index.ts', // entrypoint: message dispatch / storage I/O — integration-test TODO - 'src/content/index.ts', // entrypoint: overlay DOM injection — integration-test TODO + 'src/content/index.ts', // entrypoint: headless challenge reporter (DOM observer) — integration-test TODO 'src/options/index.ts', // entrypoint: form/nav wiring — integration-test TODO 'src/popup/index.ts', // entrypoint: thin popup render — integration-test TODO + 'src/sidebar/index.ts', // entrypoint: sidebar render layer / message wiring — integration-test TODO + // (its pure parts live in state.ts + paste.ts, which ARE covered) ], // The html tree is dev-only; CI never uploads it (coverage/ is gitignored). reporter: process.env['CI'] ? ['text'] : ['text', 'html'], diff --git a/wherefore/INDEX.md b/wherefore/INDEX.md index 7b1494f..5061a63 100644 --- a/wherefore/INDEX.md +++ b/wherefore/INDEX.md @@ -44,3 +44,4 @@ - 2026-06-30 | 2026-06-30-vitest-test-runner | Vitest for unit tests; e2e deferred | areas: (none) | topics: testing, webextensions | stories: [] | active - 2026-07-01 | 2026-07-01-test-suite-buildout | Test suite: extract pure logic, cover shipped code | areas: (none) | topics: testing, webextensions | stories: [] | active - 2026-07-01 | 2026-07-01-sidebar-run-navigation | Sidebar replaces on-page overlay for run nav | areas: matching-overlay, run-model, broker-dataset | topics: ux, webextensions, privacy | stories: [] | active +- 2026-07-01 | 2026-07-01-sidebar-nav-built | Sidebar-nav built, reviewed, QA'd | areas: matching-overlay, run-model | topics: ux, webextensions, privacy | stories: [] | active diff --git a/wherefore/QUESTIONS.md b/wherefore/QUESTIONS.md index 30b7449..4aa45cc 100644 --- a/wherefore/QUESTIONS.md +++ b/wherefore/QUESTIONS.md @@ -15,3 +15,4 @@ - Q-013 | resolved | 2026-06-30 | 2026-06-30-overlay-tab-vs-overlay | Should the overlay UI be a tab/panel alongside the page instead of over top of it? | areas: matching-overlay - Q-014 | open | 2026-06-30 | 2026-06-30-vitest-test-runner | How to run true Firefox-runtime extension e2e (Playwright loads Chromium extensions only; web-ext/geckodriver not set up)? | areas: (none) - Q-015 | resolved | 2026-07-01 | 2026-07-01-sidebar-run-navigation | Does sidebarAction.open() require a user gesture, and does a synchronous call in the Start handler satisfy it? | areas: matching-overlay, run-model +- Q-016 | open | 2026-07-01 | 2026-07-01-sidebar-nav-built | Model challenge state as content-script-owned structural state instead of side-channel per-tab session keys? | areas: matching-overlay, run-model diff --git a/wherefore/log/2026-07-01-sidebar-nav-built.md b/wherefore/log/2026-07-01-sidebar-nav-built.md new file mode 100644 index 0000000..4534054 --- /dev/null +++ b/wherefore/log/2026-07-01-sidebar-nav-built.md @@ -0,0 +1,36 @@ +--- +date: 2026-07-01 +title: "Sidebar-nav built, reviewed, QA'd" +areas: [matching-overlay, run-model] +topics: [ux, webextensions, privacy] +stories: [] +status: active +supersedes: +superseded-by: +superseded-date: +--- + +## Summary +Built the overlay→sidebar migration (design in 2026-07-01-sidebar-run-navigation) across ~26 commits on `feat/sidebar-nav`, then ran an extra-high multi-agent code review and Firefox QA. Several decisions emerged during build/review that the pre-build design didn't have; captured here. 164 tests green; branch PR-ready bar a deferred challenge-flag redesign. + +## Decisions / outcomes +- **Eight sidebar views, all pure-derived by `deriveView`** — the design's six plus **`stopped`** (honest "checked X of Y", excludes abandoned `run_stopped` items; distinct from `done`'s "all clear") and **`offsite`** (no verdict controls when the broker tab wanders off-host, so a listing can't be "confirmed" on google.com; gated on `isOnHost`, which also catches a lookalike host sitting at the results pathname). +- **Interactive clickable checklist** via one `FOCUS_ITEM{itemId}` message + a pure `promoteToOpen` (deferred→open) transition — rows jump to a tab, revisit = FOCUS_ITEM on the first deferred. The sidebar exceeds the old overlay. +- **Challenge state is content-script per-load authoritative**: the content script reports DETECTED/RESOLVED on *every* load; background never infers challenge from navigation. This fixed an on-host Cloudflare interstitial race that briefly showed verdict controls over a "checking your browser" page. +- **Verdict ACK/retry contract restored in the sidebar** (`sendVerdictAck`: 6s/3-retry, never "recorded" without an ACK) + a `handleVerdict` no-wedge guard for idempotent retries. The content-strip had silently dropped the CLAUDE.md ack contract — a HIGH-severity data-loss bug the extra-high review caught but the per-slice reviews missed. +- **Sticky-view contract**: don't push a sidebar update when focus moves to a non-broker tab; checklist is window-scoped (cleared on `no-run`). Sidebar refreshes on external mutations (Stop→`stopped`, Delete-all→`no-run`). +- **Privacy hardening**: self-hosted the fonts (dropped the Google Fonts CDN across all surfaces); removed the now-dead `scripting` and `webNavigation` permissions. +- **Sidebar UX**: `open_at_install:false` (no auto-open on load) + a "Show scan panel" re-open button. Q-015 empirically confirmed (double gesture works in Firefox 140). + +## Why +Building surfaced states the design underspecified: a stopped run isn't "done" (don't claim "all clear"); an off-host tab must not offer verdict controls (a bogus hit / wrong opt-out target). The challenge race and the dropped ACK contract both came from splitting the old overlay's logic across background + a stripped content script. The review's recall pass (10 finder angles over the whole branch) caught the ACK data-loss bug precisely because per-slice reviews trust each slice's own framing — an independent whole-branch pass doesn't. + +## Alternatives considered +- **Show `done` after a Stop** — rejected: overclaims ("all clear") when the user abandoned items; added the honest `stopped` view. +- **Fix the off-host verdict-view via the challenge flag alone** — insufficient (off-host non-challenge pages, lookalike hosts); gated on `isOnHost` structurally instead. +- **Guess challenge state from `tabs.onUpdated`** — rejected: misfired on on-host interstitials, clearing the flag the content script had just set on the same load. + +## Open questions / follow-ups +- Q-016: Should challenge state be modeled as content-script-owned structural state (one signal) instead of side-channel `expurge_challenge_` session keys cleared by four disconnected paths? (Deferred review cluster, plus the in-place challenge-reappearance gap.) +- Minor review cleanup deferred (not blockers): checklist fetch-race, redundant progress refetch, `isMissing` duplicated ×4, no push-after-mutation choke-point, wordmark rendered <24px. +- See also: 2026-07-01-sidebar-run-navigation (the pre-build design + rationale). diff --git a/wherefore/questions/Q-015.md b/wherefore/questions/Q-015.md index cf710bd..ef91eb0 100644 --- a/wherefore/questions/Q-015.md +++ b/wherefore/questions/Q-015.md @@ -5,6 +5,6 @@ status: resolved areas: [matching-overlay, run-model] asked_date: 2026-07-01 asked_slug: 2026-07-01-sidebar-run-navigation -resolution: Yes — MDN: sidebarAction.open() may only be called from inside a user-action handler and opens in the ACTIVE window. Resolved by design (as Q-009): call it synchronously first in the Start-run click handler, before the async START_RUN, and pin the run to that active window. Empirical sync-ordering smoke test remains at build time. +resolution: Yes — confirmed. MDN: sidebarAction.open() may only be called from a user-action handler and opens in the ACTIVE window. Resolved by design (as Q-009): called synchronously first in the options Start-run click handler, before the async START_RUN, with the run pinned to that active window. Empirically confirmed in Firefox 140+ (2026-07-01 QA): a single Start click drove BOTH sidebarAction.open() AND permissions.request() — the host-permission prompt appeared, the sidebar opened, and the broker tab held until the grant. No reorder needed. resolution_slug: 2026-07-01-sidebar-run-navigation --- diff --git a/wherefore/questions/Q-016.md b/wherefore/questions/Q-016.md new file mode 100644 index 0000000..4b769fa --- /dev/null +++ b/wherefore/questions/Q-016.md @@ -0,0 +1,10 @@ +--- +id: Q-016 +question: Should challenge state be modeled as content-script-owned structural state (one signal) instead of side-channel expurge_challenge_ session keys cleared by four disconnected paths? +status: open +areas: [matching-overlay, run-model] +asked_date: 2026-07-01 +asked_slug: 2026-07-01-sidebar-nav-built +resolution: +resolution_slug: +---