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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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
- **Toggleable component outlines** (button in the header, <kbd>h</kbd> 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 <kbd>h</kbd> shortcut.
- **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, 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)
Expand Down Expand Up @@ -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 <kbd>y</kbd> then <kbd>c</kbd> (component) or <kbd>p</kbd> (page) |
| Open URI in new tab | Press <kbd>o</kbd> then <kbd>c</kbd> or <kbd>p</kbd> |
| Toggle outlines on page | Press <kbd>h</kbd> or click the eye icon in the header |
| Cycle highlight mode | Press <kbd>h</kbd> (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 <kbd>?</kbd> |
| Toggle FAB ↔ panel | Press <kbd>[</kbd> or click the collapse button / the FAB |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
226 changes: 180 additions & 46 deletions src/content/highlighter.ts
Original file line number Diff line number Diff line change
@@ -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<PaletteEntry> = [
{ 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;
Expand All @@ -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);
}
Expand All @@ -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
* `<html>`. 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 {
Expand Down
8 changes: 7 additions & 1 deletion src/content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 4 additions & 4 deletions src/content/panel/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(() => {
Expand Down
13 changes: 2 additions & 11 deletions src/content/panel/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = () => {
Expand All @@ -36,15 +35,7 @@ export function Header({ ref }: HeaderProps) {
{page.isPublished ? 'Published' : 'Draft'}
</span>
)}
<button
className={`cs-icon-btn ${highlightEnabled ? '' : 'cs-icon-btn-off'}`}
onClick={toggleHighlights}
title={highlightEnabled ? 'Hide outlines (h)' : 'Show outlines (h)'}
aria-label={highlightEnabled ? 'Hide outlines' : 'Show outlines'}
aria-pressed={!highlightEnabled}
>
<Icon name={highlightEnabled ? 'eye' : 'eyeOff'} />
</button>
<HighlightModeMenu />
<button
className="cs-icon-btn"
onClick={toggleShortcuts}
Expand Down
Loading
Loading