From 231d8c5657d654b4f4007b263e67e0158bdde973 Mon Sep 17 00:00:00 2001 From: jordan Date: Mon, 18 May 2026 21:58:45 +0000 Subject: [PATCH 1/2] Mode-gate hover/select painting so each highlight mode is visually distinct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier iterations only mode-gated the ambient layer. Hover and click always flashed blue regardless of the chosen mode, which surfaced as two user reports: > i'd rather it not automatically highlight each component on hover, > i would want to have to toggle that mode before it is enabled > 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 Now the mode owns every paint on the host page. Off mode produces a truly pristine page (no blue flash, no selection outline, no name badge, no find-on-page dim/highlight) while still leaving the panel fully usable — pick components from the Tree tab. The other three modes paint blue hover/select on top of their ambient layer, so each mode is visibly different from the others. Implementation is a single :not([data-clay-slip-mode='off']) gate on the hover / selected / label / match / filtering rules, with a CSS contract test that locks the gate in place so a future edit can't silently regress. Co-authored-by: Jordan Paulino --- README.md | 7 +++- src/content/highlighter.ts | 58 ++++++++++++++++++++++--------- src/lib/types.ts | 49 +++++++++++++++++--------- tests/content/highlighter.test.ts | 27 +++++++++++++- 4 files changed, 106 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 8510a8d..e341a1a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,12 @@ 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 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/lib/types.ts b/src/lib/types.ts index f63e99f..00a848e 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,11 +72,11 @@ 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 { diff --git a/tests/content/highlighter.test.ts b/tests/content/highlighter.test.ts index b014042..297873b 100644 --- a/tests/content/highlighter.test.ts +++ b/tests/content/highlighter.test.ts @@ -377,7 +377,7 @@ describe('hover + selected stylesheet contract', () => { // selector list. Without the hover half, hovering a non-selected // component shows no name — regression we explicitly want to prevent. const labelRule = css.match( - /\[data-clay-slip-(?:hover|selected)\]\[data-clay-slip-label\]::before[^{]*,\s*\[data-clay-slip-(?:hover|selected)\]\[data-clay-slip-label\]::before/ + /\[data-clay-slip-(?:hover|selected)\]\[data-clay-slip-label\]::before[^{]*,\s*[^{]*\[data-clay-slip-(?:hover|selected)\]\[data-clay-slip-label\]::before/ ); expect(labelRule).not.toBeNull(); }); @@ -411,6 +411,31 @@ describe('hover + selected stylesheet contract', () => { const hoverRule = css.match(/\[data-clay-slip-hover\]\s*\{[^}]+\}/); expect(hoverRule?.[0]).toContain('position: relative'); }); + + it("gates hover/selected/label on :not([mode='off']) so 'Off' produces a pristine page", () => { + // This is the contract that makes the 'Off' mode actually off. + // Earlier iterations only gated the ambient layer, which left + // hover and click still flashing blue — and users (correctly) + // reported the mode dropdown felt inert. Lock the gate in so a + // future edit can't silently regress. + const css = getStylesheetText(); + const rules = [ + // Hover rule (the one that paints the blue outline). + css.match(/[^\n}]*\[data-clay-slip-hover\]\s*\{/), + // Selected rule (paints outline + inset fill). + css.match(/[^\n}]*\[data-clay-slip-selected\]\s*\{/), + // Label badge rule (the component-name pill on hover/selected). + css.match(/[^\n}]*\[data-clay-slip-label\]::before/), + // Find-on-page match rule (the green outline on Tree-tab matches). + css.match(/[^\n}]*\[data-clay-slip-match\]\s*\{/), + // Find-on-page dim rule. + css.match(/[^\n}]*\[data-clay-slip-filtering\][^{]*\{/), + ]; + for (const rule of rules) { + expect(rule).not.toBeNull(); + expect(rule?.[0]).toContain(':not([data-clay-slip-mode="off"])'); + } + }); }); describe('setReveal / getReveal', () => { From 55c345b8a1df67bb7020f5b5ef89cd2d42a94f4b Mon Sep 17 00:00:00 2001 From: jordan Date: Mon, 18 May 2026 22:05:58 +0000 Subject: [PATCH 2/2] Add persistent enable/disable kill-switch for the whole extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a top-level 'enabled' preference (default true) that fully turns Clay Slip off on every page until the user turns it back on. Addresses the request: > a way to turn it off entirely and persist the change until they > turn it back on. Make sure this applies for both chromium and > firefox. Behavior when disabled: - Content script bootstrap detects the false value and skips installing the highlighter stylesheet, painting components, and mounting the panel. No data-clay-slip-* attributes are ever written to host elements. - The service worker keeps the toolbar popup mounted on every tab (including Clay tabs that normally have it suppressed), so the user always has a UI surface to flip the extension back on from whatever tab they're looking at. - Storage.sync propagates the flip to every tab; each tab tears down or brings up its panel without a reload. UI: - New checkbox at the top of the toolbar popup (now always visible on Clay pages when disabled, in addition to the existing non-Clay-page behavior). When the toggle flips, the popup also notifies the service worker so popup-state changes are immediate without waiting for the storage listener. - New 'Extension' section at the top of the Options page with the same checkbox. Tests: - tests/lib/storage.test.ts: enabled defaults true, persists, merges with other prefs, round-trips false<->true. - tests/background/service-worker.test.ts (new): CLAY_DETECTED popup suppression is gated on the enabled cache; EXTENSION_ENABLED_CHANGED force-mounts the popup on every tab when disabled and not when re-enabled; cache flips on every state change; storage hydration on cold start honors the persisted value; storage.onChanged propagation from another tab is wired. - tests/content/index.test.ts (new): bootstrap on a non-Clay page still subscribes to prefs; bootstrap on a Clay page with disabled paints nothing, mounts nothing, but announces CLAY_DETECTED; bootstrap on a Clay page with enabled mounts everything; live flips through storage.onChanged tear down / bring up without a reload. The whole code path is the same on Chromium and Firefox — the chrome.storage.sync API, the action.setPopup API, and the webextension-polyfill all behave identically across both targets, and the Firefox postbuild only rewrites the background entry shape, not the worker logic. Verified by running both production builds. Co-authored-by: Jordan Paulino --- README.md | 5 +- src/background/service-worker.ts | 72 +++++- src/content/index.ts | 85 ++++++- src/lib/types.ts | 43 +++- src/options/Options.tsx | 21 ++ src/popup/Popup.tsx | 117 ++++++++- src/popup/popup.css | 43 ++++ tests/background/service-worker.test.ts | 312 ++++++++++++++++++++++++ tests/content/index.test.ts | 262 ++++++++++++++++++++ tests/lib/storage.test.ts | 53 ++++ 10 files changed, 994 insertions(+), 19 deletions(-) create mode 100644 tests/background/service-worker.test.ts create mode 100644 tests/content/index.test.ts diff --git a/README.md b/README.md index e341a1a..15fcdab 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,10 @@ The same source builds for both browser families: - **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/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 00a848e..fa4ba7a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -80,6 +80,32 @@ export const HIGHLIGHT_MODE_DESCRIPTIONS: Readonly }; 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; @@ -121,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, @@ -159,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.'}

- - Learn about Clay → - + + + +
); } diff --git a/src/popup/popup.css b/src/popup/popup.css index 6ced216..e166838 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -44,6 +44,49 @@ body { opacity: 0.75; } +.popup-toggle { + display: flex; + align-items: flex-start; + gap: 10px; + text-align: left; + padding: 10px 12px; + margin: 0 0 12px; + background: rgba(96, 165, 250, 0.08); + border: 1px solid rgba(96, 165, 250, 0.25); + border-radius: 6px; + cursor: pointer; +} + +.popup-toggle input[type='checkbox'] { + margin: 2px 0 0; + flex: 0 0 auto; + cursor: pointer; +} + +.popup-toggle input[type='checkbox']:disabled { + cursor: progress; +} + +.popup-toggle-text { + display: flex; + flex-direction: column; + font-size: 12px; + font-weight: 600; +} + +.popup-toggle-hint { + font-weight: 400; + opacity: 0.7; + font-size: 11px; + margin-top: 2px; +} + +.popup-links { + display: flex; + justify-content: space-between; + gap: 12px; +} + .popup-link { display: inline-block; font-size: 12px; diff --git a/tests/background/service-worker.test.ts b/tests/background/service-worker.test.ts new file mode 100644 index 0000000..7a29986 --- /dev/null +++ b/tests/background/service-worker.test.ts @@ -0,0 +1,312 @@ +/** + * Behavioral tests for the service-worker / background script. + * + * The worker is the layer that decides whether the toolbar popup should + * be shown or suppressed on each tab, which is the *only* mechanism we + * have to give users a UI surface to flip the persistent `enabled` + * preference back on from a Clay tab once they've turned it off. + * Getting this contract right is the whole reason the persistent + * kill-switch is a useful feature instead of a one-way trap. + * + * The test isolates the worker's listener registration so we can drive + * individual messages through it and verify the resulting calls to + * `browser.action.setPopup`. The webextension-polyfill mock in + * `tests/setup.ts` forwards `import browser from 'webextension-polyfill'` + * to whatever this file assigns to `globalThis.chrome`, so the worker + * source can be imported unmodified. + * + * Same source ships to Chromium and Firefox (manifest postbuild only + * touches the background entry shape, not the worker logic), so these + * assertions cover both browser families. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_PREFERENCES } from '@/lib/types'; + +interface OnMessageListener { + ( + message: unknown, + sender: { tab?: { id?: number; windowId?: number } }, + sendResponse: (response: unknown) => void + ): boolean | void; +} + +interface StorageChangeListener { + (changes: Record, area: string): void; +} + +interface MockChrome { + action: { + setBadgeBackgroundColor: ReturnType; + setBadgeText: ReturnType; + setPopup: ReturnType; + onClicked: { addListener: ReturnType }; + }; + runtime: { + onInstalled: { addListener: ReturnType }; + onMessage: { + addListener: ReturnType; + listeners: OnMessageListener[]; + }; + openOptionsPage: ReturnType; + sendMessage: ReturnType; + }; + tabs: { + onUpdated: { addListener: ReturnType }; + create: ReturnType; + captureVisibleTab: ReturnType; + sendMessage: ReturnType; + query: ReturnType; + }; + storage: { + sync: { + data: Record; + get: ReturnType; + set: ReturnType; + }; + onChanged: { + addListener: ReturnType; + removeListener: ReturnType; + listeners: StorageChangeListener[]; + }; + }; +} + +function makeMockChrome(initialPrefs: Record = {}): MockChrome { + const messageListeners: OnMessageListener[] = []; + const storageListeners: StorageChangeListener[] = []; + const data: Record = { preferences: initialPrefs }; + const mock: MockChrome = { + action: { + setBadgeBackgroundColor: vi.fn(async () => undefined), + setBadgeText: vi.fn(async () => undefined), + setPopup: vi.fn(async () => undefined), + onClicked: { addListener: vi.fn() }, + }, + runtime: { + onInstalled: { addListener: vi.fn() }, + onMessage: { + addListener: vi.fn((l: OnMessageListener) => messageListeners.push(l)), + listeners: messageListeners, + }, + openOptionsPage: vi.fn(async () => undefined), + sendMessage: vi.fn(async () => undefined), + }, + tabs: { + onUpdated: { addListener: vi.fn() }, + create: vi.fn(async () => undefined), + captureVisibleTab: vi.fn(async () => 'data:image/png;base64,xxx'), + sendMessage: vi.fn(async () => undefined), + query: vi.fn(async () => [{ id: 10 }, { id: 11 }, { id: 12 }]), + }, + storage: { + sync: { + data, + get: vi.fn(async (key: string) => ({ [key]: data[key] })), + set: vi.fn(async (entries: Record) => { + Object.assign(data, entries); + }), + }, + onChanged: { + addListener: vi.fn((l: StorageChangeListener) => storageListeners.push(l)), + removeListener: vi.fn(), + listeners: storageListeners, + }, + }, + }; + return mock; +} + +function tick(): Promise { + // Lets pending microtasks (the async preferences load on import, + // followed by the setExtensionEnabled update) drain before assertions. + return new Promise((r) => setTimeout(r, 0)); +} + +async function importWorker(): Promise { + // Each test resets the module registry so the worker re-runs its + // top-level side effects (registering listeners, hydrating the + // enabled-cache from storage) against the fresh mock. + vi.resetModules(); + await import('@/background/service-worker'); + await tick(); +} + +beforeEach(() => { + (globalThis as { chrome?: MockChrome }).chrome = makeMockChrome(); +}); + +afterEach(() => { + delete (globalThis as { chrome?: MockChrome }).chrome; +}); + +function getMock(): MockChrome { + return (globalThis as { chrome?: MockChrome }).chrome as MockChrome; +} + +function dispatchMessage( + message: unknown, + sender: { tab?: { id?: number; windowId?: number } } = {} +): unknown { + let response: unknown; + for (const listener of getMock().runtime.onMessage.listeners) { + listener(message, sender, (r) => { + response = r; + }); + } + return response; +} + +describe('CLAY_DETECTED popup gating (per-tab popup suppress)', () => { + it('suppresses the popup on Clay tabs while the extension is enabled', async () => { + // Default prefs → `enabled: true`. The historical behavior (single + // click on the toolbar = toggle the in-page panel) depends on the + // popup being cleared for the Clay tab. + await importWorker(); + dispatchMessage({ type: 'CLAY_DETECTED' }, { tab: { id: 42 } }); + expect(getMock().action.setPopup).toHaveBeenCalledWith({ tabId: 42, popup: '' }); + }); + + it('does NOT suppress the popup on Clay tabs while the extension is disabled', async () => { + // The whole point of the persistent kill-switch is that users can + // flip the extension back on from any tab. If we suppressed the + // popup on Clay tabs while disabled, a user who turned the + // extension off and then navigated to a Clay page would have no + // UI surface left to re-enable from. Lock the gate in. + getMock().storage.sync.data['preferences'] = { ...DEFAULT_PREFERENCES, enabled: false }; + await importWorker(); + dispatchMessage({ type: 'CLAY_DETECTED' }, { tab: { id: 42 } }); + // CLAY_DETECTED is the *only* path that clears the popup; if the + // worker didn't call setPopup with popup:'' the per-tab override + // is left as whatever the global default was (the popup HTML), + // which is exactly the behavior we want. + const clearedCalls = getMock().action.setPopup.mock.calls.filter( + (call) => call[0]?.tabId === 42 && call[0]?.popup === '' + ); + expect(clearedCalls).toHaveLength(0); + }); + + it('ignores CLAY_DETECTED with no tab id (defensive, no crash on unusual senders)', async () => { + await importWorker(); + // setPopup may have been called by other listeners during import; + // we only care that the new dispatch didn't add a no-tabId call. + const before = getMock().action.setPopup.mock.calls.length; + expect(() => dispatchMessage({ type: 'CLAY_DETECTED' }, {})).not.toThrow(); + expect(getMock().action.setPopup.mock.calls.length).toBe(before); + }); +}); + +describe('EXTENSION_ENABLED_CHANGED (popup force-on)', () => { + it('forces the popup back on every tab when the user disables the extension', async () => { + // The persistent kill-switch needs a synchronous way to recover + // the UI surface on every existing Clay tab — otherwise users + // would have to navigate before they could re-enable. This is + // the contract the popup-force takes care of. + await importWorker(); + dispatchMessage({ type: 'EXTENSION_ENABLED_CHANGED', enabled: false }); + await tick(); + await tick(); + const popupCalls = getMock().action.setPopup.mock.calls.filter( + (call) => call[0]?.popup === 'src/popup/index.html' + ); + // 3 tabs in the mocked tabs.query response → 3 force-on calls. + expect(popupCalls.length).toBeGreaterThanOrEqual(3); + expect(popupCalls.map((c) => c[0].tabId).sort()).toEqual([10, 11, 12]); + }); + + it('does NOT force the popup on every tab when the user re-enables', async () => { + // Re-enabling is fine to leave the per-tab popup state alone — + // non-Clay tabs already show the popup (default), and Clay tabs + // will re-suppress it on the next CLAY_DETECTED. Forcing the + // popup on across all tabs on every re-enable would actually + // *break* the one-click panel toggle on Clay tabs that aren't + // currently focused (the popup override would shadow the + // suppress until the next page load). + await importWorker(); + // Disable first so the in-memory cache flips off. + dispatchMessage({ type: 'EXTENSION_ENABLED_CHANGED', enabled: false }); + await tick(); + const setPopupBefore = getMock().action.setPopup.mock.calls.length; + dispatchMessage({ type: 'EXTENSION_ENABLED_CHANGED', enabled: true }); + await tick(); + await tick(); + // No additional bulk popup calls — re-enable doesn't force a sweep. + expect(getMock().action.setPopup.mock.calls.length).toBe(setPopupBefore); + }); + + it('updates the in-memory cache synchronously so the very next CLAY_DETECTED respects it', async () => { + // Disable -> immediately CLAY_DETECTED on a tab. The worker must + // NOT clear the popup for that tab (because the user just turned + // the extension off). If the cache update was deferred to the + // async onPreferencesChanged listener we'd race here. + await importWorker(); + dispatchMessage({ type: 'EXTENSION_ENABLED_CHANGED', enabled: false }); + dispatchMessage({ type: 'CLAY_DETECTED' }, { tab: { id: 99 } }); + const tab99Suppressions = getMock().action.setPopup.mock.calls.filter( + (call) => call[0]?.tabId === 99 && call[0]?.popup === '' + ); + expect(tab99Suppressions).toHaveLength(0); + }); + + it('round-trips off -> on -> off -> on with the cache tracking each flip', async () => { + // Catches a sticky-state bug where the cache only flips one way. + await importWorker(); + dispatchMessage({ type: 'EXTENSION_ENABLED_CHANGED', enabled: false }); + dispatchMessage({ type: 'CLAY_DETECTED' }, { tab: { id: 1 } }); + dispatchMessage({ type: 'EXTENSION_ENABLED_CHANGED', enabled: true }); + dispatchMessage({ type: 'CLAY_DETECTED' }, { tab: { id: 2 } }); + dispatchMessage({ type: 'EXTENSION_ENABLED_CHANGED', enabled: false }); + dispatchMessage({ type: 'CLAY_DETECTED' }, { tab: { id: 3 } }); + dispatchMessage({ type: 'EXTENSION_ENABLED_CHANGED', enabled: true }); + dispatchMessage({ type: 'CLAY_DETECTED' }, { tab: { id: 4 } }); + const suppressed = (tabId: number) => + getMock().action.setPopup.mock.calls.some((c) => c[0]?.tabId === tabId && c[0]?.popup === ''); + expect(suppressed(1)).toBe(false); // disabled + expect(suppressed(2)).toBe(true); // enabled + expect(suppressed(3)).toBe(false); // disabled + expect(suppressed(4)).toBe(true); // enabled + }); +}); + +describe('storage hydration', () => { + it('starts with `enabled` honored from chrome.storage.sync on cold start', async () => { + // Simulates "user disabled the extension yesterday, restarts the + // browser today" — the worker boots, hydrates from storage, and + // the very first CLAY_DETECTED on a Clay tab must not suppress + // the popup. + getMock().storage.sync.data['preferences'] = { ...DEFAULT_PREFERENCES, enabled: false }; + await importWorker(); + dispatchMessage({ type: 'CLAY_DETECTED' }, { tab: { id: 7 } }); + const tab7Suppressions = getMock().action.setPopup.mock.calls.filter( + (call) => call[0]?.tabId === 7 && call[0]?.popup === '' + ); + expect(tab7Suppressions).toHaveLength(0); + }); + + it('reacts to storage.onChanged so a flip from another tab propagates', async () => { + // Cross-tab propagation is one of the reasons we picked + // storage.sync in the first place. Verify the worker's + // onPreferencesChanged listener is wired and consulting the new + // value. + await importWorker(); + expect(getMock().storage.onChanged.listeners.length).toBeGreaterThan(0); + + // Simulate a write from another tab. + for (const listener of getMock().storage.onChanged.listeners) { + listener( + { + preferences: { newValue: { ...DEFAULT_PREFERENCES, enabled: false } }, + }, + 'sync' + ); + } + await tick(); + await tick(); + + // Now CLAY_DETECTED on this tab should not suppress. + dispatchMessage({ type: 'CLAY_DETECTED' }, { tab: { id: 33 } }); + const tab33Suppressions = getMock().action.setPopup.mock.calls.filter( + (call) => call[0]?.tabId === 33 && call[0]?.popup === '' + ); + expect(tab33Suppressions).toHaveLength(0); + }); +}); diff --git a/tests/content/index.test.ts b/tests/content/index.test.ts new file mode 100644 index 0000000..2541b22 --- /dev/null +++ b/tests/content/index.test.ts @@ -0,0 +1,262 @@ +/** + * Behavioral tests for the content-script bootstrap. + * + * The bootstrap is the layer that consults the persistent `enabled` + * preference and decides whether to install the highlighter stylesheet, + * mount the panel, and announce the Clay page to the background worker. + * The contract is: + * + * - On a non-Clay page: never paint or mount, but DO subscribe to + * preference changes so a future `enabled: true` flip elsewhere + * doesn't require a reload. + * - On a Clay page with `enabled: false`: announce CLAY_DETECTED so + * the popup shows the toggle UI, but skip every paint/mount. + * - On a Clay page with `enabled: true`: full bootstrap. + * - When the preference flips after bootstrap: tear down or bring up + * the panel + highlighter live without a reload. + * + * Tests below run the bootstrap repeatedly (via vi.resetModules) to + * exercise each branch, then drive the storage.onChanged listener to + * verify the live tear-down / bring-up behavior. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_PREFERENCES } from '@/lib/types'; + +interface StorageChangeListener { + (changes: Record, area: string): void; +} + +interface OnMessageListener { + ( + message: unknown, + sender: { tab?: { id?: number } }, + sendResponse: (response: unknown) => void + ): boolean | void; +} + +interface MockChrome { + runtime: { + onInstalled: { addListener: ReturnType }; + onMessage: { + addListener: ReturnType; + listeners: OnMessageListener[]; + }; + sendMessage: ReturnType & { messages: unknown[] }; + openOptionsPage: ReturnType; + }; + storage: { + sync: { + data: Record; + get: ReturnType; + set: ReturnType; + }; + onChanged: { + addListener: ReturnType; + removeListener: ReturnType; + listeners: StorageChangeListener[]; + }; + local: { + data: Record; + get: ReturnType; + set: ReturnType; + }; + }; + action: { setBadgeText: ReturnType }; +} + +function makeMockChrome(initialPrefs: Record = {}): MockChrome { + const data: Record = { preferences: initialPrefs }; + const local: Record = {}; + const messageListeners: OnMessageListener[] = []; + const storageListeners: StorageChangeListener[] = []; + const sendMessageMessages: unknown[] = []; + const sendMessage = Object.assign( + vi.fn(async (msg: unknown) => { + sendMessageMessages.push(msg); + return undefined; + }), + { messages: sendMessageMessages } + ); + return { + runtime: { + onInstalled: { addListener: vi.fn() }, + onMessage: { + addListener: vi.fn((l: OnMessageListener) => messageListeners.push(l)), + listeners: messageListeners, + }, + sendMessage, + openOptionsPage: vi.fn(async () => undefined), + }, + storage: { + sync: { + data, + get: vi.fn(async (key: string) => ({ [key]: data[key] })), + set: vi.fn(async (entries: Record) => { + Object.assign(data, entries); + }), + }, + onChanged: { + addListener: vi.fn((l: StorageChangeListener) => storageListeners.push(l)), + removeListener: vi.fn(), + listeners: storageListeners, + }, + local: { + data: local, + get: vi.fn(async () => local), + set: vi.fn(async (entries: Record) => { + Object.assign(local, entries); + }), + }, + }, + action: { setBadgeText: vi.fn(async () => undefined) }, + }; +} + +function getMock(): MockChrome { + return (globalThis as { chrome?: MockChrome }).chrome as MockChrome; +} + +function makeClayDocument(): void { + document.documentElement.setAttribute('data-uri', 'thecut.com/_pages/abc@published'); + const comp = document.createElement('div'); + comp.setAttribute('data-uri', 'thecut.com/_components/header/instances/hero@published'); + document.body.appendChild(comp); +} + +async function tick(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +async function importBootstrap(): Promise { + vi.resetModules(); + await import('@/content/index'); + // Bootstrap is async (awaits loadPreferences); two ticks lets the + // microtask queue drain. + await tick(); + await tick(); +} + +beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + document.documentElement.removeAttribute('data-uri'); + document.documentElement.removeAttribute('data-clay-slip-mode'); + document.getElementById('clay-slip-shadow-host')?.remove(); + (globalThis as { chrome?: MockChrome }).chrome = makeMockChrome(); +}); + +afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + document.documentElement.removeAttribute('data-uri'); + document.documentElement.removeAttribute('data-clay-slip-mode'); + document.getElementById('clay-slip-shadow-host')?.remove(); + delete (globalThis as { chrome?: MockChrome }).chrome; +}); + +describe('bootstrap on a non-Clay page', () => { + it('does not paint, does not mount the panel, but still subscribes to prefs changes', async () => { + await importBootstrap(); + expect(document.getElementById('clay-slip-highlight-styles')).toBeNull(); + expect(document.getElementById('clay-slip-shadow-host')).toBeNull(); + // Subscribed so a re-enable / disable elsewhere is reactive. + expect(getMock().storage.onChanged.listeners.length).toBeGreaterThan(0); + }); +}); + +describe('bootstrap on a Clay page with enabled: false (persistent kill-switch)', () => { + beforeEach(() => { + makeClayDocument(); + getMock().storage.sync.data['preferences'] = { ...DEFAULT_PREFERENCES, enabled: false }; + }); + + it('does not install the highlighter stylesheet', async () => { + await importBootstrap(); + expect(document.getElementById('clay-slip-highlight-styles')).toBeNull(); + }); + + it('does not mount the panel host', async () => { + await importBootstrap(); + expect(document.getElementById('clay-slip-shadow-host')).toBeNull(); + }); + + it('does not write any data-clay-slip-* attribute to host components', async () => { + await importBootstrap(); + const comp = document.querySelector('[data-uri*="header"]') as HTMLElement; + expect(comp.hasAttributeNS(null, 'data-clay-slip-color')).toBe(false); + expect(comp.hasAttributeNS(null, 'data-clay-slip-color-idx')).toBe(false); + }); + + it('still announces CLAY_DETECTED so the toolbar popup gets the toggle UI', async () => { + await importBootstrap(); + const messages = getMock().runtime.sendMessage.messages; + expect(messages).toContainEqual(expect.objectContaining({ type: 'CLAY_DETECTED' })); + }); + + it('reports a badge count of 0 (extension is off, no components surfaced)', async () => { + await importBootstrap(); + const badge = getMock().runtime.sendMessage.messages.find( + (m): m is { type: 'UPDATE_BADGE'; count: number } => + !!m && typeof m === 'object' && (m as { type?: string }).type === 'UPDATE_BADGE' + ); + expect(badge?.count).toBe(0); + }); +}); + +describe('bootstrap on a Clay page with enabled: true (default)', () => { + beforeEach(() => { + makeClayDocument(); + }); + + it('installs the highlighter stylesheet', async () => { + await importBootstrap(); + expect(document.getElementById('clay-slip-highlight-styles')).not.toBeNull(); + }); + + it('mounts the panel host', async () => { + await importBootstrap(); + expect(document.getElementById('clay-slip-shadow-host')).not.toBeNull(); + }); +}); + +describe('live re-enable / disable via storage.onChanged', () => { + // The single most valuable contract here is that flipping `enabled` + // from another tab tears down (or brings up) the panel + highlighter + // on every other tab without a reload. Without it the kill-switch + // would be lopsided: turning OFF would require a reload to take + // effect, which is exactly what the persistent flag is supposed to + // avoid. + + beforeEach(() => { + makeClayDocument(); + }); + + it('tears down the panel + highlighter when enabled flips to false', async () => { + // Boot enabled; verify it's mounted; flip to false; verify + // teardown. + await importBootstrap(); + expect(document.getElementById('clay-slip-highlight-styles')).not.toBeNull(); + expect(document.getElementById('clay-slip-shadow-host')).not.toBeNull(); + + for (const listener of getMock().storage.onChanged.listeners) { + listener({ preferences: { newValue: { ...DEFAULT_PREFERENCES, enabled: false } } }, 'sync'); + } + await tick(); + expect(document.getElementById('clay-slip-shadow-host')).toBeNull(); + }); + + it('brings the panel back up when enabled flips to true', async () => { + getMock().storage.sync.data['preferences'] = { ...DEFAULT_PREFERENCES, enabled: false }; + await importBootstrap(); + // Confirms the cold-start disabled state we're starting from. + expect(document.getElementById('clay-slip-shadow-host')).toBeNull(); + + for (const listener of getMock().storage.onChanged.listeners) { + listener({ preferences: { newValue: { ...DEFAULT_PREFERENCES, enabled: true } } }, 'sync'); + } + await tick(); + await tick(); + expect(document.getElementById('clay-slip-shadow-host')).not.toBeNull(); + expect(document.getElementById('clay-slip-highlight-styles')).not.toBeNull(); + }); +}); diff --git a/tests/lib/storage.test.ts b/tests/lib/storage.test.ts index e28f851..96de754 100644 --- a/tests/lib/storage.test.ts +++ b/tests/lib/storage.test.ts @@ -66,3 +66,56 @@ describe('savePreferences', () => { }); }); }); + +describe('persistent enable/disable flag', () => { + // The `enabled` preference is the master kill-switch users asked for + // ("a way to turn it off entirely and persist the change until they + // turn it back on"). It rides on the same chrome.storage.sync key as + // every other preference, which is what gives us: + // - persistence across browser restarts (storage.sync is durable) + // - cross-tab propagation (storage.onChanged broadcasts) + // - cross-device sync for the same browser profile on both + // Chromium and Firefox families (storage.sync is in the + // webextension-polyfill, identical surface on both) + // The tests below lock those three properties in via behavior the + // rest of the codebase already depends on. + + it('defaults `enabled` to true so first-run installs are immediately useful', async () => { + const prefs = await loadPreferences(); + expect(prefs.enabled).toBe(true); + }); + + it('round-trips a `false` value through storage so the disabled state survives reloads', async () => { + await savePreferences({ enabled: false }); + // Re-load from a fresh `loadPreferences` call to simulate the next + // page load reading the persisted value. This is exactly the path + // the content-script bootstrap takes, so a regression here would + // mean "users disable the extension, navigate, and it's back on". + const prefs = await loadPreferences(); + expect(prefs.enabled).toBe(false); + }); + + it('preserves unrelated preferences when only `enabled` is set', async () => { + // Catches a class of bugs where a partial update accidentally + // wipes the rest of the prefs (e.g. by overwriting the whole + // object instead of merging). The popup writes only `{ enabled }`, + // so this is the exact call shape we ship. + getMockChrome().storage.sync.data['preferences'] = { + theme: 'dark', + panelPosition: 'left-side', + }; + await savePreferences({ enabled: false }); + expect(getMockChrome().storage.sync.data['preferences']).toMatchObject({ + enabled: false, + theme: 'dark', + panelPosition: 'left-side', + }); + }); + + it('re-enabling clears the false value and reverts to the default load behavior', async () => { + await savePreferences({ enabled: false }); + await savePreferences({ enabled: true }); + const prefs = await loadPreferences(); + expect(prefs.enabled).toBe(true); + }); +});