diff --git a/docs/src/content/docs/install.md b/docs/src/content/docs/install.md
index 7177949..9b7cce7 100644
--- a/docs/src/content/docs/install.md
+++ b/docs/src/content/docs/install.md
@@ -59,6 +59,22 @@ Build output is written to `extension/dist/`.
The extension is now active. Reload any open tabs to pick up the content script.
+### Reading the toolbar icon
+
+The toolbar icon answers "am I protected on this tab?" at a glance, so you don't
+have to open the popup:
+
+- **Blue shield, no badge** — protected, nothing scrubbed on this page yet.
+- **Blue shield with a number** — protected; the number is how many things the
+ shield acted on across all frames of the tab. A count over 999 shows as
+ `999+`.
+- **Amber badge** — a site-level detection worth opening the popup for (for
+ example, a hard-to-cancel subscription). When there's a detection but no
+ count, the badge shows `!`.
+- **Greyed shield with an `off` badge** — enforcement is paused on this tab,
+ either because you turned the master switch off (every tab) or because the
+ site is on your *Disable on this site* list. Hover the icon to see which.
+
## Customizing defaults at build time
Which rules ship on by default is enumerated in
diff --git a/extension/icons/icon-off-16.png b/extension/icons/icon-off-16.png
new file mode 100644
index 0000000..cf1fdf0
Binary files /dev/null and b/extension/icons/icon-off-16.png differ
diff --git a/extension/icons/icon-off-24.png b/extension/icons/icon-off-24.png
new file mode 100644
index 0000000..3444e92
Binary files /dev/null and b/extension/icons/icon-off-24.png differ
diff --git a/extension/icons/icon-off-32.png b/extension/icons/icon-off-32.png
new file mode 100644
index 0000000..d6a6fb1
Binary files /dev/null and b/extension/icons/icon-off-32.png differ
diff --git a/extension/icons/icon-off.svg b/extension/icons/icon-off.svg
new file mode 100644
index 0000000..eb4f3e4
--- /dev/null
+++ b/extension/icons/icon-off.svg
@@ -0,0 +1,5 @@
+
diff --git a/extension/scripts/build-icons.ts b/extension/scripts/build-icons.ts
index 28242e2..d4c2913 100644
--- a/extension/scripts/build-icons.ts
+++ b/extension/scripts/build-icons.ts
@@ -1,11 +1,11 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.
-// Renders extension/icons/icon.svg to PNGs at the sizes the manifest and the
+// Renders extension/icons/*.svg to PNGs at the sizes the manifest and the
// Chrome Web Store listing need. Generated files are committed alongside the
-// SVG so contributors without the renderer dep can still build the extension.
+// SVGs so contributors without the renderer dep can still build the extension.
//
-// Run manually with `bun run build-icons` after editing icon.svg.
+// Run manually with `bun run build-icons` after editing a source SVG.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -13,29 +13,41 @@ import { Resvg } from "@resvg/resvg-js";
const ROOT = join(import.meta.dir, "..");
const ICONS = join(ROOT, "icons");
-const SOURCE = join(ICONS, "icon.svg");
-// 16/24/32 cover the toolbar action; 48 is Chrome's extensions page tile;
-// 128 is required by the Chrome Web Store store-listing icon.
-const SIZES = [16, 24, 32, 48, 128];
+// Each source renders to `${prefix}-${size}.png`.
+// - icon.svg: the blue shield (manifest default_icon + Web Store listing).
+// 16/24/32 cover the toolbar action; 48 is Chrome's extensions page
+// tile; 128 is required by the Chrome Web Store store-listing icon.
+// - icon-off.svg: the greyed "enforcement off" variant the background
+// swaps in via chrome.action.setIcon (spec 0010 FR-2a). Toolbar sizes
+// only — it is never used on the extensions page or the store listing.
+const SOURCES = [
+ { svg: "icon.svg", prefix: "icon", sizes: [16, 24, 32, 48, 128] },
+ { svg: "icon-off.svg", prefix: "icon-off", sizes: [16, 24, 32] },
+] as const;
-export function generateIcons(): void {
- if (!existsSync(SOURCE)) {
- throw new Error(`Missing source SVG at ${SOURCE}`);
- }
- const svg = readFileSync(SOURCE);
-
- for (const size of SIZES) {
- const png = new Resvg(svg, {
- fitTo: { mode: "width", value: size },
- })
- .render()
- .asPng();
- writeFileSync(join(ICONS, `icon-${size}.png`), png);
+export function generateIcons(): number {
+ let written = 0;
+ for (const { svg: file, prefix, sizes } of SOURCES) {
+ const source = join(ICONS, file);
+ if (!existsSync(source)) {
+ throw new Error(`Missing source SVG at ${source}`);
+ }
+ const svg = readFileSync(source);
+ for (const size of sizes) {
+ const png = new Resvg(svg, {
+ fitTo: { mode: "width", value: size },
+ })
+ .render()
+ .asPng();
+ writeFileSync(join(ICONS, `${prefix}-${size}.png`), png);
+ written += 1;
+ }
}
+ return written;
}
if (import.meta.main) {
- generateIcons();
- console.log(`Generated ${SIZES.length} icon PNGs in ${ICONS}`);
+ const count = generateIcons();
+ console.log(`Generated ${count} icon PNGs in ${ICONS}`);
}
diff --git a/extension/src/background.ts b/extension/src/background.ts
index 67cb8c8..a852798 100644
--- a/extension/src/background.ts
+++ b/extension/src/background.ts
@@ -24,12 +24,26 @@ import type {
RuleDetectionMessage,
} from "./lib/detection-messages";
import { startDumpTraceBridgeRegistration } from "./lib/dump-trace-bridge-registration";
-import { subscribeEnforcementEnabled } from "./lib/enforcement";
+import {
+ getEnforcementEnabled,
+ subscribeEnforcementEnabled,
+} from "./lib/enforcement";
import { startClassifyPortListener } from "./lib/llm-background";
import { log } from "./lib/log";
import { startShadowRootProbeRegistration } from "./lib/shadow-root-probe-registration";
import { installShadowRootProbe } from "./lib/shadow-root-probe-source";
+import { siteDenylistStorage } from "./lib/site-denylist";
import { ruleStatesStorage } from "./lib/storage";
+import type { ProtectionState } from "./lib/toolbar-protection";
+import {
+ ACTION_ICON_OFF,
+ ACTION_ICON_ON,
+ actionTitle,
+ computeProtectionState,
+ PROTECTION_OFF_BADGE_COLOR,
+ PROTECTION_OFF_BADGE_TEXT,
+ protectionAppearanceKey,
+} from "./lib/toolbar-protection";
import { startWebdriverProbeRegistration } from "./lib/webdriver-probe-registration";
import { installProbe } from "./lib/webdriver-probe-source";
import type { RuleId } from "./rules/rule-metadata";
@@ -53,6 +67,26 @@ const KNOWN_RULE_IDS = new Set(RULE_IDS);
// problem.
const tabDetections = new Map>();
+// Inputs to the per-tab "am I protected here?" signal (spec 0010 FR-2a):
+// the global enforcement kill-switch and the per-site denylist. Seeded from
+// storage at startup and kept current by the subscriptions below. Defaults
+// match the fail-open posture — assume protection is on until storage
+// resolves so we never flash an "off" badge on a tab that's actually
+// protected.
+let enforcementEnabled = true;
+let denylist: readonly string[] = [];
+
+// Last top-frame URL seen per tab, so the background can evaluate the
+// denylist for any tab without round-tripping the page. Captured from
+// tabs.onUpdated / onActivated / a startup tabs.query; dropped on tab close.
+const tabUrls = new Map();
+
+// Memo of the icon/title appearance last applied per tab, keyed by
+// `protectionAppearanceKey`, so we only call setIcon/setTitle when the
+// on/off state actually flips. The numeric count badge still refreshes on
+// every rule-count message.
+const tabAppearanceKey = new Map();
+
// Maps each detection kind to the rule id that produces it. Used to clear
// stale entries when a user toggles the rule off mid-session.
const DETECTION_KIND_TO_RULE_ID = {
@@ -112,6 +146,29 @@ function hasDetections(tabId: number): boolean {
}
function refreshBadge(tabId: number): void {
+ const state = computeProtectionState({
+ enforcementEnabled,
+ tabUrl: tabUrls.get(tabId) ?? null,
+ denylist,
+ });
+ applyProtectionAppearance(tabId, state);
+ if (state.off) {
+ // Rules don't run (denylisted) or were revealed (global off) on this
+ // tab, so a count would be misleading. Show the explicit "off" badge
+ // instead, so a clean protected page and an unprotected page never look
+ // identical — that ambiguity is the whole reason this signal exists.
+ chrome.action
+ .setBadgeText({ tabId, text: PROTECTION_OFF_BADGE_TEXT })
+ .catch(() => {
+ // noop
+ });
+ chrome.action
+ .setBadgeBackgroundColor({ tabId, color: PROTECTION_OFF_BADGE_COLOR })
+ .catch(() => {
+ // noop
+ });
+ return;
+ }
const placeholderText = formatBadge(totalForTab(tabId));
const detection = hasDetections(tabId);
// Detection without a placeholder count gets a single "!" so the badge
@@ -129,6 +186,54 @@ function refreshBadge(tabId: number): void {
}
}
+// Swap the toolbar icon + tooltip to match the tab's protection state, but
+// only when it changed — reissuing setIcon on every rule-count message would
+// be needless churn. The greyed icon is the primary "you're unprotected
+// here" signal; the badge reinforces it. Per-tab action settings persist for
+// the tab's lifetime, so each tab the user might look at must be painted at
+// least once (see refreshAllTabs and the tab listeners below).
+function applyProtectionAppearance(
+ tabId: number,
+ state: ProtectionState,
+): void {
+ const key = protectionAppearanceKey(state);
+ if (tabAppearanceKey.get(tabId) === key) {
+ return;
+ }
+ tabAppearanceKey.set(tabId, key);
+ chrome.action
+ .setIcon({ tabId, path: state.off ? ACTION_ICON_OFF : ACTION_ICON_ON })
+ .catch(() => {
+ // Restricted tabs (chrome://, Web Store) reject setIcon — swallow it.
+ });
+ chrome.action.setTitle({ tabId, title: actionTitle(state) }).catch(() => {
+ // noop
+ });
+}
+
+// Re-evaluate every open tab's toolbar appearance. Used when a *global*
+// input to the protection signal changes (enforcement toggle, denylist
+// edit): per-tab icons persist, so every tab the user might switch to has to
+// be repainted, not just the ones we're tracking counts for.
+function refreshAllTabs(): void {
+ chrome.tabs
+ .query({})
+ .then((tabs) => {
+ for (const tab of tabs) {
+ if (typeof tab.id !== "number") {
+ continue;
+ }
+ if (typeof tab.url === "string") {
+ tabUrls.set(tab.id, tab.url);
+ }
+ refreshBadge(tab.id);
+ }
+ })
+ .catch(() => {
+ // noop — tabs.query rejection shouldn't surface.
+ });
+}
+
function recordFrameRuleCounts(
tabId: number,
frameId: number,
@@ -186,6 +291,8 @@ function clearDetectionsOfKind(kind: DetectionKind): void {
chrome.tabs.onRemoved.addListener((tabId) => {
tabRuleCounts.delete(tabId);
tabDetections.delete(tabId);
+ tabUrls.delete(tabId);
+ tabAppearanceKey.delete(tabId);
// Fire-and-forget — IDB write may outlive the listener context.
void clearDebugTraceTab(tabId).catch(() => {
// noop
@@ -199,7 +306,20 @@ chrome.tabs.onRemoved.addListener((tabId) => {
// the same toggle that gates content-script emission so the trace stays empty
// when the toggle is off.
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
+ // Keep the cached top-frame URL current so the denylist evaluation in
+ // refreshBadge sees the right URL — including the new document during a
+ // "loading" event, before clearTab repaints below.
+ if (typeof tab.url === "string") {
+ tabUrls.set(tabId, tab.url);
+ }
if (changeInfo.status !== "loading") {
+ // A client-side URL change (SPA pushState / hash) arrives without a
+ // fresh "loading" status. The denylist is host-scoped so this rarely
+ // flips protection, but re-evaluate so the toolbar stays correct on
+ // cross-host in-page navigations.
+ if (typeof changeInfo.url === "string") {
+ refreshBadge(tabId);
+ }
return;
}
clearTab(tabId);
@@ -221,26 +341,58 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
})();
});
-// Re-render every tab's badge when enforcement is toggled. When disabled, the
-// rule engine reveals everything in each frame, which will eventually push
-// zero counts back — but doing this synchronously keeps the badge from
-// looking stale for the duration of those mutation observer cycles.
+// A newly-activated tab may be one we opened before the service worker
+// started (no onUpdated seen) — learn its URL and paint its toolbar state so
+// the icon/badge is right the moment the user looks at it.
+chrome.tabs.onActivated.addListener(({ tabId }) => {
+ chrome.tabs
+ .get(tabId)
+ .then((tab) => {
+ if (typeof tab.url === "string") {
+ tabUrls.set(tabId, tab.url);
+ }
+ refreshBadge(tabId);
+ })
+ .catch(() => {
+ // Tab may have closed between the event and the get — ignore.
+ });
+});
+
+// Re-render every tab's toolbar state when global enforcement is toggled.
+// When disabled, the rule engine reveals everything and the cached per-tab
+// counts/detections are immediately stale, so drop them — neither the badge
+// nor the popup should show a number for paused rules. refreshAllTabs then
+// repaints every open tab (not just the tracked ones) so the greyed "off"
+// icon reaches tabs we weren't counting.
subscribeEnforcementEnabled((enabled) => {
- if (enabled) {
- return;
- }
- // Snapshot the union of tabs we're tracking before clearing — the badge
- // for each needs to refresh, and we don't want to iterate a map we're
- // mutating.
- const affected = new Set([
- ...tabRuleCounts.keys(),
- ...tabDetections.keys(),
- ]);
- tabRuleCounts.clear();
- tabDetections.clear();
- for (const tabId of affected) {
- refreshBadge(tabId);
+ enforcementEnabled = enabled;
+ if (!enabled) {
+ tabRuleCounts.clear();
+ tabDetections.clear();
}
+ refreshAllTabs();
+});
+
+// The per-site denylist is the other input to the protection signal. A
+// denylist edit can flip any open tab between protected and off, so repaint
+// them all. (The content-side rule engine reacts to the same storage change
+// independently; this is purely the toolbar's view of it.)
+siteDenylistStorage.subscribe((next) => {
+ denylist = next;
+ refreshAllTabs();
+});
+
+// Seed the protection-signal caches from storage, then paint every open tab.
+// Service-worker IIFE — no top-level await, so chain `.get().then(...)`.
+// eslint-disable-next-line unicorn/prefer-top-level-await -- IIFE bundle, no TLA
+void getEnforcementEnabled().then((enabled) => {
+ enforcementEnabled = enabled;
+ refreshAllTabs();
+});
+// eslint-disable-next-line unicorn/prefer-top-level-await -- IIFE bundle, no TLA
+void siteDenylistStorage.get().then((next) => {
+ denylist = next;
+ refreshAllTabs();
});
// When a user disables one of the detection-producing rules mid-session,
diff --git a/extension/src/lib/__tests__/toolbar-protection.test.ts b/extension/src/lib/__tests__/toolbar-protection.test.ts
new file mode 100644
index 0000000..44d4ff4
--- /dev/null
+++ b/extension/src/lib/__tests__/toolbar-protection.test.ts
@@ -0,0 +1,120 @@
+// Copyright (c) 2026 PixieBrix, Inc.
+// Licensed under PolyForm Shield 1.0.0 — see LICENSE.
+
+import {
+ ACTION_ICON_OFF,
+ ACTION_ICON_ON,
+ actionTitle,
+ computeProtectionState,
+ PROTECTION_OFF_BADGE_COLOR,
+ PROTECTION_OFF_BADGE_TEXT,
+ protectionAppearanceKey,
+} from "../toolbar-protection";
+
+const DENYLIST = ["https://denied.test/*"];
+
+describe("computeProtectionState", () => {
+ it("is off (global) when enforcement is disabled, regardless of URL", () => {
+ expect(
+ computeProtectionState({
+ enforcementEnabled: false,
+ tabUrl: "https://allowed.test/page",
+ denylist: DENYLIST,
+ }),
+ ).toEqual({ off: true, reason: "global" });
+ });
+
+ it("global off wins even on a denylisted URL", () => {
+ expect(
+ computeProtectionState({
+ enforcementEnabled: false,
+ tabUrl: "https://denied.test/page",
+ denylist: DENYLIST,
+ }),
+ ).toEqual({ off: true, reason: "global" });
+ });
+
+ it("is off (site) when enforcement is on but the URL is denylisted", () => {
+ expect(
+ computeProtectionState({
+ enforcementEnabled: true,
+ tabUrl: "https://denied.test/checkout",
+ denylist: DENYLIST,
+ }),
+ ).toEqual({ off: true, reason: "site" });
+ });
+
+ it("is on when enforcement is on and the URL is not denylisted", () => {
+ expect(
+ computeProtectionState({
+ enforcementEnabled: true,
+ tabUrl: "https://allowed.test/page",
+ denylist: DENYLIST,
+ }),
+ ).toEqual({ off: false });
+ });
+
+ it("fails open (on) when the tab URL is unknown", () => {
+ expect(
+ computeProtectionState({
+ enforcementEnabled: true,
+ tabUrl: null,
+ denylist: DENYLIST,
+ }),
+ ).toEqual({ off: false });
+ });
+
+ it("is on when the denylist is empty", () => {
+ expect(
+ computeProtectionState({
+ enforcementEnabled: true,
+ tabUrl: "https://anything.test/",
+ denylist: [],
+ }),
+ ).toEqual({ off: false });
+ });
+});
+
+describe("actionTitle", () => {
+ it("is the plain product name when protected", () => {
+ expect(actionTitle({ off: false })).toBe("Agent Browser Shield");
+ });
+
+ it("names the global kill-switch when off for all tabs", () => {
+ expect(actionTitle({ off: true, reason: "global" })).toContain("all tabs");
+ });
+
+ it("names the per-site scope when off on one site", () => {
+ expect(actionTitle({ off: true, reason: "site" })).toContain(
+ "on this site",
+ );
+ });
+});
+
+describe("protectionAppearanceKey", () => {
+ it("collapses the protected state to a single key", () => {
+ expect(protectionAppearanceKey({ off: false })).toBe("on");
+ });
+
+ it("distinguishes the two off reasons so a global→site flip repaints", () => {
+ expect(protectionAppearanceKey({ off: true, reason: "global" })).not.toBe(
+ protectionAppearanceKey({ off: true, reason: "site" }),
+ );
+ });
+});
+
+describe("appearance constants", () => {
+ it("exposes the toolbar sizes for both icon variants", () => {
+ for (const size of [16, 24, 32]) {
+ expect(ACTION_ICON_ON[size]).toMatch(/icon-\d+\.png$/);
+ expect(ACTION_ICON_OFF[size]).toMatch(/icon-off-\d+\.png$/);
+ }
+ });
+
+ it("uses a neutral (non-amber) off-badge color", () => {
+ expect(PROTECTION_OFF_BADGE_TEXT).toBe("off");
+ // Distinct from the amber detection color so the badge meanings don't
+ // collide.
+ expect(PROTECTION_OFF_BADGE_COLOR).not.toBe("#f59e0b");
+ });
+});
diff --git a/extension/src/lib/toolbar-protection.ts b/extension/src/lib/toolbar-protection.ts
new file mode 100644
index 0000000..da651d9
--- /dev/null
+++ b/extension/src/lib/toolbar-protection.ts
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 PixieBrix, Inc.
+// Licensed under PolyForm Shield 1.0.0 — see LICENSE.
+
+// Pure inputs → toolbar-appearance logic for the "am I protected here?"
+// signal (spec 0010 FR-2a). The background worker owns the chrome.action
+// side effects; everything decision-shaped lives here so it's unit-testable
+// without a chrome mock.
+//
+// A tab is "off" when the global enforcement kill-switch is off (every tab)
+// or when the active tab's top-frame URL matches the per-site denylist
+// (ADR-0018) — the same `globalEnforcement && !matchesDenylist(url)` rule
+// the content-side `effective-enforcement.ts` computes, recomputed here so
+// the toolbar can reflect it without a round-trip to the page.
+
+import { matchesDenylist } from "./site-denylist";
+
+export type ProtectionState =
+ | { readonly off: false }
+ | { readonly off: true; readonly reason: "global" | "site" };
+
+// `tabUrl` is null when the background hasn't yet learned the tab's URL
+// (e.g. a tab open before the service worker started, before the first
+// tabs.get / onUpdated). Treat unknown as protected — fail open, matching
+// the rule engine, rather than flashing an "off" badge on a tab whose state
+// we can't actually determine.
+export function computeProtectionState(input: {
+ enforcementEnabled: boolean;
+ tabUrl: string | null;
+ denylist: readonly string[];
+}): ProtectionState {
+ if (!input.enforcementEnabled) {
+ return { off: true, reason: "global" };
+ }
+ if (input.tabUrl !== null && matchesDenylist(input.tabUrl, input.denylist)) {
+ return { off: true, reason: "site" };
+ }
+ return { off: false };
+}
+
+// Toolbar action icon variants, as chrome.action.setIcon expects them
+// (paths relative to the extension root). The "on" set is the manifest
+// default_icon (blue shield); the "off" set is the desaturated grey shield
+// rendered from icons/icon-off.svg by scripts/build-icons.ts.
+export const ACTION_ICON_ON: Record = {
+ 16: "icons/icon-16.png",
+ 24: "icons/icon-24.png",
+ 32: "icons/icon-32.png",
+};
+
+export const ACTION_ICON_OFF: Record = {
+ 16: "icons/icon-off-16.png",
+ 24: "icons/icon-off-24.png",
+ 32: "icons/icon-off-32.png",
+};
+
+// Neutral slate for the "off" badge — deliberately NOT the amber detection
+// color (`#f59e0b`) so the three badge meanings stay distinct: blue = an
+// activity count, amber = a detection worth opening the popup for, grey =
+// the shield is inactive on this tab. Grey also pairs with the greyed-out
+// icon variant; "disabled" reads as desaturated, not as a warning.
+export const PROTECTION_OFF_BADGE_TEXT = "off";
+export const PROTECTION_OFF_BADGE_COLOR = "#6b7280";
+
+// Toolbar tooltip. Differentiates global vs per-site so hovering the action
+// answers *why* the shield is off without opening the popup.
+export function actionTitle(state: ProtectionState): string {
+ if (!state.off) {
+ return "Agent Browser Shield";
+ }
+ return state.reason === "global"
+ ? "Agent Browser Shield — enforcement off (all tabs)"
+ : "Agent Browser Shield — enforcement off on this site";
+}
+
+// Stable key for the icon/title appearance so the background only issues
+// setIcon/setTitle when the on/off state actually flips — the numeric count
+// badge still refreshes on every rule-count message.
+export function protectionAppearanceKey(state: ProtectionState): string {
+ return state.off ? `off:${state.reason}` : "on";
+}
diff --git a/specs/0010-extension-ui-and-controls.md b/specs/0010-extension-ui-and-controls.md
index 06ec1bb..ab11628 100644
--- a/specs/0010-extension-ui-and-controls.md
+++ b/specs/0010-extension-ui-and-controls.md
@@ -28,6 +28,10 @@ all-or-nothing, and users pick "nothing."
- As a **person who wants to see what the shield did on this page**, I want a
badge with a per-tab activity count and a popup listing per-rule footprint, so
that I can verify protection is working at a glance.
+- As a **person who can't remember whether I paused the shield here**, I want
+ the toolbar icon itself to go grey with an *off* badge when enforcement is off
+ — globally or just on this site — so that "am I protected on this tab?" is
+ answerable without opening the popup.
- As a **person hitting a false positive**, I want a one-click enforcement pause
that disables every rule for every tab without losing my per-rule selections,
so that I can finish my task and restore protection later.
@@ -67,6 +71,18 @@ all-or-nothing, and users pick "nothing."
worth opening the popup for (roach-motel-annotate, webdriver-probe-annotate,
closed-shadow-root-annotate). When a detection is present without an activity
count, the badge text falls back to `!` so the badge stays visible.
+- **FR-2a.** The toolbar action reflects per-tab **protection state** so a
+ protected page and an unprotected page never look identical. A tab is *off*
+ when global enforcement is off (FR-5) **or** the active tab's top-frame URL
+ matches the per-site denylist (FR-7c). On an *off* tab the action shows the
+ greyed icon variant (`icons/icon-off-*.png`), an `off` badge in neutral grey
+ (`#6b7280` — deliberately not the amber detection color, so the three badge
+ meanings stay distinct), and a tooltip naming the scope (*"enforcement off
+ (all tabs)"* vs *"enforcement off on this site"*). The signal is recomputed
+ when enforcement toggles, the denylist changes, or the tab navigates; the
+ icon/tooltip are only re-issued when the on/off state flips, while the count
+ badge still refreshes on every rule-count message. The greyed icon is the
+ primary signal; the badge and tooltip reinforce it.
- **FR-3.** Top-level navigation drops stale per-frame counts so the new
document starts from zero; content scripts in the new document report fresh
numbers as rules run.
@@ -225,6 +241,13 @@ all-or-nothing, and users pick "nothing."
- FR-1, FR-2, FR-3: `extension/src/background.ts` (`refreshBadge`,
`recordFrameRuleCounts`, `recordDetection`, `clearTab`).
+- FR-2a: `extension/src/lib/toolbar-protection.ts` (pure
+ `computeProtectionState` / `actionTitle` / appearance constants, tested in
+ `extension/src/lib/__tests__/toolbar-protection.test.ts`),
+ `extension/src/background.ts` (`applyProtectionAppearance`, `refreshAllTabs`,
+ the `tabUrls` cache, and the enforcement/denylist subscriptions that drive
+ it), `extension/icons/icon-off.svg` (rendered to PNGs by
+ `extension/scripts/build-icons.ts`).
- FR-4, FR-5, FR-6, FR-7: `extension/src/popup/Popup.tsx`,
`extension/src/popup/PerRuleCountsSection.tsx`,
`extension/src/popup/DetectionsSection.tsx`,