Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<h1>`, 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 <kbd>⌃</kbd> 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 <kbd>h</kbd> 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 <kbd>⌃</kbd> 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 <kbd>h</kbd> 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&rsquo;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 <kbd>?</kbd> 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&rsquo;s embedded host
Expand Down
72 changes: 70 additions & 2 deletions src/background/service-worker.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<void> {
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 });
});
Expand All @@ -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;
Expand Down Expand Up @@ -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') {
Expand Down
58 changes: 41 additions & 17 deletions src/content/highlighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>` 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
Expand Down Expand Up @@ -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 <html> 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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
85 changes: 82 additions & 3 deletions src/content/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -62,9 +75,26 @@ function handleDeepLink(): void {
match.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}

function bootstrap(): void {
async function bootstrap(): Promise<void> {
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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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.
Expand All @@ -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();
}
Loading
Loading