diff --git a/README.md b/README.md
index 8510a8d..15fcdab 100644
--- a/README.md
+++ b/README.md
@@ -27,13 +27,19 @@ The same source builds for both browser families:
- **SEO tab** — title / meta / og / twitter / JSON-LD with a Twitter + Facebook card preview and lints (length, missing image, duplicate `
`, etc.)
- **Recently viewed components** persisted across sessions, with one-click jump back
- **Resizable + dockable panel** — drag the inner edges (or the inner-corner grabber) to resize width _and_ height; choose any of four corners or a full-height left/right side dock
-- **Refined highlight modes** — _Off_, _Selection_ (default; pristine page, hover and click highlight in blue, hold ⌃ Control to flash the rainbow over every component), _Editable only_ (always-on subtle corner accents on `[data-editable]`), or _All components_ (always-on rainbow over every component, like the original Clay devtools). Hover and selected always paint in a single blue accent — outline + inset tint — so the "you clicked it" feedback reads consistently across every mode, on top of either the rainbow or the corner-accent ambient layer. Top-left labelled badge follows your hover and selection. Switch modes from the panel header dropdown or with the h shortcut.
+- **Refined highlight modes** — the mode owns _every_ paint on the host page (ambient + hover + selection), so each option is visually distinct:
+ - _Off_ — fully silent on the page. No ambient, no blue hover, no selection outline, no name badge. The panel still works; pick components from the Tree tab.
+ - _Selection_ (default) — pristine page; hover and click paint blue; hold ⌃ Control to flash the rainbow over every component.
+ - _Editable only_ — always-on subtle corner accents on `[data-editable]`, plus blue hover + click highlights on top.
+ - _All components_ — always-on rainbow over every component, plus blue hover + click highlights on top.
+ - Hover and selected paint in a single blue accent (outline + inset tint) so the "you clicked it" feedback reads consistently across every active mode. Top-left labelled badge follows your hover and selection. Switch modes from the panel header dropdown or with the h shortcut.
- **Passive in Clay edit mode** — on `?edit=true` pages, the panel still mounts and every read-only feature stays available (Tree, JSON, Diff, SEO, Notes, copy buttons, `View on…` pills, opening the Page/Edit/Metadata links) — but the extension stops painting outlines on the host page and stops listening for clicks/hovers there so it never competes with Clay’s own in-page editor chrome. To inspect a component, pick it from the Tree tab.
- **Auto / light / dark themes** that respond to OS theme changes live
- **Keyboard shortcuts** with a ? overlay listing every binding
-- **Options page** for theme, dock side + width, site host mappings, highlight mode + intensity, shortcut toggle, and recents history size
+- **Options page** for theme, dock side + width, site host mappings, highlight mode + intensity, shortcut toggle, recents history size, and the master enable/disable toggle
- **Floating Clay button (FAB)** on every Clay page — collapsed/idle state of the panel is a small circular button anchored to the user's preferred corner, with a live component-count badge. Click it to expand the full panel. The standard browser-extension chrome pattern (Sentry / Hotjar / Crisp / Intercom).
-- **Smart popup**: friendly "Not a Clay page" popup on non-Clay pages; on Clay pages the toolbar icon mounts/unmounts the entire extension as an escape hatch
+- **Persistent on/off toggle** — a single checkbox in the toolbar popup (and in the Options page) fully disables Clay Slip on every page until you turn it back on. When disabled, the content script never paints, never mounts the panel, and never touches the host DOM; the popup stays reachable from every tab (including Clay pages) so re-enabling is always one click away. The choice rides on `chrome.storage.sync`, so it persists across browser restarts and follows you to any browser profile signed into the same account, on both Chromium and Firefox.
+- **Smart popup**: shows a friendly "Not a Clay page" message on non-Clay pages and the persistent enable/disable toggle on every page. On Clay pages the popup is normally suppressed so the toolbar icon mounts/unmounts the in-page panel in one click — but if the extension has been disabled, the popup is force-shown again so the user always has a UI surface to flip it back on.
- **Toolbar badge** shows the count of Clay components on the current page (cleared on navigation)
- **Click-through-aware selection**: the panel selects the component you clicked but lets real interactive elements (links, buttons, inputs) keep working
- **Copy-as menu**: URI / cURL / `fetch()` snippet / CSS selector — every snippet uses the URI’s embedded host
diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts
index 64d734f..e6f328d 100644
--- a/src/background/service-worker.ts
+++ b/src/background/service-worker.ts
@@ -1,4 +1,5 @@
import browser from 'webextension-polyfill';
+import { loadPreferences, onPreferencesChanged } from '@/lib/storage';
import type { CaptureResponse, RuntimeMessage } from '@/lib/types';
// Toolbar badge color — matches the unified inspector accent
@@ -9,6 +10,56 @@ import type { CaptureResponse, RuntimeMessage } from '@/lib/types';
const BADGE_BG = '#60a5fa';
const POPUP_PATH = 'src/popup/index.html';
+/**
+ * Track the persistent enable/disable state in the service-worker
+ * memory so the per-tab popup-suppress logic (which runs on every
+ * CLAY_DETECTED + tabs.onUpdated) can consult it synchronously without
+ * an awaited storage round-trip on the hot path.
+ *
+ * Hydrated at startup from `chrome.storage.sync` and kept in sync via
+ * `onPreferencesChanged`. Defaults to `true` so the worst case during
+ * the (very brief) pre-hydration window is "popup gets suppressed on a
+ * Clay page the same way it always has been" — same UX as before this
+ * change, never worse.
+ */
+let extensionEnabled = true;
+
+void loadPreferences().then((prefs) => {
+ extensionEnabled = prefs.enabled;
+});
+
+onPreferencesChanged((prefs) => {
+ const wasEnabled = extensionEnabled;
+ extensionEnabled = prefs.enabled;
+ if (wasEnabled === prefs.enabled) return;
+ // Toggle re-asserted the popup state across all tabs. When the user
+ // disables the extension we force the popup back on for every tab
+ // (otherwise the Clay pages that were popup-suppressed would have no
+ // UI surface left to re-enable from); when they re-enable we leave
+ // the per-tab state alone — the content script's next CLAY_DETECTED
+ // will suppress it again on Clay pages, and non-Clay pages already
+ // had the popup on.
+ if (!prefs.enabled) void forcePopupOnAllTabs();
+});
+
+async function forcePopupOnAllTabs(): Promise {
+ try {
+ const tabs = await browser.tabs.query({});
+ await Promise.all(
+ tabs.map((t) =>
+ typeof t.id === 'number'
+ ? browser.action.setPopup({ tabId: t.id, popup: POPUP_PATH }).catch(() => undefined)
+ : Promise.resolve()
+ )
+ );
+ } catch {
+ // browser.tabs.query can reject in restricted contexts; nothing to
+ // do — the popup is still mounted as the global default, the only
+ // thing missing is the per-tab override clear on previously-Clay
+ // tabs. Affected users get the popup back on the next page load.
+ }
+}
+
browser.runtime.onInstalled.addListener(() => {
browser.action.setBadgeBackgroundColor({ color: BADGE_BG });
});
@@ -28,7 +79,10 @@ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
/**
* On Clay pages the popup is disabled (so this handler fires); on non-Clay
- * pages the default popup shows automatically and this never runs.
+ * pages the default popup shows automatically and this never runs. Also
+ * runs on Clay pages when `enabled: false` — in that case we always force
+ * the popup, and this handler will not be invoked at all (the popup opens
+ * instead).
*/
browser.action.onClicked.addListener((tab) => {
if (!tab.id) return;
@@ -61,12 +115,26 @@ browser.runtime.onMessage.addListener((rawMessage, sender, sendResponse) => {
}
case 'CLAY_DETECTED': {
const tabId = sender.tab?.id;
- if (typeof tabId === 'number') {
+ if (typeof tabId === 'number' && extensionEnabled) {
+ // Only suppress the popup on Clay pages when the extension is
+ // actually enabled. If it's been disabled, leaving the popup
+ // suppressed would leave the user with no UI surface to flip
+ // it back on from a Clay tab — they'd have to navigate away
+ // first.
browser.action.setPopup({ tabId, popup: '' }).catch(() => undefined);
}
sendResponse({ ok: true });
break;
}
+ case 'EXTENSION_ENABLED_CHANGED': {
+ // Mirror the new value into the in-memory cache so the very next
+ // CLAY_DETECTED on the same tab respects it without waiting for
+ // the onPreferencesChanged listener (which fires asynchronously).
+ extensionEnabled = message.enabled;
+ if (!message.enabled) void forcePopupOnAllTabs();
+ sendResponse({ ok: true });
+ break;
+ }
case 'CAPTURE_TAB': {
const windowId = sender.tab?.windowId;
if (typeof windowId !== 'number') {
diff --git a/src/content/highlighter.ts b/src/content/highlighter.ts
index 664500c..72435e7 100644
--- a/src/content/highlighter.ts
+++ b/src/content/highlighter.ts
@@ -17,11 +17,18 @@
* selected – "this is what the panel is currently inspecting"
* State is encoded with width + opacity of a single accent, not with hue.
*
- * 3. **Mode-gated ambient.** The "every component outlined all the time"
- * look turns the page into caution-tape soup on busy layouts. We default
- * to `selection` mode, where ambient outlines are off entirely. The user
- * opts up to `editable` (only `[data-editable]`) or `all` (every
- * component) when they want the bird's-eye view.
+ * 3. **Mode owns every paint, ambient AND interaction.** The "every
+ * component outlined all the time" look turns the page into
+ * caution-tape soup on busy layouts. We default to `selection`
+ * mode, where ambient outlines are off entirely. The user opts up
+ * to `editable` (only `[data-editable]`) or `all` (every component)
+ * when they want the bird's-eye view. Crucially, the mode also
+ * gates the hover/click outlines themselves — `off` mode produces
+ * a truly pristine page (no blue flash on hover, no selection
+ * outline) so a user who picks "Off" gets what the label promises.
+ * The CSS rules for hover/selected/label/match all carry a
+ * `:not([mode="off"])` join key on `` so they simply don't
+ * match in off mode and nothing paints.
*
* 4. **Z-order budget.** All highlight effects live near the top of the
* z-axis (just below the panel itself). We use 2147483645/6 — one short
@@ -239,10 +246,18 @@ function buildStyleSheet(): string {
linear-gradient(${ambient}, ${ambient}) 100% 100% / 1px ${tick} no-repeat;
}
- /* Hover and selection always render regardless of mode (otherwise
- click-to-inspect would be invisible in 'off'). Both also need
- position: relative so the label-badge ::before can anchor. */
- [${HOVER_ATTR}] {
+ /* Hover and selection render in every mode EXCEPT 'off'. The
+ :not([mode="off"]) gate on is the single switch we flip
+ to make "Off" actually off — without it the blue hover flash
+ would persist regardless of mode and the mode dropdown would
+ feel inert. (Earlier iterations made that mistake; users
+ reported "the options to edit the highlight mode don't seem
+ to do anything — I'm seeing the same blue outline on hover".)
+ Both states need position: relative so the label-badge ::before
+ can anchor; we keep that on the same gated selector so it goes
+ away too in off mode (we never touched the host element's
+ position in the first place there). */
+ html:not([${MODE_ATTR}="off"]) [${HOVER_ATTR}] {
outline: ${TOKENS.hover.width}px solid ${o(TOKENS.hover.alpha)} !important;
outline-offset: ${TOKENS.hover.offset}px !important;
position: relative;
@@ -258,8 +273,11 @@ function buildStyleSheet(): string {
the annotation dot). 8% opacity is heavy enough to clearly read
as "this is the active item" the way macOS Finder + GitHub file
browser highlight rows, light enough that text/imagery underneath
- stays fully legible. */
- [${SELECTED_ATTR}] {
+ stays fully legible.
+
+ Same :not([mode="off"]) gate as hover so "Off" mode is fully
+ silent on the page. */
+ html:not([${MODE_ATTR}="off"]) [${SELECTED_ATTR}] {
outline: ${TOKENS.selected.width}px solid ${o(TOKENS.selected.alpha)} !important;
outline-offset: ${TOKENS.selected.offset}px !important;
position: relative;
@@ -276,9 +294,12 @@ function buildStyleSheet(): string {
Sits *outside* the box when there's room above it, otherwise tucks
inside via translateY(0). The negative-then-clamp trick keeps the
label visible at the very top of the page where translateY(-100%)
- would scroll out of view. */
- [${HOVER_ATTR}][${LABEL_ATTR}]::before,
- [${SELECTED_ATTR}][${LABEL_ATTR}]::before {
+ would scroll out of view.
+
+ Gated on :not([mode="off"]) (matching the hover/select rules
+ above) so "Off" mode doesn't render the label pill either. */
+ html:not([${MODE_ATTR}="off"]) [${HOVER_ATTR}][${LABEL_ATTR}]::before,
+ html:not([${MODE_ATTR}="off"]) [${SELECTED_ATTR}][${LABEL_ATTR}]::before {
content: attr(${LABEL_ATTR});
position: absolute;
top: 0;
@@ -317,12 +338,15 @@ function buildStyleSheet(): string {
/* Find-on-page filter mode: dim non-matches, highlight matches. Match
outline uses an emerald green so it reads as a *different signal*
- than the regular accent. */
- html[${FILTER_MODE_ATTR}] [data-uri]:not([${MATCH_ATTR}]) {
+ than the regular accent. Gated on :not([mode="off"]) so an "Off"
+ user still gets the textual filter inside the panel without the
+ page-wide dim and green outlines fighting that "pristine page"
+ contract. */
+ html:not([${MODE_ATTR}="off"])[${FILTER_MODE_ATTR}] [data-uri]:not([${MATCH_ATTR}]) {
opacity: 0.25 !important;
transition: opacity 0.12s;
}
- [${MATCH_ATTR}] {
+ html:not([${MODE_ATTR}="off"]) [${MATCH_ATTR}] {
outline: ${TOKENS.match.width}px solid
rgba(34, 197, 94, calc(${TOKENS.match.alpha} * var(${OPACITY_VAR}, ${DEFAULT_OPACITY}))) !important;
outline-offset: ${TOKENS.match.offset}px !important;
diff --git a/src/content/index.ts b/src/content/index.ts
index 97ed142..90a734f 100644
--- a/src/content/index.ts
+++ b/src/content/index.ts
@@ -1,5 +1,6 @@
import browser from 'webextension-polyfill';
import { isClayDocument, isEditMode, parseShareTarget } from '@/lib/clay-uri';
+import { loadPreferences, onPreferencesChanged } from '@/lib/storage';
import type { RuntimeMessage } from '@/lib/types';
import {
applyHighlights,
@@ -16,6 +17,18 @@ function send(message: RuntimeMessage): void {
browser.runtime.sendMessage(message).catch(() => undefined);
}
+/**
+ * Tears the highlighter + panel back down. Used when the user flips the
+ * persistent `enabled` preference to `false` from another tab (or this
+ * one's options page / popup) — we want the host page to return to its
+ * pre-mount state without forcing a reload.
+ */
+function teardown(): void {
+ const components = useStore.getState().components.map((c) => c.element);
+ clearHighlights(components);
+ if (isPanelMounted()) unmountPanel();
+}
+
/**
* Read all Clay components on the page and push them into the panel store.
* **Read-only** — does not write any attribute to host elements, does not
@@ -62,9 +75,26 @@ function handleDeepLink(): void {
match.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
-function bootstrap(): void {
+async function bootstrap(): Promise {
if (!isClayDocument()) {
send({ type: 'UPDATE_BADGE', count: 0 });
+ // Even on non-Clay pages we need to start watching the prefs change
+ // listener so that flipping `enabled` from another tab doesn't
+ // require a reload. Cheap (one storage subscription) and avoids
+ // surprising behavior where a re-enable doesn't take effect until
+ // the user navigates.
+ watchEnabledPref();
+ return;
+ }
+
+ // Persistent kill-switch: if the user has disabled the extension we
+ // still announce the Clay page (so the popup shows the toggle UI on
+ // it) and listen for re-enable, but we never paint or mount anything.
+ const prefs = await loadPreferences();
+ if (!prefs.enabled) {
+ send({ type: 'CLAY_DETECTED' });
+ send({ type: 'UPDATE_BADGE', count: 0 });
+ watchEnabledPref();
return;
}
// Send CLAY_DETECTED on EVERY Clay page (including edit-mode ones), so
@@ -124,6 +154,49 @@ function bootstrap(): void {
useStore.getState().toggleCollapsed();
setTimeout(handleDeepLink, 50);
}
+
+ // Watch the persistent kill-switch so a flip from another tab tears
+ // this tab down (or brings it back up) without a reload.
+ watchEnabledPref();
+}
+
+/**
+ * Subscribe to preference changes and react to flips of the persistent
+ * `enabled` flag. Mounted exactly once per content-script lifetime via
+ * the guard below — multiple bootstrap call sites share a single
+ * subscription.
+ *
+ * When the user disables: tear down the panel + highlighter so the host
+ * page returns to its pristine state. When they re-enable: re-run the
+ * paint/mount path (or the passive-mode path on edit pages) so the
+ * panel reappears in the same tab without a reload.
+ */
+let enabledWatcherInstalled = false;
+function watchEnabledPref(): void {
+ if (enabledWatcherInstalled) return;
+ enabledWatcherInstalled = true;
+ onPreferencesChanged((prefs) => {
+ if (!isClayDocument()) return;
+ if (!prefs.enabled) {
+ teardown();
+ send({ type: 'UPDATE_BADGE', count: 0 });
+ return;
+ }
+ // Re-enable path: only re-mount if we previously tore down. The
+ // `isPanelMounted` check is the cheapest way to detect that
+ // without tracking a parallel boolean.
+ if (isPanelMounted()) return;
+ if (isEditMode()) {
+ const count = syncComponents();
+ send({ type: 'UPDATE_BADGE', count });
+ mountPanel();
+ } else {
+ const count = paintAndSync();
+ send({ type: 'UPDATE_BADGE', count });
+ installRevealKeyListener();
+ mountPanel();
+ }
+ });
}
browser.runtime.onMessage.addListener((rawMessage, _sender, sendResponse) => {
@@ -133,6 +206,10 @@ browser.runtime.onMessage.addListener((rawMessage, _sender, sendResponse) => {
sendResponse({ ok: false, reason: 'not-clay' });
return true;
}
+ // No `enabled` re-check here: when the extension is disabled, the
+ // service worker force-mounts the popup on every tab, so the
+ // toolbar icon opens the popup instead of dispatching this
+ // message. The flow is owned at the service-worker layer.
if (isPanelMounted()) {
// `clearHighlights` is a no-op in passive mode (no stylesheet → no
// attrs were ever written), so we can run it unconditionally.
@@ -154,7 +231,9 @@ browser.runtime.onMessage.addListener((rawMessage, _sender, sendResponse) => {
});
if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', bootstrap);
+ document.addEventListener('DOMContentLoaded', () => {
+ void bootstrap();
+ });
} else {
- bootstrap();
+ void bootstrap();
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index f63e99f..fa4ba7a 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -23,20 +23,37 @@ export type PanelPosition =
| 'right-side';
/**
- * Controls *which* components show an ambient outline on the page.
- * Hover and selection always render their own highlight regardless of mode
- * (otherwise click-to-inspect would be invisible).
+ * Controls every visual signal Clay Slip paints on the host page.
*
- * - `off` – no ambient outlines at all. Hover + selection still highlight.
- * - `selection` – the daily-driver default: pristine page; hover and click
- * still highlight individual components, **and holding ⌃
- * (Control) reveals the rainbow over every component**
- * for a quick spatial overview. Closest to Chrome DevTools'
- * inspector with a "show all" peek gesture layered on.
- * - `editable` – always-on corner accents on `[data-editable]` components.
+ * The mode owns BOTH the ambient layer (which/whether components carry
+ * an always-on outline) AND the interaction layer (whether hover and
+ * click paint a blue selection outline). That coupling exists so the
+ * four modes are visually distinct in practice — earlier iterations
+ * only mode-gated the ambient layer, which meant hover always flashed
+ * blue and users reported that the mode dropdown felt inert ("the
+ * options to edit the highlight mode don't seem to do anything — I'm
+ * seeing the same blue outline on hover regardless of which I select").
+ *
+ * - `off` – **fully silent on the page.** No ambient outlines,
+ * no hover highlight, no selection highlight, no
+ * component name badge. The panel still works in full
+ * — pick components from the Tree tab — but the host
+ * page is pristine. This is the "I want the extension
+ * to leave my page alone but still be available" mode.
+ * (For "stop running entirely until I say so", see
+ * {@link UserPreferences.enabled}.)
+ * - `selection` – the daily-driver default: pristine page; hover and
+ * click paint blue so click-to-inspect feels obvious,
+ * **and holding ⌃ (Control) reveals the rainbow over
+ * every component** for a quick spatial overview.
+ * Closest to Chrome DevTools' inspector with a "show
+ * all" peek gesture layered on.
+ * - `editable` – always-on corner accents on `[data-editable]`
+ * components; hover + click still paint blue on top.
* Useful for editorial / PM workflows.
- * - `all` – always-on corner accents on every Clay component. The
- * "give me the bird's-eye view of structure" mode.
+ * - `all` – always-on rainbow over every Clay component; hover
+ * + click still paint blue on top. The "give me the
+ * bird's-eye view of structure" mode.
*/
export type HighlightMode = 'off' | 'selection' | 'editable' | 'all';
@@ -55,14 +72,40 @@ export const HIGHLIGHT_MODE_LABELS: Readonly> = {
};
export const HIGHLIGHT_MODE_DESCRIPTIONS: Readonly> = {
- off: 'No outlines anywhere. The panel still works for inspection.',
+ off: 'Pristine page — no outlines on hover, click, or anywhere else. The panel still works; pick components from the Tree tab.',
selection:
- 'Hover or click to highlight a component. Hold ⌃ Control to reveal every component on the page.',
- editable: 'Subtle corner accents on every editable component.',
- all: 'Subtle corner accents on every Clay component, all the time.',
+ 'Hover or click to highlight a component in blue. Hold ⌃ Control to reveal every component on the page.',
+ editable: 'Subtle corner accents on editable components, plus blue hover + click highlights.',
+ all: 'Rainbow outlines on every Clay component, plus blue hover + click highlights.',
};
export interface UserPreferences {
+ /**
+ * Master kill-switch for the entire extension. When `false`, the content
+ * script detects this at bootstrap and **never** mounts the panel, never
+ * installs the highlighter stylesheet, never touches the host DOM.
+ * The toolbar popup is force-shown so the user can flip it back to
+ * `true` from any tab (the popup's enable/disable toggle writes to the
+ * same `chrome.storage.sync` key, which propagates to every tab via
+ * the `onPreferencesChanged` listener and lazy-mounts the panel on the
+ * next render).
+ *
+ * Distinct from {@link HighlightMode}'s `'off'`: `enabled: false` is
+ * "the extension is dormant, full stop"; `highlightMode: 'off'` is
+ * "the extension is running and the panel is available, but don't
+ * paint anything on the host page". Both options exist because users
+ * asked for both:
+ * > a way to turn it off entirely and persist the change until they
+ * > turn it back on
+ * which is this flag, vs.
+ * > i'd rather it not automatically highlight each component on hover
+ * which is `highlightMode: 'off'`.
+ *
+ * Stored in `chrome.storage.sync`, so the choice follows the user
+ * across browser profiles signed into the same account on both
+ * Chromium and Firefox.
+ */
+ readonly enabled: boolean;
readonly theme: 'auto' | 'light' | 'dark';
readonly panelPosition: PanelPosition;
readonly panelWidth: number;
@@ -104,6 +147,11 @@ export interface SiteHostMapping {
}
export const DEFAULT_PREFERENCES: UserPreferences = {
+ // Default to ON: the extension is sideloaded by users who deliberately
+ // installed it, so the first-run experience should be "it just works".
+ // The persistent kill-switch is opt-in for users who want a quiet
+ // browser by default.
+ enabled: true,
theme: 'auto',
panelPosition: 'bottom-right',
panelWidth: 380,
@@ -142,7 +190,17 @@ export type RuntimeMessage =
| { type: 'UPDATE_BADGE'; count: number; tabId?: number }
| { type: 'CLAY_DETECTED' }
| { type: 'PANEL_TOGGLE' }
- | { type: 'CAPTURE_TAB' };
+ | { type: 'CAPTURE_TAB' }
+ /**
+ * Sent by the content script when the extension flips between the
+ * `enabled: true` and `enabled: false` states (see
+ * {@link UserPreferences.enabled}). The service worker uses this to
+ * force the toolbar popup on every tab when the extension is
+ * disabled — without it the per-tab popup overrides that disable the
+ * popup on Clay pages would leave the user with no way to flip the
+ * extension back on from a Clay page.
+ */
+ | { type: 'EXTENSION_ENABLED_CHANGED'; enabled: boolean };
export interface CaptureResponse {
readonly ok: boolean;
diff --git a/src/options/Options.tsx b/src/options/Options.tsx
index e0c1bf4..c07b525 100644
--- a/src/options/Options.tsx
+++ b/src/options/Options.tsx
@@ -84,6 +84,27 @@ export function Options() {
{saved && Saved}
+
+
Extension
+
+
+
+
Appearance
diff --git a/src/popup/Popup.tsx b/src/popup/Popup.tsx
index 24db230..050496a 100644
--- a/src/popup/Popup.tsx
+++ b/src/popup/Popup.tsx
@@ -1,22 +1,117 @@
+import { useEffect, useState } from 'react';
+import browser from 'webextension-polyfill';
import clayIconUrl from '@/assets/clay-icon.png?inline';
+import { loadPreferences, onPreferencesChanged, savePreferences } from '@/lib/storage';
+import { DEFAULT_PREFERENCES, type RuntimeMessage } from '@/lib/types';
+/**
+ * Browser-action popup. Shown in two scenarios:
+ *
+ * 1. **Non-Clay tabs** — the default. The popup tells the user this
+ * page isn't a Clay page and offers the persistent enable/disable
+ * toggle so they can pre-arm or pre-disable the extension before
+ * navigating somewhere it matters.
+ *
+ * 2. **Clay tabs when `enabled: false`** — the service worker
+ * normally suppresses the popup on Clay pages (so clicking the
+ * toolbar icon toggles the in-page panel in one click). When the
+ * user has explicitly disabled the extension the service worker
+ * force-shows the popup again so they can flip it back on without
+ * navigating away from the current page.
+ *
+ * The popup deliberately doesn't know which scenario it's in — it
+ * shows the toggle either way. Users reported they wanted "a way to
+ * turn it off entirely and persist the change until they turn it back
+ * on", and the popup is the only UI surface that's always reachable
+ * regardless of whether the in-page panel is currently mounted.
+ */
export function Popup() {
+ const [enabled, setEnabled] = useState(DEFAULT_PREFERENCES.enabled);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ loadPreferences().then((prefs) => {
+ if (cancelled) return;
+ setEnabled(prefs.enabled);
+ setReady(true);
+ });
+ const cleanup = onPreferencesChanged((prefs) => {
+ if (cancelled) return;
+ setEnabled(prefs.enabled);
+ });
+ return () => {
+ cancelled = true;
+ cleanup();
+ };
+ }, []);
+
+ const onToggle = async (e: React.ChangeEvent) => {
+ const next = e.target.checked;
+ // Update local state first for instant visual feedback even if the
+ // sync write is slow (network-backed storage can take ~hundreds of
+ // ms on cold sync).
+ setEnabled(next);
+ await savePreferences({ enabled: next });
+ // Tell the service worker so it can re-assert popup behavior on
+ // every tab immediately (without waiting for its own
+ // onPreferencesChanged listener to fire). Best-effort: even if this
+ // message fails, the storage change will reach the worker via its
+ // listener shortly after.
+ browser.runtime
+ .sendMessage({
+ type: 'EXTENSION_ENABLED_CHANGED',
+ enabled: next,
+ } satisfies RuntimeMessage)
+ .catch(() => undefined);
+ };
+
+ const openOptions = (e: React.MouseEvent) => {
+ e.preventDefault();
+ browser.runtime.openOptionsPage().catch(() => undefined);
+ };
+
return (
-
No Clay components found
+
Clay Slip
- This page does not appear to be powered by Clay. Open a Clay page and click the toolbar icon
- to inspect components.
+ {enabled
+ ? 'Visit a Clay page to inspect components. The floating Clay button appears in the corner.'
+ : 'The extension is disabled — it will not run on any page until you turn it back on.'}