π v2.0: full rewrite β MV3, TypeScript + React, Shadow DOM, FAB, cross-env tooling#2
Merged
Merged
Conversation
β¦ts, CI
Replaces the original Manifest V2 vanilla-JS extension with a modern,
fully typed, fully tested Chrome extension covering every item in the
upgrade plan.
Foundation
- Manifest V3 with a service worker (replaces the persistent MV2
background page and `browserAction` API).
- TypeScript (strict, noUncheckedIndexedAccess, noUnusedLocals).
- Vite + @crxjs/vite-plugin for HMR-friendly builds.
- ESLint 9 (flat config) + Prettier + EditorConfig + .nvmrc.
- GitHub Actions CI: typecheck, lint, format check, tests, build,
artifact upload of dist/.
- Pre-rendered icon set at 16 / 32 / 48 / 128 from a single SVG source.
Bug fixes from the legacy code
- Removed implicit globals in keystroke handler.
- Removed duplicate `hiddenInput` declaration.
- Null-guarded all URI parsing utilities (no more crashes when
`getPageInstance()` returns null).
- Fixed the color hierarchy cycle so all 6 palette colors are used.
- Replaced deprecated `document.execCommand('copy')` with the
Clipboard API (with a graceful fallback retained for legacy contexts).
- Migrated from `chrome.tabs.executeScript` to a content-script-based
message-passing model that does not require ad-hoc injection.
UX overhaul
- React 18 panel mounted inside a Shadow DOM so host page CSS can
never bleed into the panel.
- Light / dark / auto theme tied to `prefers-color-scheme`.
- Draggable, collapsible panel.
- Breadcrumb trail showing the nesting path of the selected
component, with click-to-navigate.
- Keyboard shortcut overlay (`?`) with full binding documentation.
- Toolbar badge showing the count of `[data-uri]` elements on the
current page.
New features (Phase 4)
- Component tree tab with depth-aware indentation and click-to-scroll.
- Live search / filter by component name, instance, or URI.
- Inline JSON preview that fetches the component's data without
opening a new tab; results are syntax-highlighted and cached.
- Diff view comparing the published instance against the unpublished
draft side by side.
- Environment switcher pill (local / dev / staging / prod) persisted
in `chrome.storage.sync`.
- Full options page for theme, panel position, default environment,
highlight intensity, and shortcut toggles.
- Copy-as-cURL button on the selected component.
Tests
- 33 Vitest unit tests covering URI parsing, clipboard fallbacks, and
preference storage. happy-dom DOM environment.
Docs
- README rewritten with install instructions, full shortcut table,
architecture overview, scripts catalog, and migration notes.
- Three UI screenshots (inspect / tree / options) under
docs/screenshots/ and embedded in the README.
Co-authored-by: Cursor <cursoragent@cursor.com>
Aligns the toolchain with the current Node.js LTS line and brings every runtime / dev dependency up to its current major. Runtime - Node 24.x LTS (`.nvmrc`, `engines.node = ">=24"`). CI already uses `node-version-file: '.nvmrc'`, so it picks this up automatically. Major bumps - TypeScript 5 β 6 (6.0.3) - React 18 β 19 (19.2.x) - Vite 5 β 8 (8.0.x) - Vitest 2 β 4 (4.1.x) - Zustand 4 β 5 (5.0.x) - happy-dom 15 β 20 (20.9.x) - @vitejs/plugin-react 4 β 6 - @types/node 22 β 24 - prettier 3.3 β 3.8 - @types/chrome β 0.1.x Held back (still on 9.x because eslint-plugin-react has not yet declared ESLint 10 as a peer): - ESLint 9.39.x (latest 9.x line) - @eslint/js 9.39.x Compatibility fixes triggered by the bumps - tsconfig: removed deprecated `baseUrl` (TypeScript 6 deprecation warning); `paths` now uses `./src/*`. - vite.config.ts: dropped the embedded `test` config and switched `__dirname` to `import.meta.dirname` (Vite 8 ESM). - New `vitest.config.ts` β Vitest 4 wants its own config file. - Zustand 5 typed-store syntax: `create<T>()(impl)` instead of `create<T>(impl)`. - React 19 ref typing: `useDraggable` now accepts `RefObject<HTMLElement | null>`. - eslint-plugin-react-hooks 7.x added a strict `react-hooks/set-state-in-effect` rule. `useDraggable`, `JsonPreview`, and `DiffView` were refactored to use the documented "adjust state during render" pattern (store the previous prop and reset state inline) instead of `setState` inside a `useEffect`. Async branches keep using `setState` in `.then()` callbacks, which the rule allows. - `scripts/build-icons.mjs` comment bumped to suggest sharp@0.34. Validation (Node 24.14.1) - typecheck: β - lint (--max-warnings=0): β - format check: β - 33 / 33 tests passing - production build succeeds (`dist/` valid MV3 extension) Co-authored-by: Cursor <cursoragent@cursor.com>
Audit-driven follow-up that completes the v2 rewrite. Every "done" feature that was actually a stub or had a real bug is now wired end-to-end, and all dead code identified in the audit is gone. Stubs wired up - Environment switcher: `buildUrl` / `buildSchemaUrl` / `buildCurlCommand` now accept a host override. Every panel button, fetch, copy-as-cURL, and `o p` / `o c` shortcut routes through the selected env's host. Options page exposes a host input per environment (local / dev / staging / prod) and the env pill shows whether the active env has a configured host. - Highlight intensity slider: outline colors became `rgba()` with a `var(--clay-slip-outline-opacity)` driver. `setHighlightOpacity()` updates the var, and `App.tsx` subscribes to `prefs.highlightOpacity` so the slider is live. - "Not a Clay page" popup: manifest now declares `default_popup`. On Clay pages the content script sends `CLAY_DETECTED` and the service worker clears the per-tab popup (so the icon click toggles the panel). On non-Clay pages the popup shows automatically. `tabs.onUpdated` resets the popup on navigation so the same tab works for both kinds of pages. Bugs fixed - Outlines now paint on first page load (initial bootstrap calls `applyHighlights`). - Auto-theme listener now reacts to OS theme changes (tracks `systemDark` state instead of setting state to the same value). - Click-to-select no longer hijacks real interactive elements (`<a>`, `<button>`, `<input>`, contenteditable, etc.) - selection still updates but the original click goes through. - Tree-row hover now shows the actual hover outline via `highlighter.setHovered`, replacing the dead `cs-tree-hover` class. - Toolbar badge is cleared on every navigation (loading state in `tabs.onUpdated`) and bootstrapping a non-Clay page sends `count: 0`. Dead code removed - `setHighlightingEnabled` is now wired to a header toggle (eye icon) and a new `h` shortcut. - `DEFAULT_ENVIRONMENTS` replaced by `DEFAULT_ENVIRONMENT_HOSTS` / `ENVIRONMENT_ORDER` / `ENVIRONMENT_LABELS`, all consumed by the options page and switcher. - `COPY_TO_CLIPBOARD` runtime message removed (clipboard is handled directly in content script). - Replaced unused `resolveTheme` with inline computation in `useThemedRoot`. Cosmetic / minor - `Header` drops `forwardRef` for the React 19 ref-as-prop syntax. - Options page "Saved" toast clears its previous timer on rapid saves (no more overlapping flashes). - New keyboard shortcut: `h` toggles outlines from anywhere on the page; shortcut overlay updated. Tests - Added coverage for `buildUrl` host override, `splitHostAndPath`, `normalizeHost`, and host-aware `buildSchemaUrl` / `buildCurlCommand`. - Total: 47 tests, all passing on Node 24.14.1. Co-authored-by: Cursor <cursoragent@cursor.com>
Two reported bugs:
1. Settings button no-op'd because `chrome.runtime.openOptionsPage()` is
not available in content scripts. Routed through the service worker
via a new `OPEN_OPTIONS` runtime message.
2. Collapsed panel was 180px wide but still tried to render the title,
status pill, and 4 buttons side-by-side, so the expand button was
crushed off the right edge with no way to get the panel back.
Reworked the collapsed state:
- Hides the "Clay Slip" title text, status pill, ?/help, and gear
buttons (`.cs-collapsed-hide`).
- Keeps the logo, component count badge, eye toggle, and expand
button β the always-useful at-a-glance bits.
- Panel shrinks to its content (`width: auto; max-width: 240px`)
instead of a fixed 180px, so the expand button is always visible.
- Header padding tightened and bottom border removed when collapsed
so it reads as a compact pill.
Also promoted the component count to its own pill-shaped badge in
the header so it's readable at a glance whether expanded or not.
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds the next batch of features picked from the roadmap. Each one slots into the v2 architecture and reuses the env switcher / store / shadow host plumbing already in place. Features - Open in Clay editor β Edit button on the page and on every component opens the page in `?edit=true` mode, focused on the component instance via a hash anchor. - Sticky-note annotations β pinned notes per component URI in chrome.storage.local. Editor lives in ComponentDetails; an orange dot appears on annotated components on the page when Slip is open; new Notes tab lists every note across pages with click-to-jump. - Page audit export β Export βΎ button on the Inspect tab downloads the full page manifest as JSON / CSV / Markdown with a stable filename. - Cross-environment diff β Diff tab now has a Compare: select with the existing Published vs Draft entry plus one entry per configured env host (Production vs Staging, etc.); options for unconfigured envs are disabled with a helpful empty state. - Shareable selection links β Share button on a selected component copies a `?clay-slip-select=<uri>` link; opening it on another machine auto-mounts the panel, scrolls to that component, and selects it. - Find-on-page β Tree tab search now dims non-matches on the page, shows a match counter, and supports Enter / Shift+Enter to cycle and Esc to clear. The current match gets an accent border in the tree. - SEO tab β extracts <title>, description, canonical, robots, every og:* / twitter:* meta, and JSON-LD blocks; renders Twitter + Facebook/Slack card previews; lints common issues (length, missing og:image, duplicate <h1>, etc.). Live-updates via MutationObserver. - Recently viewed components β last N (configurable, default 20) selections persisted in chrome.storage.local with a dedicated Recently viewed section in the Inspect tab; one click jumps to the component (or opens its source page). - Resizable + dockable panel β drag the inner edge to resize live (clamped 280β720px, persisted to prefs). Two new dock modes: left-side and right-side, both full-height like a sidebar. Resize handle flips to the inner edge based on dock side. - Component screenshot β Screenshot button uses chrome.tabs.captureVisibleTab + canvas crop scaled by devicePixelRatio to get a pixel-perfect PNG of just the selected component, written to the system clipboard. Panel auto-hides during capture so it doesn't appear in the screenshot. Bonus - Copy-as menu in ComponentDetails: URI / cURL / fetch() / Playwright locator / CSS selector β all env-host aware. - Auto-theme listener now actually re-themes when the OS theme changes (carried over from the previous fix). - Header stays usable when collapsed; settings opens via service-worker message instead of the no-op content-script API call (carried over). Manifest - No new permissions needed. captureVisibleTab uses activeTab (already granted) and the existing <all_urls> host_permissions; clipboardWrite was already declared. Tests - 71 tests passing on Node 24.14.1. - New test files: annotations, recents, exporter, seo. - Extended clay-uri tests for buildEditorUrl, buildShareLink / parseShareTarget, and the copy-as snippet helpers. Docs - README updated with the new feature list, the expanded usage table, and an annotated lib/ tree. Co-authored-by: Cursor <cursoragent@cursor.com>
Two real bugs working together: 1. Shadow-DOM event retargeting broke the click-outside detection. The document-level mousedown listener used `e.target` which gets retargeted to the shadow host element at the document boundary. Since the shadow host is not a descendant of `wrapperRef.current` (which lives inside the shadow tree), the very first mousedown after opening the menu β including the one on a menu item β closed the menu before the click event ever fired the export. Fixed by using `e.composedPath()` which traces the real path through the shadow tree, and switching to `pointerdown` in capture phase for consistency. 2. The menu was `position: absolute` inside the panel body, which has `overflow-y: auto`. Even when the menu opened, it was clipped by the scroll container so users only saw a sliver (or nothing). Switched the menu to `position: fixed` with the trigger button's `getBoundingClientRect()` driving its top/right inline. Also added a flip-up heuristic when there isn't room below, an Escape-to-close handler, and an auto-close on resize/scroll so the menu doesn't drift. The download path (`<a download>` + blob URL appended to document.body) was always correct β it just wasn't being reached. Co-authored-by: Cursor <cursoragent@cursor.com>
The panel was only resizable horizontally because there was a single
width-only handle. Worse, useDraggable positioned the panel via
left/top β so making a bottom-right anchored panel wider grew it off
the right edge of the screen instead of inward.
This change:
- Adds panelHeight to UserPreferences (default 540px) and a height
slider in Options.
- Rewrites useDraggable to use anchor-edge CSS (right/bottom for
right/bottom-anchored panels), so resizing always grows the panel
toward the viewport interior.
- Generalizes ResizeHandle to width / height / corner modes:
* width β vertical strip on the inner vertical edge
* height β horizontal strip on the inner horizontal edge
* corner β small grabber in the inner corner that resizes both
with a diagonal cursor
- App.tsx renders width-only for side-dock modes and all three
handles for the four corner modes.
- Cursors: ew-resize, ns-resize, nwse/nesw-resize as appropriate.
Co-authored-by: Cursor <cursoragent@cursor.com>
The Clay editor only operates on the unpublished version of a page, but buildEditorUrl was passing the page URI through unchanged β which on a published article includes the `@published` suffix, sending the editor to a URL it can't load. Strip `@published` from the page URI before constructing the editor URL. Component instance hashes are unaffected (the instance regex already stops at `@`). Co-authored-by: Cursor <cursoragent@cursor.com>
Different forks of this extension serve different brands and domains,
and the staging/qa hostname conventions vary. Hardcoding any of that
in the codebase isn't sustainable, so this introduces a per-instance
mapping table.
What's new:
- types: SiteEnv ('prod' | 'staging' | 'qa'), SiteHostMapping, and a
new UserPreferences.siteHosts array (default []).
- lib/site-host.ts: three pure functions β
findMappingForHost(host, mappings)
rewriteUrlToEnv(url, toEnv, mappings)
availableEnvsFor(host, mappings)
Exact case-insensitive hostname matching only β no wildcards, no
prefix stripping. Predictable, idempotent.
- Options page: new "Site host mappings" section with an add/edit/
remove table (Label Β· Prod Β· Staging Β· QA Β· β). Empty cells mean
"not deployed in that env". Persists to chrome.storage.sync.
- PageInfo: renders a "View on:" pill row when the current hostname
matches a configured mapping. One pill per other configured env;
click opens the rewritten URL in a new tab.
- ComponentDetails: Share button is now a split control. The main
face copies a share link for the current page (existing behaviour);
the trailing βΎ opens a tiny menu of cross-env share links built
via rewriteUrlToEnv.
- Tests: 13 new tests covering exact-match / case-insensitivity /
unmapped / partial-mapping / invalid-URL paths (now 85 total).
- Docs: new "Configuration β Site host mappings" section in README
with an example table for a multi-brand setup.
dev and local envs are intentionally out of scope for site mappings β
those are configured via the existing Environments section for the
Clay API hosts.
Co-authored-by: Cursor <cursoragent@cursor.com>
The download flow was overkill for the common case β most uses of the page manifest are pasting it into a Linear ticket, a PR description, or a CSV cell. Generating a .json/.csv/.md file then asking the user to open it just to copy from it adds friction. Changes: - ExportMenu items now copy the formatted manifest to the clipboard instead of triggering a Blob/anchor download. Labels are now "Copy as JSON / CSV / Markdown" and the toast reports the format. - Removed the unused downloadManifest() helper from src/lib/exporter along with its Blob/URL.createObjectURL plumbing β buildManifest and formatManifest stay (still tested). - README updated to reflect the new behaviour. Co-authored-by: Cursor <cursoragent@cursor.com>
The previous "collapsed" state was a horizontal pill stuck in the panel's anchor corner β useful only briefly. The standard browser- extension chrome pattern (Sentry, Hotjar, Crisp, Intercom, React DevTools' floating toggle, etc.) is a small circular FAB that expands into the full UI on click. This commit adopts that pattern. Behavior: - The panel now auto-mounts on every Clay page in collapsed/idle state, rendered as a 48px circular Clay button anchored to the user's preferred corner. A live component-count badge (capped at "99+") rides in its top-right. - Click the FAB β expands into the full panel. - Click the panel's existing collapse button β returns to the FAB. - The toolbar icon's mount/unmount toggle is preserved as the "fully hide on this tab" escape hatch. Implementation: - New Fab.tsx component (corner-anchored circular button + badge). - store.collapsed defaults to `true` (FAB visible on first paint). - App.tsx splits its render: collapsed = `<Fab/>` in a themed root, expanded = the existing panel chrome. positionStyle is no longer applied when collapsed (FAB has its own anchor logic). - Header drops the `cs-collapsed-hide` plumbing β Header is only rendered when expanded now, so all elements are always visible. - styles.css gains .cs-fab + .cs-fab-logo + .cs-fab-badge styles with hover lift, active press, focus-visible ring, and elevated drop shadow. Retires the dead .cs-panel.cs-collapsed pill rules. - content/index.ts auto-mounts on bootstrap so the FAB shows on first page load (previously only mounted on share-link landings or toolbar-icon click). Side-dock modes (left-side / right-side) collapse to the matching bottom corner β full-height side panels can't shrink to a pill in a meaningful way. Co-authored-by: Cursor <cursoragent@cursor.com>
Two follow-ups: 1. **Use the real Clay icon.** The "S" placeholder is gone β both the FAB and the panel header now use the cream-colored Clay character that lives in the master branch (`clay.png`). Imported as a bundled asset; @crxjs auto-registered it under `web_accessible_resources` so the content script can fetch it. FAB grew to 52px with a light elevated background and an accent border on hover, so the cream character pops instead of clashing with the previous solid-red background. 2. **Fix keyboard shortcuts firing while typing in panel inputs.** Pressing `t` in the Notes textarea was switching the active tab to Tree (and stealing focus). The handler did check `target.tagName === 'TEXTAREA'`, but Shadow DOM event retargeting rewrites `e.target` to the shadow host at document level, so the actual `<textarea>` was invisible to the check. Switched to `composedPath()` and walk every node looking for an `input`, `textarea`, `select`, or `contentEditable` element β works transparently across the shadow boundary. Affects every shortcut (`t`, `i`, `h`, `[`, `?`, `y`+, `o`+). Co-authored-by: Cursor <cursoragent@cursor.com>
The previous import resolved to a relative URL (`/assets/clay-icon-β¦png`) that the content script tried to fetch from the **host page's** origin β not the extension's β so the FAB and panel-header logo silently 404'd on every page. Switch the import to `?inline` so Vite emits the PNG as a base64 data URL embedded directly in the bundled JS. No fetch, no `chrome.runtime.getURL()` wiring, and no dependency on the host page's ability to resolve `/assets/...`. Bundle grows by ~12 KB (the expected 33% base64 inflation of an 8 KB PNG) β fine for an asset that has to be present whenever the panel mounts. Co-authored-by: Cursor <cursoragent@cursor.com>
- Remove the (redundant) Edit button from the Component section: editing a
component always means opening the parent page in editor mode, which is
already exposed on the Page section. Two Edit buttons in the same panel
was confusing.
- Refactor `ShareMenu`:
- Read `location.href` at click time so SPA navigation can never produce
a stale share URL.
- When no site-host mappings are configured, render a plain pill button
(full radius/border) instead of a half-pill split that visually looked
broken next to nothing.
- Always surface a toast on copy success/failure, including the env name
so it's obvious which environment was copied.
- Add `type="button"` everywhere so an enclosing form can't accidentally
submit instead of copying.
Co-authored-by: Cursor <cursoragent@cursor.com>
Remove the Playwright locator option from the Component "Copy asβ¦" menu β not used in our QA flow. Drops `copyAsPlaywrightLocator` and its test, and trims the README highlight. Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR is a full rewrite of
clay-devtools(Clay Slip), delivered as a series of focused commits on top of the original 2018 Manifest V2 vanilla-JS extension.It ships a modern, fully typed, fully tested Chrome extension built on Manifest V3 + TypeScript 6 + React 19 + Vite 8 + Vitest 4 + Zustand 5, runs on Node 24 LTS, fixes the original bugs, and adds a long list of new features tailored to both devs and product managers.
npm run build) is load-unpacked-ready indist/What's new (v2)
Floating button + panel
[data-uri]elements on the current page (cleared on navigation).Inspect & navigate
attachShadow({ mode: 'open' })so host-page CSS never bleeds in.<a>/<button>/<input>clicks go through.Entercycles next,Shift+Enterprevious,Escclears.hshortcut) with configurable opacity slider.Edit, share, screenshot
@publishedso the editor can actually save).chrome.storage.local); show as an orange dot on the page and a Notes tab listing every note across pages.?clay-slip-select=β¦URL that auto-opens Slip and selects the same component on someone else's machine. Readslocation.hrefat click time so SPA nav can never produce a stale link.captureVisibleTab+ canvas crop scaled bydevicePixelRatio, panel auto-hides during capture.Cross-environment tooling
Compare:select to diff the same URI across hosts.Compare & diff
@publishedselection.SEO & audit (PMs love these)
<title>/ description / canonical / robots /og:*/twitter:*/ JSON-LD with Twitter + Facebook/Slack card previews and lints (length, missingog:image, duplicate<h1>, etc.); live updates viaMutationObserver.Ergonomics
fetch()snippet / CSS selector β all env-host aware.?. Shortcuts are gated bycomposedPath()so typingtin a note input doesn't switch tabs.What it looks like
Inspect tab
Tree tab
Options page
Bugs fixed from the original Slip + audit
evaluateKeystroke(e.g.uri,opts) β entirely rewritten as a typed React hook.hiddenInputdeclaration β gone, clipboard goes throughnavigator.clipboard.write[Text]with a tested legacy fallback.getPageInstance(...).replace(...)could throw on null β every URI utility is now a total function.chrome.tabs.executeScriptβ replaced by an MV3-native content script + message-passing model.<a>/<button>/<input>/ contenteditable.tabs.onUpdatedclears it now.chrome.runtime.openOptionsPage()isn't callable from a content script; routed through the service worker.@publishedso the editor opens the editable version.composedPath()now correctly detects inputs inside the Shadow DOM.?inline, so no cross-origin asset fetch.Architecture
Configuration: site host mappings
Open the Options page β Site host mappings to declare brand β env hostnames, e.g.:
Once configured:
Migration notes (1.0 β 2.0)
This release is a full rewrite, but no end-user features were dropped. The keyboard model (
y+p/y+c/o+p/o+c) is preserved verbatim and now lives in the?overlay alongside new bindings..nvmrcpinned,engines.node = ">=24")chrome.action,chrome.scripting)npm install && npm run buildTest plan
CI runs all of these on every push and PR:
npm run typecheck(tsc --noEmit, strict)npm run lint(--max-warnings=0, react-hooks v7 strict rules)npm run format:check(Prettier)npm run testβ 84 tests passingnpm run buildβ validdist/with MV3 manifestManual smoke test (load unpacked from
dist/):hβ outlines hide; press again β return<a>inside a component β navigation still worksEntercycles@publishedselection@published)autoβ panel re-themes liveMade with Cursor