Skip to content

Sidebar-driven run navigation: replace the per-tab overlay with a native Firefox sidebar#4

Merged
DustinVK merged 35 commits into
mainfrom
feat/sidebar-nav
Jul 1, 2026
Merged

Sidebar-driven run navigation: replace the per-tab overlay with a native Firefox sidebar#4
DustinVK merged 35 commits into
mainfrom
feat/sidebar-nav

Conversation

@DustinVK

@DustinVK DustinVK commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Replaces the shadow-DOM overlay injected into every broker page with a Firefox native sidebar_action panel that drives the whole run. The sidebar is a persistent, window-level
checklist: the broker page stays fully visible (the browser reflows for it), it survives tab switches and navigations for free, and it lets us delete the fragile per-tab
re-injection loop (PING/PONG/reinjectIfMissing). Resolves Q-013; Q-015 confirmed empirically in Firefox 140 (one Start click drives the permission prompt, the sidebar open, and
the run start).

Built in reviewed slices — the full design and slice-by-slice status live in plan/sidebar-nav.md.

Architecture

  • One source of view truth. deriveView (src/sidebar/state.ts) is a pure (run, focus) → SidebarView. The background builds the inputs and pushes SIDEBAR_UPDATE; the sidebar
    renders and never re-derives.
  • New deferred work-item state, non-terminal: keeps the tab open, frees its batch slot, revisited at the end with a MAX_OPEN_TABS = 15 ceiling so "defer everything" can't
    open the entire broker list at once.
  • Run pinned to one window. windowId is threaded through; SIDEBAR_UPDATE is window-scoped, so an idle window's sidebar never adopts the run window's view.
  • Content script → headless challenge reporter (~680 → ~45 lines). No UI, never touches the page DOM; reports only bot-challenge state per load.

Sidebar views

Eight resting views derived from run + focus - no-run / guidance / verdict / challenge / offsite / revisit / done / stopped - plus transient saving/recorded owned by the UI
layer. Notably:

  • offsite: the broker tab wandered off the broker host: no verdict controls, so a listing can't be confirmed on, e.g., google.com.
  • stopped: honest "checked X of Y" that excludes run-stopped items (distinct from done's "all clear").
  • revisit: carries focusId so the button works on first render without a run-state round-trip.

Message contract

SIDEBAR_GET_STATE · SIDEBAR_UPDATE {windowId} · VERDICT · DEFER · FOCUS_ITEM · NAVIGATE_BROKER_TAB · CHALLENGE_DETECTED/RESOLVED. Removed the dead
PING/PONG/GET_ITEM/REINJECT_OVERLAY machinery and the scripting + webNavigation permissions.

Correctness & data safety

  • Verdict path honors the ACK contract: 6s timeout, up to 3 retries, shows "recorded" only on confirmed write; handleVerdict no-wedge guard makes retries idempotent.
  • The sidebar renders only generic broker data (names, exposes chips, search.guidance) — never the user's real name or the name-bearing renderedUrl/listingUrl (STYLEGUIDE §0).
  • Fonts self-hosted (OFL) so no more per-surface request to the Google Fonts CDN, in keeping with "nothing about the user leaves their machine."
  • Off-host paste warning; paste navigates the item's own tab.

Testing

npm run typecheck, npm test (165 tests), npm run build all green. Coverage 100% (pure logic fully covered; browser/DOM entrypoints excluded as integration-test TODOs). Manually
walked through in Firefox 140+.

Follow-ups (not in this PR)

Two pieces of the challenge-flag lifecycle cluster remain parked: load-time flicker and the loose per-tab flag. Neither is a data-loss bug. webNavigation.onErrorOccurred
(load-error view) is still deferred to M9.

DustinVK and others added 30 commits July 1, 2026 11:23
…, shared completion

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) <noreply@anthropic.com>
…e url helpers to shared

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ng perm

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…hared progress

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) <noreply@anthropic.com>
…idebar

- sidebar-nav.md: mark §1/§2/§3/§5/§6/§7 ✅ DONE with commit refs; document
  Decision A under §3 (detail+view from the pushed SidebarView; grouped checklist
  from GET_RUN_STATE re-fetched per update; skew negligible; PII invariant on rows).
