From 5398e5aad3a2df934723fd500889d7169e575bcc Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 11:23:48 -0400 Subject: [PATCH 01/35] feat(coordinator): deferred status, applyDefer, MAX_OPEN_TABS ceiling, shared completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of the sidebar-nav migration (plan/sidebar-nav.md §4) — the pure coordinator groundwork, no UI yet. - types: add non-terminal `deferred` to WorkItemStatus (tab open but set aside; frees its batch slot, revisited at run end). - applyDefer(run, itemId): open → deferred only; never re-defers a pending item and never overrides a verdict (no-wedge). - selectBatch: count only `open` against the batch window (deferred freed its slot) but cap `open + deferred` at MAX_OPEN_TABS = 15 so a defer-everything run can't open the whole broker list; claim deferred brokers too (a deferred tab is still a live tab — no second variant). - applyStop now sweeps deferred items into run_stopped. - isComplete / progressOf: one shared definition of done (no pending/open/ deferred remain) and progress (deferred counts toward total, not done) for popup, options, and the sidebar to consume in later slices. +16 tests; suite 116 green, typecheck + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/background/coordinator.test.ts | 149 ++++++++++++++++++++++++++++- src/background/coordinator.ts | 66 +++++++++++-- src/shared/types.ts | 4 +- 3 files changed, 209 insertions(+), 10 deletions(-) diff --git a/src/background/coordinator.test.ts b/src/background/coordinator.test.ts index b191d18..f5aa425 100644 --- a/src/background/coordinator.test.ts +++ b/src/background/coordinator.test.ts @@ -3,10 +3,14 @@ import { buildItems, withVerdict, applySkip, + applyDefer, applyStop, applyMarkSent, + isComplete, + progressOf, selectBatch, 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 +112,106 @@ 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('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 +275,58 @@ 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); }); }); diff --git a/src/background/coordinator.ts b/src/background/coordinator.ts index 5ec9559..cf281a8 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,27 @@ 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, + ), + }; +} + +// 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 +139,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; diff --git a/src/shared/types.ts b/src/shared/types.ts index 7201964..7b70e01 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}" From 768d1d30fffa413c7badd590c040f8ffd1db8544 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 11:42:32 -0400 Subject: [PATCH 02/35] docs: carry Slice-1 review findings into sidebar-nav plan --- plan/sidebar-nav.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/plan/sidebar-nav.md b/plan/sidebar-nav.md index eec5074..a732f32 100644 --- a/plan/sidebar-nav.md +++ b/plan/sidebar-nav.md @@ -90,12 +90,19 @@ No new `permissions` — `sidebarAction` is available whenever `sidebar_action` 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`** — 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). +- **`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:** + +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) From 508e6da6ce8cfc52ea29f891088b9727f8270600 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 11:45:35 -0400 Subject: [PATCH 03/35] feat(types): sidebar/challenge messages + broker search.guidance; move url helpers to shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of the sidebar-nav migration (plan/sidebar-nav.md §6/§7) — the pure data-model foundations. Additive/mechanical, no runtime behavior change. types.ts: - New inbound messages: SIDEBAR_GET_STATE, DEFER, NAVIGATE_BROKER_TAB (sidebar → background, all carry the pinned windowId) and CHALLENGE_DETECTED / CHALLENGE_RESOLVED (headless content → background). Added to ToBackground. - Optional windowId on VerdictMsg + CloseTabMsg (the sidebar isn't in a broker tab, so sender.tab can't identify the run). - Optional guidance on ItemInfoMsg. - Ping/Pong/Reinj kept — their consumers (content PING listener, background handlers, popup/options Restore-overlay) are gutted in later slices. - SidebarView/SidebarUpdateMsg intentionally deferred to Slice 3, designed with the state.ts machine that produces them. brokers.ts: optional search.guidance (generic, PII-free, results-state only); TruePeopleSearch left unset — only ever populated with an accurate note. url helpers: move isResultsPage + brokerHostname out of content/classify.ts into new src/shared/url.ts (background will classify page-type too); detectChallenge stays. Tests relocated to src/shared/url.test.ts. typecheck + 116 tests + build all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/content/classify.test.ts | 28 +--------------------------- src/content/classify.ts | 21 --------------------- src/content/index.ts | 3 ++- src/shared/brokers.ts | 1 + src/shared/types.ts | 23 ++++++++++++++++++++--- src/shared/url.test.ts | 28 ++++++++++++++++++++++++++++ src/shared/url.ts | 25 +++++++++++++++++++++++++ 7 files changed, 77 insertions(+), 52 deletions(-) create mode 100644 src/shared/url.test.ts create mode 100644 src/shared/url.ts 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..eb14908 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,6 +1,7 @@ import browser from 'webextension-polyfill'; import type { Verdict, ItemInfoMsg } from '../shared/types'; -import { detectChallenge, isResultsPage, brokerHostname } from './classify'; +import { detectChallenge } from './classify'; +import { isResultsPage, brokerHostname } from '../shared/url'; // ── Shadow DOM overlay ─────────────────────────────────────────────────────── // The overlay NEVER injects the user's profile data into the page DOM. 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 7b70e01..acf45db 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -61,13 +61,28 @@ export interface StartRunMsg { type: 'START_RUN'; profile: Profile } 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 } +export interface NavigateBrokerTabMsg { type: 'NAVIGATE_BROKER_TAB'; windowId: number; 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 ───────────────────────────────────── @@ -76,6 +91,7 @@ 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 }; } @@ -88,4 +104,5 @@ export interface StopRunMsg { type: 'STOP_RUN' } export type ToBackground = | StartRunMsg | GetRunStateMsg | GetDraftMsg | GetItemMsg | VerdictMsg | ReverdictMsg - | PingMsg | ReinjMsg | StopRunMsg | SaveProfileMsg | GetProfileMsg | MarkSentMsg | DeleteAllMsg | CloseTabMsg; + | PingMsg | ReinjMsg | StopRunMsg | SaveProfileMsg | GetProfileMsg | MarkSentMsg | DeleteAllMsg | CloseTabMsg + | SidebarGetStateMsg | DeferMsg | NavigateBrokerTabMsg | ChallengeDetectedMsg | ChallengeResolvedMsg; diff --git a/src/shared/url.test.ts b/src/shared/url.test.ts new file mode 100644 index 0000000..cbb2ebf --- /dev/null +++ b/src/shared/url.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { isResultsPage, brokerHostname } 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(''); + }); +}); diff --git a/src/shared/url.ts b/src/shared/url.ts new file mode 100644 index 0000000..eba7a2b --- /dev/null +++ b/src/shared/url.ts @@ -0,0 +1,25 @@ +// 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 ''; + } +} From 4a79712322e3edd16b1176be8c4d97e7d43957ba Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 12:02:18 -0400 Subject: [PATCH 04/35] =?UTF-8?q?docs:=20add=20missing=20'done'=20view=20t?= =?UTF-8?q?o=20sidebar-nav=20=C2=A73=20(gap=20A);=20note=20resting=20vs=20?= =?UTF-8?q?transient=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plan/sidebar-nav.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plan/sidebar-nav.md b/plan/sidebar-nav.md index a732f32..6e24cb5 100644 --- a/plan/sidebar-nav.md +++ b/plan/sidebar-nav.md @@ -76,7 +76,7 @@ No new `permissions` — `sidebarAction` is available whenever `sidebar_action` - **`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. **Sidebar views** (the `state.ts` tagged union): @@ -87,6 +87,9 @@ No new `permissions` — `sidebarAction` is available whenever `sidebar_action` - `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) +- `done` — run finished (no `pending`/`open`/`deferred` remain): terminal summary from `progressOf` (done / total / hits). Distinct from `no-run` (never started / no run in this window) + +The pure `deriveView` (Slice 3) returns only the six **resting** views — `no-run` / `guidance` / `verdict` / `challenge` / `revisit` / `done`. `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"). From fa1dc0f16df585aecb861f76a7e1a12cb2a59b3c Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 12:05:11 -0400 Subject: [PATCH 05/35] feat(sidebar): pure SidebarView state machine + types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 of the sidebar-nav migration (plan/sidebar-nav.md §3/§5). Pure only — no DOM, no browser, no wiring (mirrors the coordinator.ts / classify.ts convention). The sidebar UI, background coordination, and content strip come in later slices. src/shared/types.ts: - SidebarView discriminated union (tag: `view`). Six resting views — no-run / guidance / verdict / challenge / revisit / done — plus the two transient states saving / recorded (kept for union completeness; the UI layer sets them imperatively around a verdict send, they are never derived). - ActiveItemInfo (the ItemInfoMsg render payload + derived pageType) for the active-item views; PageType; SidebarUpdateMsg. - Extracted RunProgress and reused it in ItemInfoMsg (identical shape → no behavior change) so the sidebar and the message share one definition. src/sidebar/state.ts — deriveView(run, focus, brokers?): pure derivation with precedence no-run > done > challenge > guidance > verdict > revisit. Uses isResultsPage (shared/url) for the results↔details split and progressOf / isComplete (coordinator) for counts/completion. `brokers` is injectable (mirrors buildItems). Reconciled two §3 gaps before coding: - Gap A: added the `done` view §5 referenced but §3 omitted (doc fixed in 4a79712). `no-run` (never started) and `done` (finished) are distinct. - Gap B: saving/recorded are transient, so deriveView returns only resting views; a test asserts it never emits them. Load-bearing (Slice-1 review finding #2): when a pending AKA is stranded behind a deferred sibling broker (selectBatch claims deferred brokers, so no open tab exists to act on) and nothing is focused, deriveView routes to `revisit`, never `no-run`. Covered by an explicit test. +17 tests; suite 133 green, typecheck + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/shared/types.ts | 42 +++++++++- src/sidebar/state.test.ts | 157 ++++++++++++++++++++++++++++++++++++++ src/sidebar/state.ts | 88 +++++++++++++++++++++ 3 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 src/sidebar/state.test.ts create mode 100644 src/sidebar/state.ts diff --git a/src/shared/types.ts b/src/shared/types.ts index acf45db..4f1e421 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -32,6 +32,11 @@ export interface RunState { items: WorkItem[]; } +// 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. @@ -93,7 +98,7 @@ export interface ItemInfoMsg { 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 } @@ -106,3 +111,38 @@ export type ToBackground = | StartRunMsg | GetRunStateMsg | GetDraftMsg | GetItemMsg | VerdictMsg | ReverdictMsg | PingMsg | ReinjMsg | StopRunMsg | SaveProfileMsg | GetProfileMsg | MarkSentMsg | DeleteAllMsg | CloseTabMsg | SidebarGetStateMsg | DeferMsg | 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 } + | { view: 'revisit'; waiting: number; progress: RunProgress } + | { view: 'done'; progress: RunProgress } + | { view: 'saving'; item: ActiveItemInfo } + | { view: 'recorded'; item: ActiveItemInfo }; + +export interface SidebarUpdateMsg { type: 'SIDEBAR_UPDATE'; view: SidebarView } diff --git a/src/sidebar/state.test.ts b/src/sidebar/state.test.ts new file mode 100644 index 0000000..3148fb7 --- /dev/null +++ b/src/sidebar/state.test.ts @@ -0,0 +1,157 @@ +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 — 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 → details/verdict (can\'t confirm results)', () => { + expect(deriveView(incomplete, focus({ tabUrl: null }), brokers).view).toBe('verdict'); + }); + + 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.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); + }); + + 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 — only ever returns resting views', () => { + const resting = new Set(['no-run', 'guidance', 'verdict', 'challenge', 'revisit', 'done']); + 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: 'deferred' })]), 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..eac31ce --- /dev/null +++ b/src/sidebar/state.ts @@ -0,0 +1,88 @@ +// 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 } 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)) return { view: 'done', progress }; + + // A focused broker tab → show its active-item detail. Challenge outranks page-type: a + // CAPTCHA hides the listing, so there's nothing to judge until it clears. + if (focus?.item) { + const item = activeItemInfo(focus.item, focus.tabUrl, progress, brokers); + if (focus.challenge) return { view: 'challenge', 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. Background's + // revisit action reopens/focuses the first waiting item. `waiting` is the count of + // non-terminal items (== total − done, since missing: skips are already excluded from both). + return { view: 'revisit', waiting: progress.total - progress.done, 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 ''; + } +} From b373bc1c4130b43282a958791807d3eedd7a0aa0 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 12:21:03 -0400 Subject: [PATCH 06/35] =?UTF-8?q?docs:=20add=20sticky-view=20contract=20to?= =?UTF-8?q?=20sidebar-nav=20=C2=A75=20(Slice-3=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plan/sidebar-nav.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plan/sidebar-nav.md b/plan/sidebar-nav.md index 6e24cb5..f4b9192 100644 --- a/plan/sidebar-nav.md +++ b/plan/sidebar-nav.md @@ -113,6 +113,7 @@ The **Defer** control is present alongside the active-item detail in `guidance`/ - **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. From 6bf62702de6dd0f60c9e9601d7b7c6102d0c3724 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 12:24:02 -0400 Subject: [PATCH 07/35] feat(content): strip overlay to headless challenge reporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 of the sidebar-nav migration (plan/sidebar-nav.md §6). The sidebar owns all verdict UI (later slices), so the content script no longer paints anything — it's now ~45 lines (was 679) that only report bot-challenge transitions and never touch the page DOM. Removed (~630 lines): OVERLAY_STYLES, createOverlayShell, all panel builders (buildVerdictPanel / buildGuidancePanel / buildChallengePanel / showMainPanel), setOverlayState, verdictMsg, sendVerdict, closeSelfTab, the GET_ITEM init call, the PING listener, and the now-unused imports (Verdict, ItemInfoMsg, isResultsPage, brokerHostname). Kept / new: - Reuses the already-tested detectChallenge() from ./classify. - On load, if a challenge is up: send CHALLENGE_DETECTED, then a MutationObserver on documentElement watches for it clearing → CHALLENGE_RESOLVED + disconnect. The 250 ms dismiss debounce is lifted verbatim from the old buildChallengePanel (guards Turnstile briefly detaching its container mid-transition). - Bare messages; background identifies the tab via sender.tab.id (this runs in the broker tab), same as GET_ITEM did. - Idempotency latch window.__expurgeReporterBound (mirrors the old __expurgePingBound) so re-injection — manifest auto-inject on nav, plus background's still-present reinjectIfMissing until Slice 5 — can't stack observers or emit duplicate CHALLENGE_DETECTED. Parity with the old overlay: challenge-detect on load + observe-until-resolved. Did NOT add the optional mid-run "challenge appears after a clean load" detection (the old overlay only detected on init); can revisit if rate-limiting mid-run needs it. Scope notes: - Background PING/PONG/REINJECT_OVERLAY/GET_ITEM handlers + reinjectIfMissing left intact (removed in Slice 5). During the gap, background PINGs get no response and it re-executeScripts the reporter — harmless (the latch no-ops it), just wasteful. Expected. - The extension is intentionally non-functional end-to-end after this slice: no overlay, and the sidebar UI doesn't exist until Slice 6. Runs open tabs with no verdict UI until Slices 5-6 land. No new tests (thin imperative glue over the already-covered detectChallenge). dist/content.js: 52844 → 35775 bytes (~17KB of overlay code gone; remaining floor is the bundled webextension-polyfill). typecheck + 133 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/content/index.ts | 676 ++----------------------------------------- 1 file changed, 21 insertions(+), 655 deletions(-) diff --git a/src/content/index.ts b/src/content/index.ts index eb14908..3171ec1 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,574 +1,22 @@ import browser from 'webextension-polyfill'; -import type { Verdict, ItemInfoMsg } from '../shared/types'; +import type { ChallengeDetectedMsg, ChallengeResolvedMsg } from '../shared/types'; import { detectChallenge } from './classify'; -import { isResultsPage, brokerHostname } from '../shared/url'; -// ── 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. +// 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: tell the background +// when a broker page is gated behind a bot-challenge, and when that challenge clears. +// Background identifies the tab via sender.tab.id — this script runs in the broker tab. -const OVERLAY_STYLES = ` -:host { - position: fixed; - bottom: 20px; - right: 20px; - z-index: 2147483647; +// If a challenge is up on load, report it and watch for it clearing. A clean load reports +// nothing (parity with the old overlay, which only challenge-detected on init). +function reportChallenges(): void { + if (!detectChallenge()) return; - /* ── 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; - } -} - -// 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(() => {}); + // Watch for the challenge clearing. The 250 ms debounce guards against CAPTCHA widgets + // (notably Turnstile) briefly detaching their container mid-transition, which would read + // as "resolved" for an instant. Lifted from the old buildChallengePanel observer. let dismissTimer: ReturnType | null = null; const observer = new MutationObserver(() => { if (detectChallenge()) { @@ -576,104 +24,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')); - } +// Idempotency guard: the manifest auto-injects on navigation and (until Slice 5) background +// still re-executeScripts on a missing PING. Without this latch we'd stack MutationObservers +// and emit duplicate CHALLENGE_DETECTED. Mirrors the old __expurgePingBound flag. +const w = window as Window & { __expurgeReporterBound?: boolean }; +if (!w.__expurgeReporterBound) { + w.__expurgeReporterBound = true; + reportChallenges(); } - -// ── 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); - } -} - -init(); From 4889191aba301a09eb957a2f5282b83a13700134 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 13:00:48 -0400 Subject: [PATCH 08/35] docs: challenge flag clears on on-host navigation (Slice-4 review) --- plan/sidebar-nav.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plan/sidebar-nav.md b/plan/sidebar-nav.md index f4b9192..e8ffc8d 100644 --- a/plan/sidebar-nav.md +++ b/plan/sidebar-nav.md @@ -117,7 +117,8 @@ The **Defer** control is present alongside the active-item detail in `guidance`/ - **`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`. + - **Also clear the challenge flag on on-host navigation (from the Slice-4 review).** The content script's `MutationObserver` only catches *in-page* clears (e.g. Turnstile solved inline). The common **Cloudflare interstitial resolves by redirecting** to the real page — a navigation, not a DOM mutation — so `CHALLENGE_RESOLVED` **never fires** for it. If the flag only clears on `CHALLENGE_RESOLVED`, the sidebar sticks on the `challenge` view forever after an interstitial clears. Rule: **the tab's challenge flag clears on EITHER `CHALLENGE_RESOLVED` OR a `tabs.onUpdated` complete to an on-host broker page** (reuse the existing off-host guard so the `challenges.cloudflare.com` hop itself doesn't clear it). This is where the old overlay's "re-inject → showMainPanel after redirect" behavior now lives. - **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) From 057f87845d5514515c184fddd6b3bbd7c9e5488a Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 13:08:00 -0400 Subject: [PATCH 09/35] feat(bg): pin run to window (windowId) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5a of the sidebar-nav migration (plan §5). Threads a windowId through the run so batch tabs open in the run's window (the sidebar is a window-level surface). - StartRunMsg gains optional windowId; RunState gains windowId (session-only, but not a recycled-id hazard like tabId, so safe to persist in session storage). - handleStartRun resolves windowId: msg.windowId → sender.tab.windowId → windows.getLastFocused() (§7 will have popup/options pass it explicitly). - openNextBatch creates tabs with { windowId: run.windowId }. typecheck + 133 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/background/index.ts | 16 ++++++++++++---- src/shared/types.ts | 5 ++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/background/index.ts b/src/background/index.ts index 816b8d0..a565cbb 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -88,7 +88,8 @@ 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 }); } @@ -97,12 +98,16 @@ async function openNextBatch(run: RunState, focusFirst = false): Promise { // ── 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); @@ -195,7 +200,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 }; } diff --git a/src/shared/types.ts b/src/shared/types.ts index 4f1e421..15d03f0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -30,6 +30,9 @@ 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 @@ -62,7 +65,7 @@ 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' } From 55f7d0a200aa176e5041121d4585d211a15483da Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 13:10:34 -0400 Subject: [PATCH 10/35] feat(bg): sidebar view plumbing + challenge flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5b of the sidebar-nav migration (plan §5). Background builds SidebarFocus inputs and lets deriveView (sidebar/state.ts) own the view; it never re-derives. - SIDEBAR_GET_STATE{windowId} (PULL): resolve the window's broker tab (prefer active, else any tracked broker tab in the window via the window-scoped findWindowBrokerTab — the old findActiveBrokerTab scan, retained), build focus, return deriveView. A window without the run → no-run. - Push SIDEBAR_UPDATE on tabs.onActivated, tabs.onUpdated complete, and challenge messages. Sticky-view contract: pushActiveView reflects the ACTIVE broker tab only and no-ops when the active tab isn't a broker tab, so a glance at another tab won't flip the sidebar to revisit. - SIDEBAR_UPDATE is window-scoped (windowId added): runtime.sendMessage broadcasts to every sidebar, so each ignores mismatched windowIds. - Per-tab challenge flag from CHALLENGE_DETECTED/RESOLVED, keyed by sender.tab.id in storage.session (survives spindown mid-challenge; an in-memory Map wouldn't). Cleared on RESOLVED, on tab close, AND on tabs.onUpdated complete to an on-host page — Cloudflare interstitials resolve by redirect so RESOLVED never fires (Slice-4 finding). New pure isOnHost (shared/url.ts, +5 tests) centralizes the on-host/off-host check (also replaces the inline hostname math in the tab scan). reinjectIfMissing stays wired in onUpdated for now (removed in 5d); it's a harmless no-op ping against the headless content script. typecheck + 138 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/background/index.ts | 163 ++++++++++++++++++++++++++++++++++++---- src/shared/types.ts | 5 +- src/shared/url.test.ts | 29 ++++++- src/shared/url.ts | 14 ++++ 4 files changed, 193 insertions(+), 18 deletions(-) diff --git a/src/background/index.ts b/src/background/index.ts index a565cbb..7fb823a 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 } from '../shared/url'; +import { deriveView, type SidebarFocus } from '../sidebar/state'; import { evaluateGate } from '../shared/gate'; import { buildDraft } from '../shared/templates'; import { @@ -193,6 +195,97 @@ 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)); +} + // ── message listener ───────────────────────────────────────────────────────── browser.runtime.onMessage.addListener( @@ -212,6 +305,30 @@ browser.runtime.onMessage.addListener( return { run }; } + // 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(); + // 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; + } + + // 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 === 'GET_ITEM') { const tabId = sender.tab?.id; if (tabId === undefined) return null; @@ -368,11 +485,21 @@ 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); }); +// 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); +}); + // ── overlay re-injection ───────────────────────────────────────────────────── @@ -447,20 +574,24 @@ 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; + const item = run.items.find(i => i.id === itemId); + const tab = await browser.tabs.get(tabId).catch(() => null); + const onHost = !!(item?.renderedUrl && tab?.url && isOnHost(tab.url, item.renderedUrl)); + + // Clear the challenge flag once the tab lands back on-host: Cloudflare interstitials resolve + // by REDIRECT (a navigation, not a DOM mutation), so the content script's CHALLENGE_RESOLVED + // never fires for them (Slice-4 review). The off-host guard keeps the flag during the + // challenges.cloudflare.com hop itself. + if (onHost) await setChallengeFlag(tabId, false); + + // Broker tab finished navigating (e.g. results → details) → recompute the active tab's + // page-type and push. + await pushActiveView(run); + + // (removed in Slice 5d) legacy overlay reinject, on-host only. + if (onHost) await reinjectIfMissing(tabId); }); diff --git a/src/shared/types.ts b/src/shared/types.ts index 15d03f0..4a49304 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -148,4 +148,7 @@ export type SidebarView = | { view: 'saving'; item: ActiveItemInfo } | { view: 'recorded'; item: ActiveItemInfo }; -export interface SidebarUpdateMsg { type: 'SIDEBAR_UPDATE'; view: SidebarView } +// 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 index cbb2ebf..74f20b2 100644 --- a/src/shared/url.test.ts +++ b/src/shared/url.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isResultsPage, brokerHostname } from './url'; +import { isResultsPage, brokerHostname, isOnHost } from './url'; describe('isResultsPage', () => { it('matching pathname → true (results page)', () => { @@ -26,3 +26,30 @@ describe('brokerHostname', () => { 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 index eba7a2b..c778e07 100644 --- a/src/shared/url.ts +++ b/src/shared/url.ts @@ -23,3 +23,17 @@ export function brokerHostname(renderedUrl: string): string { 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; + } +} From 913ba8e5568b99a05544b8453662b2530c9bb450 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 13:13:10 -0400 Subject: [PATCH 11/35] feat(bg): focus-driving, DEFER, verdict-close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5c of the sidebar-nav migration (plan §5). Wires the run's action loop to the sidebar era. - nextFocusTarget(run) (coordinator, pure, +4 tests): the first `open` item, or null. Callers openNextBatch first, so a leftover `pending` is blocked behind a deferred/open sibling (finding #2) and is NOT force-opened — null routes deriveView to revisit. deferred is never an auto-focus target. - driveFocus/advance: after an action, openNextBatch (fill the freed slot) then activate the next target's tab and push its view; if none → push the focus=null view (revisit while deferred/blocked-pending remain, else done). ensureItemTab reopens a fresh tab from renderedUrl when a target has no live tab (resume / finding #3 tolerance). tabIdForItem reverse-resolves the expurge_tab_ map. - handleVerdict now comes from the sidebar (sender.tab is the sidebar, not the broker tab): resolve broker tab via tabIdForItem, capture listingUrl from the tab's own URL for a details-page verdict (captureListingUrl; explicit paste URL still wins), record, drop tracking, advance, then background closes the tab — the sidebar's 800 ms recorded animation is a UI transient and doesn't gate it. - handleSkip (tab_closed) now advances too. handleDefer + DEFER handler: applyDefer, keep the tab, advance. NAVIGATE_BROKER_TAB points the broker tab at a pasted URL. CLOSE_TAB kept vestigial (background owns the close now). - openNextBatch returns the updated run. typecheck + 142 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/background/coordinator.test.ts | 29 ++++++ src/background/coordinator.ts | 10 ++ src/background/index.ts | 160 ++++++++++++++++++++++++++--- 3 files changed, 183 insertions(+), 16 deletions(-) diff --git a/src/background/coordinator.test.ts b/src/background/coordinator.test.ts index f5aa425..a6a2aec 100644 --- a/src/background/coordinator.test.ts +++ b/src/background/coordinator.test.ts @@ -9,6 +9,7 @@ import { isComplete, progressOf, selectBatch, + nextFocusTarget, BATCH_SIZE, MAX_OPEN_TABS, } from './coordinator'; @@ -330,3 +331,31 @@ describe('selectBatch', () => { 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 cf281a8..5693c56 100644 --- a/src/background/coordinator.ts +++ b/src/background/coordinator.ts @@ -202,3 +202,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 7fb823a..be8a559 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,7 +1,7 @@ import browser from 'webextension-polyfill'; import type { Profile, RunState, WorkItemStatus, Verdict, SkipReason, SidebarView, SidebarUpdateMsg } from '../shared/types'; import { BROKERS, getBroker } from '../shared/brokers'; -import { isOnHost } from '../shared/url'; +import { isOnHost, isResultsPage } from '../shared/url'; import { deriveView, type SidebarFocus } from '../sidebar/state'; import { evaluateGate } from '../shared/gate'; import { buildDraft } from '../shared/templates'; @@ -10,9 +10,11 @@ import { buildItems, withVerdict, applySkip, + applyDefer, applyStop, applyMarkSent, selectBatch, + nextFocusTarget, } from './coordinator'; // ── serial write queue ──────────────────────────────────────────────────────── @@ -80,9 +82,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); @@ -96,6 +98,7 @@ async function openNextBatch(run: RunState, focusFirst = false): Promise { await browser.storage.session.set({ [`expurge_tab_${tab.id}`]: item.id }); } } + return updated; } // ── handlers ───────────────────────────────────────────────────────────────── @@ -117,22 +120,45 @@ async function handleStartRun(profile: Profile, windowId?: 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; + 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); }); } @@ -167,7 +193,7 @@ async function handleSkip(itemId: string, skipReason: SkipReason, tabId?: number await browser.storage.session.remove(`expurge_tab_${tabId}`); } - await openNextBatch(updated); + await advance(updated); }); } @@ -286,6 +312,87 @@ async function pushActiveView(run: RunState): Promise { 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( @@ -352,16 +459,33 @@ browser.runtime.onMessage.addListener( } if (m.type === 'VERDICT') { - const tabId = sender.tab?.id; + // 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 === 'NAVIGATE_BROKER_TAB') { + // Paste-URL fallback: point the window's broker tab at the pasted listing. The ensuing + // onUpdated recomputes page-type (results → details) and pushes the verdict view. + const windowId = m.windowId as number; + const run = await loadRun(); + if (run && run.windowId === windowId) { + const tabId = await findWindowBrokerTab(windowId, run); + 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, @@ -463,9 +587,13 @@ 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 }; } From 83fd2c6e946860525627a3e6099a768d48f65e80 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 13:15:49 -0400 Subject: [PATCH 12/35] refactor(bg): remove reinject/PING/GET_ITEM; resume handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5d of the sidebar-nav migration (plan §5). Deletes the overlay-era machinery now that the sidebar drives everything. Removed: - reinjectIfMissing + the PING/PONG roundtrip, findActiveBrokerTab (superseded by the window-scoped findWindowBrokerTab in 5b), the GET_ITEM/ITEM_INFO handler (content no longer pulls item info — deriveView→progressOf is the single view/ progress source, finding #1), the PING handler, and the reinject body of tabs.onUpdated (now purely pushes updates). browser.scripting is no longer used (the manifest's now-dead scripting permission is cleaned in Slice 6). - Dead types GetItemMsg / PingMsg / PongMsg and their ToBackground entries (grepped: zero senders). ReinjMsg KEPT — the popup/options "Restore overlay" buttons still send REINJECT_OVERLAY until §7; with the background handler gone they simply no-op ("Nothing to restore") until Slice 6 deletes the buttons. Resume / finding #3: no open→pending revert added. In the session-storage model, browser.storage.session (run + expurge_tab_ keys) AND the broker tabs all survive event-page spindown together, so open items keep valid tab links — a blanket revert on every loadRun would be wrong. ensureItemTab (5c) already reopens a fresh tab from renderedUrl when a target has no live tab, which is the tolerance the finding asks for; Slice 6's revisit-click will use it to reopen a resumed deferred item. Closing a deferred tab in-session still → skipped/tab_closed (unchanged). typecheck + 142 tests + build green. (Extension remains non-functional end-to-end until the sidebar UI lands in Slice 6.) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/background/index.ts | 135 +--------------------------------------- src/shared/types.ts | 9 ++- 2 files changed, 6 insertions(+), 138 deletions(-) diff --git a/src/background/index.ts b/src/background/index.ts index be8a559..358ad11 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -436,28 +436,6 @@ browser.runtime.onMessage.addListener( return { ok: true }; } - if (m.type === 'GET_ITEM') { - const tabId = sender.tab?.id; - if (tabId === undefined) return null; - const itemId = await itemIdForTab(tabId); - if (!itemId) return null; - 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 }, - }; - } - 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. @@ -500,47 +478,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(); @@ -628,71 +565,6 @@ browser.tabs.onActivated.addListener(async ({ windowId }) => { await pushActiveView(run); }); - -// ── 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 - } -} - // ── first install → open options page ──────────────────────────────────────── browser.runtime.onInstalled.addListener(({ reason }) => { @@ -716,10 +588,7 @@ browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => { // challenges.cloudflare.com hop itself. if (onHost) await setChallengeFlag(tabId, false); - // Broker tab finished navigating (e.g. results → details) → recompute the active tab's - // page-type and push. + // Broker tab finished navigating (e.g. results → details, or a challenge redirect landing + // back on-host) → recompute the active tab's page-type and push. await pushActiveView(run); - - // (removed in Slice 5d) legacy overlay reinject, on-host only. - if (onHost) await reinjectIfMissing(tabId); }); diff --git a/src/shared/types.ts b/src/shared/types.ts index 4a49304..948ea8b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -68,7 +68,6 @@ export interface 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; windowId?: number } export interface ReverdictMsg { type: 'REVERDICT'; itemId: string; verdict: Verdict; listingUrl?: string } export interface SaveProfileMsg { type: 'SAVE_PROFILE'; profile: Profile } @@ -104,15 +103,15 @@ export interface ItemInfoMsg { progress: RunProgress; } export interface AckMsg { type: 'ACK'; itemId: string } -export interface PongMsg { type: 'PONG'; hasOverlay: boolean } -export interface PingMsg { type: 'PING' } +// REINJECT_OVERLAY is dead in the background (the overlay is gone), but the popup/options +// "Restore overlay" buttons still send it until §7 deletes them, so the type stays until then. 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 + | ReinjMsg | StopRunMsg | SaveProfileMsg | GetProfileMsg | MarkSentMsg | DeleteAllMsg | CloseTabMsg | SidebarGetStateMsg | DeferMsg | NavigateBrokerTabMsg | ChallengeDetectedMsg | ChallengeResolvedMsg; // ── sidebar view model ────────────────────────────────────────────────────── From 9b33823ec2c08cdf44badb299557474a76ccdaca Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 13:44:38 -0400 Subject: [PATCH 13/35] fix(challenge): report state per-load; drop onUpdated clear misfire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice-5 review found a challenge-flag lifecycle bug on ON-HOST Cloudflare interstitials (broker.com/cdn-cgi/...): the content script set the flag at document_end (CHALLENGE_DETECTED), then tabs.onUpdated complete fired later and cleared it on the same load, so deriveView yielded `verdict` — "Is this you? Yes/No" over a "checking your browser" page. Root cause: split source of truth. The content script only reported DETECTED-on- load + RESOLVED-via-observer (nothing on a clean load), so a background onUpdated heuristic was bolted on to cover the redirect-to-clean case — but it couldn't tell "clean content" from "on-host challenge page," so it misfired. Fix — make the content script authoritative per-load, drop the background guess: - content/index.ts: report state on EVERY load — CHALLENGE_DETECTED if challenged (+ the existing MutationObserver for in-page Turnstile clears), else CHALLENGE_RESOLVED on a clean load. Idempotency guard kept. (This is Slice 4's "optional bidirectional detect" — it's load-bearing for on-host challenges. A challenge appearing after a clean load with no navigation stays out of scope.) - background/index.ts: remove the onUpdated challenge-flag clear and its now-dead item/tab/onHost computation; onUpdated is just "tracked broker tab + run → pushActiveView". Flag still cleared on CHALLENGE_RESOLVED and onRemoved. isOnHost stays imported (findWindowBrokerTab still uses it). Now the on-host interstitial reports DETECTED and stays challenged (nothing clears it); the redirect to the real page reports RESOLVED. No race, no misfire; the observer still covers in-page clears that don't navigate. plan/sidebar-nav.md §5/§6 updated to match (per-load content-authoritative). Also refreshed a stale content-script comment (Slice 5 removed the reinject path). No new tests (detectChallenge already covered; reporter/onUpdated are imperative glue, fully verifiable only once the sidebar lands in Slice 6). typecheck + 142 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- plan/sidebar-nav.md | 6 +++--- src/background/index.ts | 14 ++++---------- src/content/index.ts | 31 +++++++++++++++++++------------ 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/plan/sidebar-nav.md b/plan/sidebar-nav.md index e8ffc8d..8b703fd 100644 --- a/plan/sidebar-nav.md +++ b/plan/sidebar-nav.md @@ -118,7 +118,7 @@ The **Defer** control is present alongside the active-item detail in `guidance`/ - **`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 → set/clear a per-tab challenge flag → push `SIDEBAR_UPDATE`. - - **Also clear the challenge flag on on-host navigation (from the Slice-4 review).** The content script's `MutationObserver` only catches *in-page* clears (e.g. Turnstile solved inline). The common **Cloudflare interstitial resolves by redirecting** to the real page — a navigation, not a DOM mutation — so `CHALLENGE_RESOLVED` **never fires** for it. If the flag only clears on `CHALLENGE_RESOLVED`, the sidebar sticks on the `challenge` view forever after an interstitial clears. Rule: **the tab's challenge flag clears on EITHER `CHALLENGE_RESOLVED` OR a `tabs.onUpdated` complete to an on-host broker page** (reuse the existing off-host guard so the `challenges.cloudflare.com` hop itself doesn't clear it). This is where the old overlay's "re-inject → showMainPanel after redirect" behavior now lives. + - **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) @@ -127,8 +127,8 @@ The **Defer** control is present alongside the active-item detail in `guidance`/ **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. diff --git a/src/background/index.ts b/src/background/index.ts index 358ad11..9a7d006 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -578,17 +578,11 @@ browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => { const run = await loadRun(); if (!run) return; - const item = run.items.find(i => i.id === itemId); - const tab = await browser.tabs.get(tabId).catch(() => null); - const onHost = !!(item?.renderedUrl && tab?.url && isOnHost(tab.url, item.renderedUrl)); - - // Clear the challenge flag once the tab lands back on-host: Cloudflare interstitials resolve - // by REDIRECT (a navigation, not a DOM mutation), so the content script's CHALLENGE_RESOLVED - // never fires for them (Slice-4 review). The off-host guard keeps the flag during the - // challenges.cloudflare.com hop itself. - if (onHost) await setChallengeFlag(tabId, false); // Broker tab finished navigating (e.g. results → details, or a challenge redirect landing - // back on-host) → recompute the active tab's page-type and push. + // 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/index.ts b/src/content/index.ts index 3171ec1..db87814 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -3,20 +3,27 @@ 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: tell the background -// when a broker page is gated behind a bot-challenge, and when that challenge clears. -// Background identifies the tab via sender.tab.id — this script runs in the broker tab. +// 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. -// If a challenge is up on load, report it and watch for it clearing. A clean load reports -// nothing (parity with the old overlay, which only challenge-detected on init). +// 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()) return; + if (!detectChallenge()) { + browser.runtime.sendMessage({ type: 'CHALLENGE_RESOLVED' } satisfies ChallengeResolvedMsg).catch(() => {}); + return; + } browser.runtime.sendMessage({ type: 'CHALLENGE_DETECTED' } satisfies ChallengeDetectedMsg).catch(() => {}); - // Watch for the challenge clearing. The 250 ms debounce guards against CAPTCHA widgets - // (notably Turnstile) briefly detaching their container mid-transition, which would read - // as "resolved" for an instant. Lifted from the old buildChallengePanel observer. + // 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()) { @@ -35,9 +42,9 @@ function reportChallenges(): void { observer.observe(document.documentElement, { childList: true, subtree: true }); } -// Idempotency guard: the manifest auto-injects on navigation and (until Slice 5) background -// still re-executeScripts on a missing PING. Without this latch we'd stack MutationObservers -// and emit duplicate CHALLENGE_DETECTED. Mirrors the old __expurgePingBound flag. +// 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; From 46502a45e89d9179ec556a30d0167bf63e9fc3a1 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 14:19:17 -0400 Subject: [PATCH 14/35] feat(bg): FOCUS_ITEM + promoteToOpen; start-run view push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves Decision B (clickable checklist rows) — the last backend piece before the Slice 6 UI. The sidebar can't focus a tab itself (tab ids are background-only), so it names an item and background jumps to it. One message serves both a checklist row click and the revisit button (revisit = FOCUS_ITEM on the first deferred item). - coordinator.promoteToOpen(run, itemId) (pure, +5 tests): deferred → open only, the exact inverse of applyDefer. No-op on open/pending/verdicted. A pending item must go through ensureItemTab (which creates its tab), so promoteToOpen never conjures an `open` item with no live tab. - FocusItemMsg { itemId, windowId } → ToBackground. - handleFocusItem (serialWrite, handleDefer pattern): bail on no run / wrong window / already-verdicted item; ensureItemTab reopens a lost tab from renderedUrl (resume / finding #3), flipping a tabless item to open; promoteToOpen flips a still-alive deferred item to open (so a re-defer during revisit doesn't no-op); saveRun; activate the tab; push deriveView(...) for the window. - handleStartRun now pushActiveView after the first batch opens — init-race insurance (Slice-5 review): a sidebar that raced SIDEBAR_GET_STATE ahead of the run being saved corrects itself once tabs open. typecheck + 147 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/background/coordinator.test.ts | 29 +++++++++++++++++++++++ src/background/coordinator.ts | 15 ++++++++++++ src/background/index.ts | 37 +++++++++++++++++++++++++++++- src/shared/types.ts | 6 ++++- 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/background/coordinator.test.ts b/src/background/coordinator.test.ts index a6a2aec..b87a521 100644 --- a/src/background/coordinator.test.ts +++ b/src/background/coordinator.test.ts @@ -4,6 +4,7 @@ import { withVerdict, applySkip, applyDefer, + promoteToOpen, applyStop, applyMarkSent, isComplete, @@ -145,6 +146,34 @@ describe('applyDefer', () => { }); }); +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, open, and deferred items but leaves verdicted ones alone', () => { const r = applyStop( diff --git a/src/background/coordinator.ts b/src/background/coordinator.ts index 5693c56..d0157ec 100644 --- a/src/background/coordinator.ts +++ b/src/background/coordinator.ts @@ -115,6 +115,21 @@ export function applyDefer(run: RunState, itemId: string): RunState { }; } +// 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 { diff --git a/src/background/index.ts b/src/background/index.ts index 9a7d006..d989866 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -11,6 +11,7 @@ import { withVerdict, applySkip, applyDefer, + promoteToOpen, applyStop, applyMarkSent, selectBatch, @@ -116,7 +117,11 @@ async function handleStartRun(profile: Profile, windowId?: number): Promise { }); } +// 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)); + }); +} + // Re-verdict from the results dashboard: a pure state edit of an already-recorded // item. Never touches tab tracking or opens tabs. async function handleReverdict(itemId: string, verdict: Verdict, listingUrl?: string): Promise { @@ -452,6 +482,11 @@ browser.runtime.onMessage.addListener( 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 window's broker tab at the pasted listing. The ensuing // onUpdated recomputes page-type (results → details) and pushes the verdict view. diff --git a/src/shared/types.ts b/src/shared/types.ts index 948ea8b..7b0fc0d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -82,6 +82,10 @@ export interface CloseTabMsg { type: 'CLOSE_TAB'; windowId?: number } 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; url: string } // ── messages content → background ─────────────────────────────────────────── @@ -112,7 +116,7 @@ export interface StopRunMsg { type: 'STOP_RUN' } export type ToBackground = | StartRunMsg | GetRunStateMsg | GetDraftMsg | VerdictMsg | ReverdictMsg | ReinjMsg | StopRunMsg | SaveProfileMsg | GetProfileMsg | MarkSentMsg | DeleteAllMsg | CloseTabMsg - | SidebarGetStateMsg | DeferMsg | NavigateBrokerTabMsg | ChallengeDetectedMsg | ChallengeResolvedMsg; + | SidebarGetStateMsg | DeferMsg | FocusItemMsg | NavigateBrokerTabMsg | ChallengeDetectedMsg | ChallengeResolvedMsg; // ── sidebar view model ────────────────────────────────────────────────────── // The sidebar's display is derived purely from run state + focus (src/sidebar/state.ts). From 1de082997b2cf6512c0968484c550807c036591b Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 14:31:30 -0400 Subject: [PATCH 15/35] feat(build): sidebar_action manifest + build entry; drop dead scripting perm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 6a of the sidebar-nav migration (plan §1/§2). Makes the sidebar loadable. - manifest.json: add sidebar_action (default_panel dist/sidebar.html). Drop the now-dead `scripting` permission (reinjectIfMissing/executeScript went away in Slice 5 — grep confirms zero browser.scripting refs). Refresh the m9 note (the overlay is gone; verdict UI is the window-level sidebar, no per-domain entry). - build.mjs: esbuild src/sidebar/index.ts → dist/sidebar.js and style.css → dist/sidebar.css; copyStatics copies index.html → dist/sidebar.html. - src/sidebar/{index.html,style.css,index.ts}: minimal but real — listener-FIRST init (attach onMessage, then windows.getCurrent → windowId, then SIDEBAR_GET_STATE), windowId-filtered pushes, renders the view tag + shared progressOf. The full checklist UI lands in 6b. typecheck + 147 tests + build green; dist/sidebar.{html,js,css} emitted. Co-Authored-By: Claude Opus 4.8 (1M context) --- build.mjs | 3 ++ manifest.json | 8 +++-- src/sidebar/index.html | 21 +++++++++++++ src/sidebar/index.ts | 40 ++++++++++++++++++++++++ src/sidebar/style.css | 71 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/sidebar/index.html create mode 100644 src/sidebar/index.ts create mode 100644 src/sidebar/style.css diff --git a/build.mjs b/build.mjs index 5853bbd..6453dbe 100644 --- a/build.mjs +++ b/build.mjs @@ -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,7 @@ 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'); } if (watch) { diff --git a/manifest.json b/manifest.json index 8f5d490..972ec17 100644 --- a/manifest.json +++ b/manifest.json @@ -21,7 +21,11 @@ "default_popup": "dist/popup.html", "default_title": "expurge" }, - "permissions": ["storage", "tabs", "downloads", "scripting", "webNavigation"], + "sidebar_action": { + "default_panel": "dist/sidebar.html", + "default_title": "expurge" + }, + "permissions": ["storage", "tabs", "downloads", "webNavigation"], "host_permissions": [ "*://updates.expurge.dev/*" ], @@ -40,7 +44,7 @@ "_notes": { "webNavigation": "In permissions for background webNavigation.onErrorOccurred (load-error skipping). Not yet wired in background/index.ts — implement when adding M9 brokers.", "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/src/sidebar/index.html b/src/sidebar/index.html new file mode 100644 index 0000000..a4fd6ca --- /dev/null +++ b/src/sidebar/index.html @@ -0,0 +1,21 @@ + + + + + + expurge + + + + + + +
      +

      expurge

      +

      +
      +
      +
      + + + diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts new file mode 100644 index 0000000..99b12cd --- /dev/null +++ b/src/sidebar/index.ts @@ -0,0 +1,40 @@ +import browser from 'webextension-polyfill'; +import type { RunState, SidebarView, SidebarUpdateMsg } from '../shared/types'; +import { progressOf } from '../background/coordinator'; + +// The sidebar is a thin render layer over the view 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 that lands between them isn't missed. + +let windowId: number | undefined; + +browser.runtime.onMessage.addListener((msg: unknown) => { + const m = msg as Partial; + if (m?.type !== 'SIDEBAR_UPDATE') return; + // Ignore updates for other windows — runtime.sendMessage broadcasts to every open sidebar. + if (windowId === undefined || m.windowId !== windowId) 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); +} + +function renderView(view: SidebarView): void { + document.getElementById('detail')!.textContent = view.view; + void refreshProgress(); +} + +async function refreshProgress(): Promise { + const res = await browser.runtime.sendMessage({ type: 'GET_RUN_STATE' }) as { run?: RunState }; + const run = res.run ?? null; + 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` : ''}`; +} + +init().catch(() => {}); diff --git a/src/sidebar/style.css b/src/sidebar/style.css new file mode 100644 index 0000000..29d6744 --- /dev/null +++ b/src/sidebar/style.css @@ -0,0 +1,71 @@ +@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; } + +/* ── checklist ───────────────────────────────────────────────────────────── */ + +#checklist { + padding: 0 var(--s-4) var(--s-4); + overflow-y: auto; + flex: 1; +} From 87a7179fccb9f388b2b6f8b1c8eca2316d796b3b Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 14:33:21 -0400 Subject: [PATCH 16/35] feat(sidebar): checklist UI over deriveView + transient states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 6b of the sidebar-nav migration (plan §3). The sidebar renders the eight views; it never re-derives (the background's deriveView is the source of truth). - Renders all 8 views. Buttons → messages: verdict cluster (hit/clear/unknown/ skip) and challenge "Skip this site" → VERDICT; guidance "Not found" → VERDICT clear + paste-URL → NAVIGATE_BROKER_TAB; Defer control (guidance/verdict/ challenge) → DEFER; revisit → FOCUS_ITEM on the first deferred item; done → open dashboard. - Transient saving/recorded (UI-owned, never derived): a verdict click owns the panel through saving → recorded (~800 ms) while a `transient` latch suppresses incoming pushes, then re-pulls the resting view (background already advanced). - Checklist (Decision A): grouped In progress / Waiting / Done from GET_RUN_STATE, re-fetched on each update; non-terminal rows clickable → FOCUS_ITEM (manual override, decision 5). Keyboard-accessible (role=button, Enter/Space). - Progress header via shared progressOf. DATA-INJECTION INVARIANT (STYLEGUIDE §0): renders only generic broker data — broker names, generic `exposes` chips, broker `guidance`, and a generic "alternate name" tag for AKA variants (from nameVariant, never the actual name). The user's real data — variantFirst/variantLast, renderedUrl/listingUrl (carry the searched name) — is deliberately never displayed. All dataset text via textContent, never innerHTML. Tokens only; no hard-coded values. typecheck + 147 tests + build green. (End-to-end behavior verifiable once loaded in Firefox — manual QA after 6c.) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sidebar/index.ts | 257 ++++++++++++++++++++++++++++++++++++++++-- src/sidebar/style.css | 212 ++++++++++++++++++++++++++++++++++ 2 files changed, 459 insertions(+), 10 deletions(-) diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts index 99b12cd..9a01777 100644 --- a/src/sidebar/index.ts +++ b/src/sidebar/index.ts @@ -1,18 +1,36 @@ import browser from 'webextension-polyfill'; -import type { RunState, SidebarView, SidebarUpdateMsg } from '../shared/types'; +import type { RunState, WorkItem, Verdict, SidebarView, SidebarUpdateMsg, ActiveItemInfo } from '../shared/types'; +import { getBroker } from '../shared/brokers'; import { progressOf } from '../background/coordinator'; -// The sidebar is a thin render layer over the view 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 that lands between them isn't missed. +// 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; +let latestRun: RunState | null = null; +// 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 updates for other windows — runtime.sendMessage broadcasts to every open sidebar. - if (windowId === undefined || m.windowId !== windowId) 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!); }); @@ -23,18 +41,237 @@ async function init(): Promise { 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 { - document.getElementById('detail')!.textContent = view.view; - void refreshProgress(); + 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 'revisit': renderRevisit(d, view.waiting); break; + case 'done': renderDone(d, view.progress.done, view.progress.total, view.progress.hits); break; + case 'saving': renderSaving(d); break; + case 'recorded': renderRecorded(d); break; + } + 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 the broker tab to a listing the user pastes. + 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 go = button('Go to my listing', 'btn-quiet wide', () => { + const url = input.value.trim(); + if (url) send({ type: 'NAVIGATE_BROKER_TAB', windowId, url }); + }); + paste.appendChild(input); + 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); +} + +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.")); } -async function refreshProgress(): Promise { +function renderRevisit(d: HTMLElement, waiting: number): 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 firstDeferred = latestRun?.items.find(i => i.status === 'deferred')?.id; + const b = button('Revisit set-aside sites', 'btn-primary wide', () => { + if (firstDeferred) send({ type: 'FOCUS_ITEM', itemId: firstDeferred, windowId }); + }); + if (!firstDeferred) 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(() => {}); })); +} + +// ── 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.'; +} + +// 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). +async function castVerdict(itemId: string, verdict: Verdict): Promise { + if (windowId === undefined) return; + transient = true; + lastVerdict = verdict; + + const d = detail(); + d.replaceChildren(); + renderSaving(d); + + await browser.runtime.sendMessage({ type: 'VERDICT', itemId, verdict, windowId }).catch(() => {}); + + d.replaceChildren(); + renderRecorded(d); + await delay(800); + + transient = false; + await pullState(); +} + +// ── 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; + latestRun = res.run ?? null; + renderChecklist(latestRun); + renderProgress(latestRun); +} + +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/style.css b/src/sidebar/style.css index 29d6744..d5159bd 100644 --- a/src/sidebar/style.css +++ b/src/sidebar/style.css @@ -62,10 +62,222 @@ header { #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); } + +/* ── 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); } + /* ── 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); } From 8442273d2e00b5a854a938cd99776e58fc12c1b8 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 14:36:17 -0400 Subject: [PATCH 17/35] feat(popup/options): open sidebar on Start; remove Restore-overlay; shared progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 6c of the sidebar-nav migration (plan §7). - options handleStartRun: open the sidebar SYNCHRONOUSLY in the Start click's user gesture, before any await (sidebarAction.open() needs a gesture and opens in the active window — Q-015); then capture windowId (windows.getCurrent) and send it with START_RUN so the run is pinned to that window. Profile guard moved up so we don't open a sidebar / prompt for permission with no profile. ⚠ Q-015 empirical: the gesture now drives BOTH sidebarAction.open() and permissions.request(); flagged in a code comment to verify in Firefox 140+. - Removed the "Restore overlay" button + REINJECT_OVERLAY handler from BOTH popup and options; deleted the now-senderless ReinjMsg type + its ToBackground entry (grep confirms zero senders). - Replaced inline completion math in popup + options with shared isComplete / progressOf (one definition; deferred counts toward total, not done). typecheck + 147 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/options/index.html | 1 - src/options/index.ts | 55 ++++++++++++++++++------------------------ src/popup/index.html | 1 - src/popup/index.ts | 27 +++------------------ src/shared/types.ts | 5 +--- 5 files changed, 27 insertions(+), 62 deletions(-) diff --git a/src/options/index.html b/src/options/index.html index 91e71d4..d585525 100644 --- a/src/options/index.html +++ b/src/options/index.html @@ -48,7 +48,6 @@

      Scan in progress

      -
      diff --git a/src/options/index.ts b/src/options/index.ts index 0eabe6e..2d9b430 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,22 +993,6 @@ 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; - } -}); - document.getElementById('btn-stop')!.addEventListener('click', async () => { await browser.runtime.sendMessage({ type: 'STOP_RUN' }); const res = await browser.runtime.sendMessage({ type: 'GET_RUN_STATE' }) as { run?: RunState }; diff --git a/src/popup/index.html b/src/popup/index.html index 5364492..c9f6e1f 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -25,7 +25,6 @@

      expurge

      diff --git a/src/options/index.ts b/src/options/index.ts index 2d9b430..0cfee38 100644 --- a/src/options/index.ts +++ b/src/options/index.ts @@ -993,6 +993,12 @@ document.querySelectorAll('[data-nav]').forEach(btn => { document.getElementById('btn-start')!.addEventListener('click', () => { handleStartRun().catch(console.error); }); +// 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 () => { await browser.runtime.sendMessage({ type: 'STOP_RUN' }); const res = await browser.runtime.sendMessage({ type: 'GET_RUN_STATE' }) as { run?: RunState }; diff --git a/src/popup/index.html b/src/popup/index.html index 29b88cc..6b5e163 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -23,6 +23,7 @@

      expurge

      diff --git a/src/popup/index.ts b/src/popup/index.ts index 41754bf..9bf67c2 100644 --- a/src/popup/index.ts +++ b/src/popup/index.ts @@ -6,6 +6,12 @@ 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; @@ -33,6 +39,9 @@ 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-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' }); window.close(); From 47f7c3d7b1d988fb56d7d88bc8ac558e9b1ccaad Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 16:16:07 -0400 Subject: [PATCH 24/35] fix(bg): refresh sidebar on Stop / Delete-all (external run mutations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run-mutating actions from OUTSIDE the sidebar (Stop from popup/options, Delete-all from options) didn't push a sidebar update, so the sidebar kept showing live verdict/guidance controls for a run that had ended. - handleStopRun: after applyStop + saveRun + clearing tab keys, push the resting view to the run's window. A stopped run isComplete, so deriveView(updated, null) yields `done` — sidebar drops the actionable controls. deriveView, not a hardcoded view, keeps one source of truth. - DELETE_ALL: capture the run's windowId BEFORE wiping session storage, then push { view: 'no-run' } after clearing — sidebar shows "No active scan in this window." REVERDICT/MARK_SENT left as-is (they edit already-completed items — minor drift only); follow-up if the done hit-count ever reads stale. No test changes (imperative push, like the other handlers). typecheck + 148 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/background/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/background/index.ts b/src/background/index.ts index d989866..8382f9b 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -242,6 +242,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)); + } }); } @@ -571,10 +578,14 @@ browser.runtime.onMessage.addListener( } 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 }; } From a1c4b4c03117dc6964564df274ce61c519c407be Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 16:22:04 -0400 Subject: [PATCH 25/35] feat(sidebar): honest `stopped` view distinct from `done` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a Stop, the run is isComplete so deriveView returned `done` — "Checked N of N. You're all clear here." — which overclaims: the run_stopped items were abandoned, not checked, and the user isn't "all clear." Add a `stopped` SidebarView variant, derived when isComplete AND some item has skipReason 'run_stopped' (checked before the `done` branch — a stopped run can't also be a clean done). Payload { checked, total, hits } where `checked` excludes the abandoned run_stopped items (they stay in `total`, which still excludes missing: skips). Copy: "Scan stopped. Checked X of Y. The rest are still on your list — start again anytime." — framed as what's left, no alarm (STYLEGUIDE voice). handleStopRun / SIDEBAR_GET_STATE need no change — both route through deriveView, so the stop push and any pull now yield `stopped` automatically. +4 deriveView tests (checked excludes run_stopped; stays done without one; precedence over a focused item; missing excluded from total). resting-views test updated. typecheck + 152 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- plan/sidebar-nav.md | 5 +++-- src/shared/types.ts | 3 +++ src/sidebar/index.ts | 9 +++++++++ src/sidebar/state.test.ts | 34 +++++++++++++++++++++++++++++++++- src/sidebar/state.ts | 11 ++++++++++- 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/plan/sidebar-nav.md b/plan/sidebar-nav.md index aeba68d..0309a73 100644 --- a/plan/sidebar-nav.md +++ b/plan/sidebar-nav.md @@ -88,9 +88,10 @@ No new `permissions` — `sidebarAction` is available whenever `sidebar_action` - `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". 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 (no `pending`/`open`/`deferred` remain): terminal summary from `progressOf` (done / total / hits). Distinct from `no-run` (never started / no run in this window) +- `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 six **resting** views — `no-run` / `guidance` / `verdict` / `challenge` / `revisit` / `done`. `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 pure `deriveView` (Slice 3) returns only the seven **resting** views — `no-run` / `guidance` / `verdict` / `challenge` / `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"). diff --git a/src/shared/types.ts b/src/shared/types.ts index 94df00e..f147f61 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -145,6 +145,9 @@ export type SidebarView = | { view: 'challenge'; 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 }; diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts index a879da6..d844a0b 100644 --- a/src/sidebar/index.ts +++ b/src/sidebar/index.ts @@ -76,6 +76,7 @@ function renderView(view: SidebarView): void { case 'challenge': renderChallenge(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; } @@ -172,6 +173,14 @@ function renderDone(d: HTMLElement, done: number, total: number, hits: number): 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 { diff --git a/src/sidebar/state.test.ts b/src/sidebar/state.test.ts index f05a1d4..f426fa2 100644 --- a/src/sidebar/state.test.ts +++ b/src/sidebar/state.test.ts @@ -43,6 +43,37 @@ describe('deriveView — 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' })]); @@ -145,7 +176,7 @@ describe('deriveView — results↔details boundary', () => { }); describe('deriveView — only ever returns resting views', () => { - const resting = new Set(['no-run', 'guidance', 'verdict', 'challenge', 'revisit', 'done']); + const resting = new Set(['no-run', 'guidance', 'verdict', 'challenge', 'revisit', 'done', 'stopped']); const cases: Array<[RunOrNull, SidebarFocus | null]> = [ [null, null], [run([item({ status: 'verdicted', verdict: 'hit' })]), focus()], @@ -153,6 +184,7 @@ describe('deriveView — only ever returns resting views', () => { [run([item({ status: 'open' })]), focus()], [run([item({ status: 'open' })]), focus({ tabUrl: DETAILS_URL })], [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) { diff --git a/src/sidebar/state.ts b/src/sidebar/state.ts index 397f0f5..eb0bca1 100644 --- a/src/sidebar/state.ts +++ b/src/sidebar/state.ts @@ -34,7 +34,16 @@ export function deriveView( if (!run) return { view: 'no-run' }; const progress = progressOf(run); - if (isComplete(run)) return { view: 'done', progress }; + 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 page-type: a // CAPTCHA hides the listing, so there's nothing to judge until it clears. From ce367e8f7bb36b9c2e3362a70340837ae1cfbbf0 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 16:52:09 -0400 Subject: [PATCH 26/35] =?UTF-8?q?docs:=20Q-015=20empirically=20confirmed?= =?UTF-8?q?=20=E2=80=94=20double=20gesture=20works=20in=20Firefox=20140?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firefox 140+ QA (2026-07-01): 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. Q-015 closes the last open thread from the sidebar-nav migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- wherefore/questions/Q-015.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 --- From d39a1b62f9aa797fe3cce2afac8b10e4995eea5c Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 17:38:31 -0400 Subject: [PATCH 27/35] fix(sidebar): confirm verdict ACK before showing recorded (retry on failure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit castVerdict fired the VERDICT message with .catch(()=>{}) and rendered `recorded` unconditionally — no ACK check, no timeout, no retry. That violated CLAUDE.md's verdict contract ("wait for explicit ACK that background wrote to storage → then show recorded; no ACK within timeout → retry"), which the old content-script sendVerdict honored before the strip (6bf6270~1). - sendVerdictAck: races each send against a 6s timeout, up to 3 attempts, returns true iff reply.type === 'ACK'. Restores the old contract in the sidebar. - castVerdict: saving → await ACK. On failure, re-pull the true state (the verdict may or may not have landed) and append an inline "Couldn't save just now — try again" while leaving the verdict controls usable — NEVER renders recorded on an unconfirmed write. On success, recorded → 800 ms → re-pull. Both re-pulls are now .catch()-guarded (finding #7) so a rejected SIDEBAR_GET_STATE can't dead-end the panel. Retry correctness depends on the handleVerdict idempotency guard (next commit): a retry of a landed-but-ack-lost verdict must re-ACK without re-recording/-closing. typecheck + 152 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sidebar/index.ts | 41 ++++++++++++++++++++++++++++++++++++----- src/sidebar/style.css | 1 + 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts index d844a0b..0c51670 100644 --- a/src/sidebar/index.ts +++ b/src/sidebar/index.ts @@ -201,8 +201,34 @@ function recordedMsg(v: Verdict | null): string { 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). +// 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; @@ -212,14 +238,19 @@ async function castVerdict(itemId: string, verdict: Verdict): Promise { d.replaceChildren(); renderSaving(d); - await browser.runtime.sendMessage({ type: 'VERDICT', itemId, verdict, windowId }).catch(() => {}); + const ok = await sendVerdictAck(itemId, verdict); + transient = false; + + if (!ok) { + await pullState().catch(() => {}); // reflect reality (verdict may not have landed) + renderVerdictError(); + return; + } d.replaceChildren(); renderRecorded(d); await delay(800); - - transient = false; - await pullState(); + 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) ── diff --git a/src/sidebar/style.css b/src/sidebar/style.css index d5159bd..368e23b 100644 --- a/src/sidebar/style.css +++ b/src/sidebar/style.css @@ -235,6 +235,7 @@ button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus-ring-sha 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 ───────────────────────────────────────────────────────────── */ From 74d99da6378751a4fbc163352aa1407ad5a66d76 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 17:39:03 -0400 Subject: [PATCH 28/35] fix(sidebar): scope the checklist to the run's window (clear on no-run) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshChecklist fetched GET_RUN_STATE — the GLOBAL run, no windowId — so an idle window's sidebar showed the run window's checklist + progress, even though its detail panel correctly derived no-run. Its rows looked clickable but FOCUS_ITEM{windowId:idle} was rejected by the background → dead clicks. The detail view is already windowId-scoped: a no-run view means THIS window has no run. So renderView now clears #checklist + #progress on no-run and only fetches the checklist for other views (where the single pinned run IS this window's run, so the global fetch is correct). typecheck + 152 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sidebar/index.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts index 0c51670..af52320 100644 --- a/src/sidebar/index.ts +++ b/src/sidebar/index.ts @@ -80,7 +80,12 @@ function renderView(view: SidebarView): void { case 'saving': renderSaving(d); break; case 'recorded': renderRecorded(d); break; } - void refreshChecklist(); + // 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 ──────────────────────────────────────────────────── @@ -262,6 +267,12 @@ async function refreshChecklist(): Promise { 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; } From b088188e6e52f3e140956d851122b7e580f1f558 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 17:39:31 -0400 Subject: [PATCH 29/35] fix(bg): guard handleVerdict against re-verdicting a recorded item (no-wedge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withVerdict overwrites any item unconditionally (handleReverdict relies on that to re-verdict already-verdicted items from the dashboard). So the no-wedge guard goes in handleVerdict (the live-run path), not withVerdict: after loadRun, bail if the item is missing or already `verdicted`. Makes verdicts idempotent — the sidebar's retry (previous commit) of a landed-but- ack-lost verdict no-ops and re-ACKs instead of re-recording/-advancing/-closing — and stops a fast second verdict (Yes then No) from clobbering a recorded hit. The message listener still returns {type:'ACK'} regardless, so the retry still confirms. typecheck + 152 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/background/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/background/index.ts b/src/background/index.ts index 8382f9b..fc67e0a 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -134,6 +134,14 @@ async function handleVerdict(itemId: string, verdict: Verdict, explicitListingUr 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); From 23233cece6509aefc8ce2fc78e09e0c6db4376c8 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 17:40:35 -0400 Subject: [PATCH 30/35] fix(sidebar): warn on off-host paste + navigate the item's own tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old guidance overlay warned "doesn't look like a {host} URL" on an off-broker paste; the sidebar dropped both the warning and any tab-targeting guarantee. - Host warning: renderGuidance derives the broker host via brokerHostname(item. renderedUrl) and, on input, shows a non-blocking warning (reusing the pure, tested isOnHost) when the pasted host isn't the broker's (exact or subdomain). Warn, never block. Only the hostname is shown — the full renderedUrl (which carries the searched name) stays unrendered, honoring the PII invariant. - Right tab: NavigateBrokerTabMsg gains itemId; the sidebar sends item.itemId and the handler navigates tabIdForItem(itemId) instead of the active-preferred findWindowBrokerTab — so if the active tab changed since the guidance view rendered, the paste still lands in that item's own broker tab, not another's. No new tests (isOnHost/brokerHostname already covered; the rest is UI/bg glue). typecheck + 152 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/background/index.ts | 8 +++++--- src/shared/types.ts | 2 +- src/sidebar/index.ts | 15 +++++++++++++-- src/sidebar/style.css | 3 +++ 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/background/index.ts b/src/background/index.ts index fc67e0a..856baf1 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -503,12 +503,14 @@ browser.runtime.onMessage.addListener( } if (m.type === 'NAVIGATE_BROKER_TAB') { - // Paste-URL fallback: point the window's broker tab at the pasted listing. The ensuing - // onUpdated recomputes page-type (results → details) and pushes the verdict view. + // 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 findWindowBrokerTab(windowId, run); + 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 }; diff --git a/src/shared/types.ts b/src/shared/types.ts index f147f61..dd40181 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -86,7 +86,7 @@ export interface DeferMsg { type: 'DEFER'; itemId: str // 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; url: string } +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 diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts index af52320..e737d47 100644 --- a/src/sidebar/index.ts +++ b/src/sidebar/index.ts @@ -1,6 +1,7 @@ import browser from 'webextension-polyfill'; import type { RunState, WorkItem, Verdict, SidebarView, SidebarUpdateMsg, ActiveItemInfo } from '../shared/types'; import { getBroker } from '../shared/brokers'; +import { brokerHostname, isOnHost } from '../shared/url'; import { progressOf } from '../background/coordinator'; // The sidebar is a thin render layer over the view the background derives (deriveView) — it @@ -112,17 +113,27 @@ function renderGuidance(d: HTMLElement, item: ActiveItemInfo): void { 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 the broker tab to a listing the user pastes. + // 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.`); + warn.hidden = true; + input.addEventListener('input', () => { + const url = input.value.trim(); + warn.hidden = !url || isOnHost(url, 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, url }); + if (url) send({ type: 'NAVIGATE_BROKER_TAB', windowId, itemId: item.itemId, url }); }); paste.appendChild(input); + paste.appendChild(warn); paste.appendChild(go); d.appendChild(paste); diff --git a/src/sidebar/style.css b/src/sidebar/style.css index 368e23b..eb08b3a 100644 --- a/src/sidebar/style.css +++ b/src/sidebar/style.css @@ -214,6 +214,9 @@ button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus-ring-sha } .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-mono); line-height: var(--lh-small); margin-bottom: var(--s-2); } + /* ── transient status ────────────────────────────────────────────────────── */ @keyframes spin { to { transform: rotate(360deg); } } From 9f7380ed2a23263271995a56cc2f1f6fae7b87e1 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 18:09:02 -0400 Subject: [PATCH 31/35] test(sidebar): prove the off-host paste-warning DOM path; make it more visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA reported an off-host paste showing no warning. The logic and the freshly-built bundle were already correct (off-host paste → warn.hidden=false), so this pins the behaviour down with a real test and makes the warning harder to miss. - Extract wirePasteWarning / shouldWarnOffHost (src/sidebar/paste.ts) — the pure warn-decision plus the input→warning DOM wiring, so the exact path a paste takes (set value → 'input' event → warn.hidden toggles) is unit-testable. - paste.test.ts (jsdom, +8): off-host/on-host/subdomain/empty/unparseable decisions, and the event path — an off-host paste shows the warning, an on-host one hides it, clearing hides it. Green, so the wiring is confirmed working. - renderGuidance uses wirePasteWarning (same behaviour, now tested). - Bump .paste-warning to fs-small + medium weight so it's not easy to overlook. If a manual test still shows no warning, it's a stale loaded add-on — reload it in about:debugging (the built dist contains the code, grep-confirmed). typecheck + 160 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sidebar/index.ts | 9 ++---- src/sidebar/paste.test.ts | 62 +++++++++++++++++++++++++++++++++++++++ src/sidebar/paste.ts | 18 ++++++++++++ src/sidebar/style.css | 8 ++++- 4 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 src/sidebar/paste.test.ts create mode 100644 src/sidebar/paste.ts diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts index e737d47..5e5ba68 100644 --- a/src/sidebar/index.ts +++ b/src/sidebar/index.ts @@ -1,7 +1,8 @@ import browser from 'webextension-polyfill'; import type { RunState, WorkItem, Verdict, SidebarView, SidebarUpdateMsg, ActiveItemInfo } from '../shared/types'; import { getBroker } from '../shared/brokers'; -import { brokerHostname, isOnHost } from '../shared/url'; +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 @@ -123,11 +124,7 @@ function renderGuidance(d: HTMLElement, item: ActiveItemInfo): void { 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.`); - warn.hidden = true; - input.addEventListener('input', () => { - const url = input.value.trim(); - warn.hidden = !url || isOnHost(url, item.renderedUrl); - }); + 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 }); 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/style.css b/src/sidebar/style.css index eb08b3a..5f88a5a 100644 --- a/src/sidebar/style.css +++ b/src/sidebar/style.css @@ -215,7 +215,13 @@ button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus-ring-sha .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-mono); line-height: var(--lh-small); margin-bottom: var(--s-2); } +.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 ────────────────────────────────────────────────────── */ From a5639d75a5727f8912a87fb1d528c6f415a3cee1 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 18:29:19 -0400 Subject: [PATCH 32/35] =?UTF-8?q?fix(sidebar):=20offsite=20view=20?= =?UTF-8?q?=E2=80=94=20no=20verdict=20controls=20off=20the=20broker's=20ho?= =?UTF-8?q?st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA surfaced it: navigate the broker tab off-host (address bar → google.com) and the sidebar still showed "Could this listing be you? Yes/No", letting you confirm a listing on google.com — which would record a bogus hit (listingUrl=google.com) and could generate a wrong opt-out draft. deriveView treated every non-results page as "details" → verdict, including off-host ones. (This is the off-host verdict-view finding from the deferred cluster; the demo made it worth doing now.) - New `offsite` SidebarView. deriveView gates guidance/verdict on isOnHost(tabUrl, renderedUrl), checked after challenge (a Cloudflare interstitial is legitimately off-host) and before results/details — so an off-host page, OR a lookalike host sitting at the results pathname (isResultsPage only compares the path), can no longer read as verdict/guidance. null/unknown tabUrl → offsite too (conservative: don't offer confirm unless we can confirm the host). - renderOffsite: "This tab isn't on {broker} right now" + a "Back to my results" action (NAVIGATE_BROKER_TAB to renderedUrl — used as a nav target, never shown, PII-safe) + Defer. - +4 deriveView tests (off-host → offsite, lookalike host → offsite, challenge still wins off-host, on-host unaffected); null-tabUrl test updated verdict→offsite; resting-set test updated. plan §3 documents it (eight resting views). Note: this fixes the off-host *verdict* path. The paste-box warning (separate, verified working) guards the sidebar's paste field — a different entry point than the address bar. typecheck + 164 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- plan/sidebar-nav.md | 3 ++- src/shared/types.ts | 3 +++ src/sidebar/index.ts | 12 ++++++++++++ src/sidebar/state.test.ts | 30 +++++++++++++++++++++++++++--- src/sidebar/state.ts | 10 +++++++--- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/plan/sidebar-nav.md b/plan/sidebar-nav.md index 0309a73..1784716 100644 --- a/plan/sidebar-nav.md +++ b/plan/sidebar-nav.md @@ -85,13 +85,14 @@ No new `permissions` — `sidebarAction` is available whenever `sidebar_action` - `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". 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 seven **resting** views — `no-run` / `guidance` / `verdict` / `challenge` / `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 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"). diff --git a/src/shared/types.ts b/src/shared/types.ts index dd40181..b6c7715 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -143,6 +143,9 @@ export type SidebarView = | { 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 diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts index 5e5ba68..0889f42 100644 --- a/src/sidebar/index.ts +++ b/src/sidebar/index.ts @@ -76,6 +76,7 @@ function renderView(view: SidebarView): void { 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; @@ -156,6 +157,17 @@ function renderChallenge(d: HTMLElement, item: ActiveItemInfo): void { 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.")); diff --git a/src/sidebar/state.test.ts b/src/sidebar/state.test.ts index f426fa2..2dea500 100644 --- a/src/sidebar/state.test.ts +++ b/src/sidebar/state.test.ts @@ -93,8 +93,8 @@ describe('deriveView — challenge / guidance / verdict (focused item)', () => { expect(v.item.pageType).toBe('details'); }); - it('null tab URL → details/verdict (can\'t confirm results)', () => { - expect(deriveView(incomplete, focus({ tabUrl: null }), brokers).view).toBe('verdict'); + 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', () => { @@ -175,14 +175,38 @@ describe('deriveView — results↔details boundary', () => { }); }); +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('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', 'revisit', 'done', 'stopped']); + 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], ]; diff --git a/src/sidebar/state.ts b/src/sidebar/state.ts index eb0bca1..d9e6715 100644 --- a/src/sidebar/state.ts +++ b/src/sidebar/state.ts @@ -5,7 +5,7 @@ import type { RunState, WorkItem, SidebarView, ActiveItemInfo, PageType, RunProgress } from '../shared/types'; import { BROKERS, type Broker } from '../shared/brokers'; -import { isResultsPage } from '../shared/url'; +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 @@ -45,11 +45,15 @@ export function deriveView( return { view: 'done', progress }; } - // A focused broker tab → show its active-item detail. Challenge outranks page-type: a - // CAPTCHA hides the listing, so there's nothing to judge until it clears. + // 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 }; From a7a6cc2d28aec2d4cfdc3f2aea4a6ae5fc84a30e Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 19:16:22 -0400 Subject: [PATCH 33/35] fix(sidebar): keep transient latch through the recorded animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 (verdict ACK) hoisted `transient = false` to right after sendVerdictAck, so on the success path the latch was open during the recorded + 800 ms window. But handleVerdict pushes the NEXT item's SIDEBAR_UPDATE before returning the ACK (it calls advance first), so that push races the reply — landing during the unguarded window, it renders the next item and clobbers the "✓ recorded" confirmation. The latch exists to prevent exactly that yank; timing-dependent, so it passed casual testing and would flake in the wild. Move `transient = false` out of the hoisted spot into the two branches: on failure, clear it immediately (restore controls to retry); on success, clear it only AFTER the 800 ms recorded animation. The trailing pullState still re-derives the true latest state (next item, or a Stop/Delete that arrived during the suppressed window), so suppressing pushes for 800 ms loses nothing. typecheck + 164 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sidebar/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts index 0889f42..9ceed45 100644 --- a/src/sidebar/index.ts +++ b/src/sidebar/index.ts @@ -264,17 +264,22 @@ async function castVerdict(itemId: string, verdict: Verdict): Promise { renderSaving(d); const ok = await sendVerdictAck(itemId, verdict); - transient = false; 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 } From 6b39949b03fc0e3574085f7946876f3cb6e30a95 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 19:27:34 -0400 Subject: [PATCH 34/35] test: exclude sidebar/index.ts entrypoint from coverage; cover state.ts pathnameOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new src/sidebar/index.ts is the sidebar's imperative render/message-wiring shell — same category as the background/content/options/popup entrypoints, which are all excluded from coverage (they run addListener/init at import and aren't unit-tested; their pure logic is extracted and covered). It was just missing from the exclude list, so its 0% dragged the global totals under threshold. Added it — its pure parts (state.ts, paste.ts) are already covered. Also covered state.ts's pathnameOf catch branch (unparseable tab URL → offsite) so the pure module is back to 100%, matching every other pure module. And refreshed the stale content/index.ts exclude comment (it's a headless challenge reporter now, not an overlay injector). Coverage: 100% statements/branches/functions/lines, thresholds pass. typecheck + 165 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sidebar/state.test.ts | 4 ++++ vitest.config.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/sidebar/state.test.ts b/src/sidebar/state.test.ts index 2dea500..5f88868 100644 --- a/src/sidebar/state.test.ts +++ b/src/sidebar/state.test.ts @@ -188,6 +188,10 @@ describe('deriveView — offsite (tab left the broker host)', () => { 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'); }); 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'], From e4eb87d1cc2c6fe096350c995ebb98c6d82eb8f2 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Wed, 1 Jul 2026 19:37:21 -0400 Subject: [PATCH 35/35] wherefore capture --- wherefore/INDEX.md | 1 + wherefore/QUESTIONS.md | 1 + wherefore/log/2026-07-01-sidebar-nav-built.md | 36 +++++++++++++++++++ wherefore/questions/Q-016.md | 10 ++++++ 4 files changed, 48 insertions(+) create mode 100644 wherefore/log/2026-07-01-sidebar-nav-built.md create mode 100644 wherefore/questions/Q-016.md 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-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: +---