diff --git a/README.md b/README.md
index 503a900..8e0b46f 100644
--- a/README.md
+++ b/README.md
@@ -23,10 +23,10 @@ Clay annotates rendered HTML with `data-uri` attributes on every component, page
- **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
-- **Toggleable component outlines** (button in the header, h shortcut) with a configurable opacity
+- **Refined highlight modes** — _Off_, _Selection only_ (default, like Chrome DevTools' element inspector), _Editable only_, or _All components_. Single accent color with state-based opacity instead of the old multi-color dashed rainbow. Selected component carries a labelled top-left badge. Switch modes from the panel header dropdown or with the h shortcut.
- **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, environment hosts, highlight intensity, shortcut toggle, and recents history size
+- **Options page** for theme, dock side + width, environment hosts, highlight mode + intensity, shortcut toggle, and recents history size
- **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
- **Toolbar badge** shows the count of Clay components on the current page (cleared on navigation)
@@ -75,7 +75,7 @@ Reload the extension in `chrome://extensions` after switching between `dev` and
| Resize the panel | Drag the inner vertical / horizontal edge — or the inner-corner grabber for both at once |
| Copy URI | Press y then c (component) or p (page) |
| Open URI in new tab | Press o then c or p |
-| Toggle outlines on page | Press h or click the eye icon in the header |
+| Cycle highlight mode | Press h (off → selection → editable → all) or pick from the eye-icon dropdown |
| Cycle environment | Click the `env: …` pill at the bottom of the Inspect tab |
| Show shortcut overlay | Press ? |
| Toggle FAB ↔ panel | Press [ or click the collapse button / the FAB |
@@ -124,7 +124,7 @@ src/
│ ├── components/ # Tabs, tree, JSON viewer, diff, breadcrumb…
│ └── hooks/ # Drag, theme, shortcuts, selection
├── popup/ # "Not a Clay page" popup (active until a page sends CLAY_DETECTED)
-├── options/ # Full options page (env hosts, dock + width, intensity, recents, shortcuts)
+├── options/ # Full options page (env hosts, dock + width, highlight mode + intensity, recents, shortcuts)
└── lib/ # Pure utilities
├── clay-uri.ts # URI parsing + buildUrl/buildEditorUrl/buildShareLink + copy-as helpers
├── clipboard.ts # Modern + legacy clipboard
diff --git a/package.json b/package.json
index c7424ff..639b89a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "clay-slip",
- "version": "2.0.2",
+ "version": "2.1.0",
"description": "Modern devtools for Clay CMS pages: visualize component boundaries, inspect data, and navigate the page/layout hierarchy.",
"private": true,
"type": "module",
diff --git a/src/content/highlighter.ts b/src/content/highlighter.ts
index ec37aea..a601f76 100644
--- a/src/content/highlighter.ts
+++ b/src/content/highlighter.ts
@@ -1,34 +1,65 @@
/**
- * Manages outline highlighting for Clay components in the host page.
- * Uses a single style element scoped via data-attribute selectors so we never
- * mutate inline styles on host elements.
+ * Manages the visual outlines Clay Slip paints over host-page components.
+ *
+ * Design rules — read first
+ * -------------------------
+ * 1. **Outline only.** We never set background, border, or position on host
+ * elements except via two pseudo-elements (annotation dot + selection
+ * label badge), each gated behind a tiny `position: relative` opt-in. CSS
+ * `outline` is layout-neutral and overrides cleanly with `!important`.
+ *
+ * 2. **One accent color.** Old versions used a six-color rainbow keyed off a
+ * component's sibling order. It looked busy and carried no real meaning
+ * (siblings aren't more or less important than each other). The only
+ * thing the highlight needs to communicate is **state**:
+ * ambient – "this is a Clay component"
+ * hover – "this is what you'd select right now"
+ * 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.
+ *
+ * 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
+ * of the int max — so panel UI can still sit on top with 2147483647.
*/
+import type { HighlightMode } from '@/lib/types';
+
const STYLE_ID = 'clay-slip-highlight-styles';
+
const HIGHLIGHT_ATTR = 'data-clay-slip-color';
const SELECTED_ATTR = 'data-clay-slip-selected';
const HOVER_ATTR = 'data-clay-slip-hover';
const ANNOTATED_ATTR = 'data-clay-slip-annotated';
const MATCH_ATTR = 'data-clay-slip-match';
const FILTER_MODE_ATTR = 'data-clay-slip-filtering';
+const LABEL_ATTR = 'data-clay-slip-label';
+const MODE_ATTR = 'data-clay-slip-mode';
+
const OPACITY_VAR = '--clay-slip-outline-opacity';
const DEFAULT_OPACITY = 0.85;
-interface PaletteEntry {
- readonly r: number;
- readonly g: number;
- readonly b: number;
- readonly style: string;
- readonly width: number;
-}
+/**
+ * Single accent color for ambient/hover/selected outlines.
+ * Picked to be readable against both light and dark editorial designs and
+ * to *not* visually clash with typical brand reds, magentas, or oranges (the
+ * colors most likely to appear in a Clay site's content). RGB triple is
+ * inlined into rgba() expressions since CSS `outline` only takes a single
+ * color and we vary opacity per state.
+ */
+const ACCENT_RGB = '37, 99, 235'; // tailwind blue-600
-const PALETTE: ReadonlyArray = [
- { r: 221, g: 161, b: 161, style: 'solid', width: 2 },
- { r: 221, g: 221, b: 161, style: 'dashed', width: 3 },
- { r: 176, g: 221, b: 161, style: 'dotted', width: 4 },
- { r: 161, g: 221, b: 221, style: 'solid', width: 5 },
- { r: 161, g: 161, b: 221, style: 'dashed', width: 4 },
- { r: 221, g: 160, b: 221, style: 'double', width: 4 },
-];
+/** Element width/opacity tokens per state. Tweak these together. */
+const TOKENS = {
+ ambient: { width: 1, alpha: 0.18, offset: -1 },
+ hover: { width: 2, alpha: 0.7, offset: -2 },
+ selected: { width: 2, alpha: 1, offset: -2 },
+ match: { width: 2, alpha: 0.95, offset: -2 },
+} as const;
export function installHighlightStyles(): void {
if (document.getElementById(STYLE_ID)) return;
@@ -37,48 +68,132 @@ export function installHighlightStyles(): void {
style.textContent = buildStyleSheet();
document.head.appendChild(style);
setHighlightOpacity(DEFAULT_OPACITY);
+ // Keep the previously-active mode if one was set before mount; otherwise
+ // start in 'selection' mode (also the prefs default), which keeps the page
+ // pristine until the user opts up.
+ if (!document.documentElement.hasAttribute(MODE_ATTR)) {
+ setHighlightMode('selection');
+ }
}
function buildStyleSheet(): string {
- const colorRules = PALETTE.map(
- (p, i) =>
- `[${HIGHLIGHT_ATTR}="${i}"]{outline:${p.width}px ${p.style} rgba(${p.r},${p.g},${p.b},var(${OPACITY_VAR},${DEFAULT_OPACITY})) !important;outline-offset:-${p.width}px !important;}`
- ).join('\n');
+ // We use the var() for opacity so the user's "intensity" slider can scale
+ // every outline at once without re-emitting the stylesheet.
+ const o = (alpha: number) =>
+ `rgba(${ACCENT_RGB}, calc(${alpha} * var(${OPACITY_VAR}, ${DEFAULT_OPACITY})))`;
return `
- ${colorRules}
- [${HOVER_ATTR}]{outline:3px solid rgba(255,175,58,var(${OPACITY_VAR},${DEFAULT_OPACITY})) !important;outline-offset:-3px !important;}
- [${SELECTED_ATTR}]{outline:5px solid rgba(226,44,44,var(${OPACITY_VAR},${DEFAULT_OPACITY})) !important;outline-offset:-5px !important;}
- [${ANNOTATED_ATTR}]{position:relative;}
- [${ANNOTATED_ATTR}]::before{
- content:"";position:absolute;top:4px;right:4px;width:10px;height:10px;border-radius:50%;
- background:rgba(245,158,11,0.95);box-shadow:0 0 0 2px rgba(255,255,255,0.85);
- pointer-events:none;z-index:2147483646;
+ /* ── Ambient outlines: gated by html[data-clay-slip-mode] ────────────
+ Mode 'all' → every [data-uri] gets a 1px ghost outline.
+ Mode 'editable' → only [data-editable] does.
+ Mode 'selection' or 'off' → no rule matches; nothing painted. */
+ html[${MODE_ATTR}="all"] [${HIGHLIGHT_ATTR}],
+ html[${MODE_ATTR}="editable"] [${HIGHLIGHT_ATTR}][data-editable] {
+ outline: ${TOKENS.ambient.width}px solid ${o(TOKENS.ambient.alpha)} !important;
+ outline-offset: ${TOKENS.ambient.offset}px !important;
+ }
+
+ /* Hover and selection always render regardless of mode (otherwise
+ click-to-inspect would be invisible in 'off'). */
+ [${HOVER_ATTR}] {
+ outline: ${TOKENS.hover.width}px solid ${o(TOKENS.hover.alpha)} !important;
+ outline-offset: ${TOKENS.hover.offset}px !important;
+ }
+
+ [${SELECTED_ATTR}] {
+ outline: ${TOKENS.selected.width}px solid ${o(TOKENS.selected.alpha)} !important;
+ outline-offset: ${TOKENS.selected.offset}px !important;
+ position: relative;
+ }
+
+ /* Selection label — a small pill in the top-left of the selected box
+ reading the component name from the data-clay-slip-label attribute.
+ 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. */
+ [${SELECTED_ATTR}][${LABEL_ATTR}]::before {
+ content: attr(${LABEL_ATTR});
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform: translateY(-100%);
+ background: rgb(${ACCENT_RGB});
+ color: #ffffff;
+ font: 500 11px/1.4 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+ padding: 2px 6px;
+ border-radius: 3px 3px 3px 0;
+ white-space: nowrap;
+ max-width: 240px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ pointer-events: none;
+ z-index: 2147483646;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
+ }
+
+ /* Annotation dot — kept from the previous iteration, retuned to amber
+ so it doesn't blend with the new blue accent. */
+ [${ANNOTATED_ATTR}] { position: relative; }
+ [${ANNOTATED_ATTR}]::after {
+ content: "";
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: rgba(245, 158, 11, 0.95);
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85);
+ pointer-events: none;
+ z-index: 2147483646;
+ }
+
+ /* 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}]) {
+ opacity: 0.25 !important;
+ transition: opacity 0.12s;
+ }
+ [${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;
}
- html[${FILTER_MODE_ATTR}] [data-uri]:not([${MATCH_ATTR}]){opacity:0.25 !important;transition:opacity 0.12s;}
- [${MATCH_ATTR}]{outline:3px solid rgba(34,197,94,var(${OPACITY_VAR},${DEFAULT_OPACITY})) !important;outline-offset:-3px !important;}
`;
}
-export function applyHighlights(elements: HTMLElement[]): void {
- let lastParent: ParentNode | null = null;
- let colorIdx = 0;
- for (const el of elements) {
- if (lastParent && el.parentNode !== lastParent) {
- colorIdx = (colorIdx + 1) % PALETTE.length;
- }
- el.setAttribute(HIGHLIGHT_ATTR, String(colorIdx));
- lastParent = el.parentNode;
+/**
+ * Tag every component element so the ambient + state CSS selectors have
+ * something to target. Also stashes a human-readable label on the element
+ * so the selection badge can read it via `attr()`.
+ *
+ * The previous implementation also encoded a per-sibling color index here.
+ * That was the source of the rainbow look and we drop it — `[data-clay-slip-color]`
+ * is now just a presence flag.
+ */
+export function applyHighlights(
+ elements: readonly HTMLElement[],
+ labels?: readonly string[]
+): void {
+ for (let i = 0; i < elements.length; i++) {
+ const el = elements[i];
+ if (!el) continue;
+ el.setAttribute(HIGHLIGHT_ATTR, '');
+ const label = labels?.[i];
+ if (label) el.setAttribute(LABEL_ATTR, label);
}
}
-export function clearHighlights(elements: HTMLElement[]): void {
+export function clearHighlights(elements: readonly HTMLElement[]): void {
for (const el of elements) {
el.removeAttribute(HIGHLIGHT_ATTR);
el.removeAttribute(SELECTED_ATTR);
el.removeAttribute(HOVER_ATTR);
el.removeAttribute(ANNOTATED_ATTR);
el.removeAttribute(MATCH_ATTR);
+ el.removeAttribute(LABEL_ATTR);
}
document.documentElement.removeAttribute(FILTER_MODE_ATTR);
}
@@ -93,10 +208,29 @@ export function setHovered(prev: HTMLElement | null, next: HTMLElement | null):
if (next) next.setAttribute(HOVER_ATTR, '');
}
-export function setHighlightingEnabled(enabled: boolean): void {
- const style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
- if (!style) return;
- style.disabled = !enabled;
+/**
+ * Switch the global ambient-outline mode by flipping a single attribute on
+ * ``. The attribute is the join key the stylesheet uses for its mode
+ * gating, so this is the only function that needs to run when mode changes.
+ *
+ * `'off'` removes the attribute entirely so nothing matches the ambient
+ * rule — slightly cheaper than carrying an explicit `[mode="off"]` selector
+ * around, and means no ambient rule can ever fire by accident.
+ */
+export function setHighlightMode(mode: HighlightMode): void {
+ if (mode === 'off') {
+ document.documentElement.setAttribute(MODE_ATTR, 'off');
+ } else {
+ document.documentElement.setAttribute(MODE_ATTR, mode);
+ }
+}
+
+export function getHighlightMode(): HighlightMode {
+ const attr = document.documentElement.getAttribute(MODE_ATTR);
+ if (attr === 'off' || attr === 'selection' || attr === 'editable' || attr === 'all') {
+ return attr;
+ }
+ return 'selection';
}
export function setHighlightOpacity(opacity: number): void {
diff --git a/src/content/index.ts b/src/content/index.ts
index 2f13ae5..97e006b 100644
--- a/src/content/index.ts
+++ b/src/content/index.ts
@@ -17,7 +17,13 @@ function send(message: RuntimeMessage): void {
function paintAndSync(): number {
installHighlightStyles();
const components = readComponents();
- applyHighlights(components.map((c) => c.element));
+ applyHighlights(
+ components.map((c) => c.element),
+ // Selection badge label — passed down so the CSS pseudo-element can
+ // surface it via attr(). Falling back to component name keeps the
+ // badge useful when an instance ID isn't present.
+ components.map((c) => c.displayName || c.name)
+ );
useStore.getState().setComponents(components);
return components.length;
}
diff --git a/src/content/panel/App.tsx b/src/content/panel/App.tsx
index bd9cc17..6a32db6 100644
--- a/src/content/panel/App.tsx
+++ b/src/content/panel/App.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
import { loadPreferences, onPreferencesChanged } from '@/lib/storage';
import { listAnnotations, onAnnotationsChanged } from '@/lib/annotations';
import { loadRecents, onRecentsChanged, pushRecent } from '@/lib/recents';
-import { setAnnotatedUris, setHighlightingEnabled, setHighlightOpacity } from '../highlighter';
+import { setAnnotatedUris, setHighlightMode, setHighlightOpacity } from '../highlighter';
import { useStore } from './store';
import { useDraggable } from './hooks/useDraggable';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
@@ -32,7 +32,7 @@ export function App() {
const panelWidth = useStore((s) => s.preferences.panelWidth);
const panelHeight = useStore((s) => s.preferences.panelHeight);
const highlightOpacity = useStore((s) => s.preferences.highlightOpacity);
- const highlightEnabled = useStore((s) => s.highlightEnabled);
+ const highlightMode = useStore((s) => s.preferences.highlightMode);
const setPrefs = useStore((s) => s.setPreferences);
const setRecents = useStore((s) => s.setRecents);
const setAnnotations = useStore((s) => s.setAnnotations);
@@ -68,8 +68,8 @@ export function App() {
}, [highlightOpacity]);
useEffect(() => {
- setHighlightingEnabled(highlightEnabled);
- }, [highlightEnabled]);
+ setHighlightMode(highlightMode);
+ }, [highlightMode]);
// Sync the annotation dot indicators on the page whenever either set changes.
useEffect(() => {
diff --git a/src/content/panel/components/Header.tsx b/src/content/panel/components/Header.tsx
index 4462566..543d281 100644
--- a/src/content/panel/components/Header.tsx
+++ b/src/content/panel/components/Header.tsx
@@ -2,6 +2,7 @@ import type { Ref } from 'react';
import clayIconUrl from '@/assets/clay-icon.png?inline';
import type { RuntimeMessage } from '@/lib/types';
import { Icon } from './Icon';
+import { HighlightModeMenu } from './HighlightModeMenu';
import { useStore } from '../store';
interface HeaderProps {
@@ -12,8 +13,6 @@ export function Header({ ref }: HeaderProps) {
const page = useStore((s) => s.page);
const toggleCollapsed = useStore((s) => s.toggleCollapsed);
const toggleShortcuts = useStore((s) => s.toggleShortcuts);
- const toggleHighlights = useStore((s) => s.toggleHighlights);
- const highlightEnabled = useStore((s) => s.highlightEnabled);
const componentCount = useStore((s) => s.components.length);
const openOptions = () => {
@@ -36,15 +35,7 @@ export function Header({ ref }: HeaderProps) {
{page.isPublished ? 'Published' : 'Draft'}
)}
-
+