- expurge-progress.md: flip the overlay→sidebar item from planned to done; note
  the sidebar gained an interactive clickable checklist beyond the old overlay,
  and that the pre-migration content/popup/background rows are superseded.
The sidebar migration removed webNavigation's last would-be user (it was only
ever reserved, never wired — grep confirms zero refs in src/). An unused
permission is an AMO-review smell, same as the scripting perm dropped in 6a.

permissions → ["storage","tabs","downloads"]. The _notes breadcrumb is kept and
rewritten to "re-add when M9 wires webNavigation.onErrorOccurred".

dist/manifest.json valid; typecheck + 147 tests + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atestRun)

renderView renders the view, THEN refreshChecklist() async-sets latestRun. But
renderRevisit read latestRun to find the first deferred item — so when the
sidebar's FIRST render was `revisit` (reopen mid-revisit / after resume),
latestRun was still null and the "Revisit set-aside sites" button rendered
DISABLED (the clickable Waiting rows still worked, but the button looked broken).

Make the pure function the source of truth: deriveView's revisit branch computes
focusId = first deferred ?? first pending ?? null (first pending covers the
blocked-behind-deferred case; FOCUS_ITEM's ensureItemTab opens a pending one) and
carries it in the view. renderRevisit takes focusId from the view and disables the
button only when it's null — no dependency on the async checklist fetch. The now-
unused module-level `latestRun` is dropped (made local in refreshChecklist).

types: revisit variant gains `focusId: string | null`. state.test.ts: revisit
tests assert focusId (first deferred; first pending when no deferred). plan §3
notes the carried focusId.

typecheck + 148 tests + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every surface (popup, options, sidebar) loaded fonts from fonts.googleapis.com,
so for an extension whose pitch is "nothing about the user leaves their machine,"
opening any surface pinged Google (IP + usage). Pre-existing (the migration only
matched the popup/options pattern); fixing it project-wide.

Vendored the latin-subset woff2 locally (SIL OFL 1.1, redistribution fine —
src/styles/fonts/LICENSE.md carries per-family copyrights + the full license):
- Hanken Grotesk (variable wght axis, one file covers 400/500/600), 35 KB
- IBM Plex Mono 400, 15 KB
- Newsreader 600, 24 KB

- src/styles/fonts.css: @font-face blocks with url("fonts/*.woff2"), font-display
  swap. Loaded by all three surfaces via a plain <link> — NOT bundled by esbuild,
  so the woff2 url()s resolve verbatim against dist/ (no woff2 loader needed).
- build.mjs copyStatics: copy fonts.css → dist/fonts.css and src/styles/fonts →
  dist/fonts (woff2 + LICENSE).
- Removed the 3 Google <link> tags (2 preconnect + 1 stylesheet) from all three
  index.html; added <link href="fonts.css">.

Glyphs outside the latin subset fall back to the system stacks already in
tokens.css. Verified: zero googleapis/gstatic refs in src or dist; woff2 present
in dist/fonts; no external network request on surface open.

typecheck + 148 tests + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Firefox auto-opens a sidebar_action panel when the extension installs/loads,
which is confusing next to the options page that already opens on install
(onInstalled → openOptionsPage). Add the Firefox-specific
sidebar_action.open_at_install: false (this is a Firefox-only extension) so on
load only the options page opens; the sidebar opens only on the Start-run click
(and the new Show-scan-panel button).

dist/manifest.json valid; build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once the sidebar is closed there was no way back. Add a "Show scan panel" button
that calls browser.sidebarAction.open() (synchronous in the click gesture; opens
in the active window, which then SIDEBAR_GET_STATEs — showing the run if it's the
run's window, else no-run). No windowId plumbing needed.

- popup (primary): button in the active + done states (a run exists); absent on
  no-run. Reuses the existing .popup-actions flex row — no new CSS.
- options (secondary): same button in the Run section's active action-row.

typecheck + 148 tests + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ailure)

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…o-wedge)

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
DustinVK and others added 5 commits July 1, 2026 18:09
…e visible

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ts pathnameOf

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) <noreply@anthropic.com>
@DustinVK DustinVK merged commit 131e61a into main Jul 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant