Skip to content

feat: a Chrome extension putting the ThemePanel on any shadcn site - #170

Open
abernier wants to merge 14 commits into
feat/theme-panelfrom
feat/bookmarklet
Open

feat: a Chrome extension putting the ThemePanel on any shadcn site#170
abernier wants to merge 14 commits into
feat/theme-panelfrom
feat/bookmarklet

Conversation

@abernier

@abernier abernier commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Stacked on #168.

Puts the ThemePanel on any shadcn-based site behind a toolbar button, as an MV3 Chrome extension built out of chrome-extension/ with Vite + CRXJS — the same per-target idiom as figma-plugin/, sharing the same src/ panel.

Supersedes the bookmarklet this branch started as. A bookmarklet asks a lot of a user (create a bookmark by hand, paste a javascript: URL, hope the browser doesn't strip it) for something a toolbar button does in one click. The panel code carried over nearly verbatim; what changed is how it gets onto the page.

How it works

  • Shadow DOM: the panel mounts in a shadow root (fixed, centered at the bottom) — Tailwind preflight stays confined, host styles can't leak in, and since custom properties inherit through the shadow boundary, the panel is styled by the very shadcn variables it edits (dark mode included; a MutationObserver mirrors the page's .dark class for compiled dark: variants).
  • Theme application: on the first actual tweak (opening the panel repaints nothing), the config runs through builder().toShadcn() and is forced onto the page as unlayered !important :root/.dark blocks, re-appended last in <head> — this wins against @layer base vars, unlayered site styles, and inline-style theme switchers. Applied to the host document and every same-origin iframe (ui.shadcn.com/create renders its preview in one).
  • Seeding: the source color is read from the host's computed --primary (1×1 canvas pixel-readback normalizes oklch(…) to hex).
  • Injected on demand, not declared: React on every page someone browses would be a poor trade for a panel they open now and then, so the service worker calls chrome.scripting.executeScript on click. That is also what lets the manifest get by on activeTabno host permissions, nothing read until the button is pressed.
  • Injection is not the toggle: executeScript re-runs the loader, but the module it dynamic-imports stays cached in that tab's isolated world, so a second click would be a no-op. The live instance toggles itself over a message instead, and a failed ping is what tells the worker there is no instance yet. A per-tab ON badge tracks the state, including the panel closing itself via ✕.
  • CSS: Vite's ?inline compiles content.css through the repo's own postcss/Tailwind config and hands back a string for the shadow root — replacing the bespoke tsup esbuild plugin that used to do it.
  • popover.tsx / tooltip.tsx take a PortalContainerContext so Radix portals land inside the shadow root instead of document.body.

Layout

File
manifest.config.ts MV3 manifest, version tracks package.json
src/background.ts service worker — toolbar button, badge, injection
src/content.tsx the panel (was src/bookmarklet.tsx)
src/content.css Tailwind for the shadow root, imported ?inline
src/messages.ts the two messages crossing between the two
icons/ generated by scripts/make-extension-icons.mjs — a seed disc ringed by three tones, both out of builder() itself, so the mark can't drift from what the tool produces

Tested

  • Vitest: shadcnStyleSheet() (both mode blocks, every shadcn var, !important, unlayered).
  • The built bundle, driven in a real Chrome against a stub shadcn page with chrome.* stubbed: panel mounts in the shadow root with 24 KB of compiled Tailwind; source seeded from the page's --primary; a color change appends <style id="mtb-shadcn"> last in <head> and repaints the page; the About popover + Flowfield portal inside the shadow root; ✕ removes host, styles and restores --primary; mtb:toggle replies {open:true} then {open:false} — the exact contract the service worker relies on. No console errors.
  • ⚠️ Not yet exercised: load unpacked in Chrome (no way to automate the file picker), in particular the CRXJS loader's dynamic import() on a site with a very strict CSP. Fallback if it bites: ./content?script./content?iife in background.ts.

Distribution

  • pnpm zip-extension on every version bump, attached to the GitHub release — the "load unpacked" path documented in the README.
  • Chrome Web Store upload+publish in the same CI job, skipped until vars.CHROME_EXTENSION_ID and the OAuth secrets exist. Listing copy, permission justifications and the one-time store setup live in chrome-extension/README.md.
  • minor changeset (rewritten — the bookmarklet one never shipped).

The extension is not part of the npm package; dist/bookmarklet.global.js and its tsup entry are gone.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja

abernier and others added 14 commits August 3, 2026 14:37
A javascript: loader injects dist/bookmarklet.global.js (self-contained
IIFE, React bundled, Tailwind CSS compiled and inlined at build time via
a tsup esbuild plugin) from jsDelivr. The panel mounts in a shadow root
bottom-right, seeds its source color from the host's --primary, and on
the first tweak forces the shadcn variables onto the page — unlayered
!important :root/.dark blocks, re-appended last in <head>, host document
and same-origin iframes alike (ui.shadcn.com/create previews in one).
Closing it (via its close button or clicking the bookmarklet again)
restores the site untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
The action cluster next to the panel gains two buttons: ArrowDownToLine
downloads the current theme as a shadcn registry item (mtb-theme.json,
installable as-is with the shadcn CLI — toShadcn() already matches the
cssVars field), and Terminal copies the matching 'npx shadcn@latest add
./mtb-theme.json' command to the clipboard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
…tons

- BOOKMARKLET_HREF was a named export in a CSF file: Storybook registers
  every named export as a story, and Chromatic failed hanging parameters
  off the string. Module-level const now.
- Download now sits before the copy-command button, and the close button
  moves to its own ButtonGroup.
- Keeps 'npx shadcn add ./mtb-theme.json' as the copied command: 'apply'
  only takes named presets or ui.shadcn.com/create codes, not local
  registry item files (verified against shadcn@latest).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
… URLs in JSX

React swaps javascript: hrefs for a thrower string as a security
precaution, which is precisely what a bookmarklet link is. Setting the
attribute in a ref callback sidesteps the sanitizer; the drag-to-
bookmarks-bar flow now carries the real snippet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
In dev the story link targets /dist/bookmarklet.global.js served by
Storybook itself (staticDirs) — run pnpm build to refresh — while the
built Storybook keeps the jsDelivr URL. The story now prints which
bundle the link carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
Chromatic builds Storybook without building the package, so the
unconditional staticDirs entry crashed the build. Declared only when
dist/ exists; the built Storybook's bookmarklet targets jsDelivr anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
…e64 data: URL

The Terminal button now copies
'npx shadcn@latest add "data:application/json;base64,<theme>"' built
from the current config at click time — nothing to download or host
first. add fetches data: URLs fine (verified end-to-end in a real Vite
project); apply would corrupt them by appending its ?base=&rtl= params
into the payload, and only accepts named presets, create codes, or
http(s) URLs anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
The dev server sends no Cache-Control, so Chrome's heuristic caching
serves a stale bundle; the dev loader now appends ?t=Date.now() when it
runs. The built Storybook's jsDelivr URL stays clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
… loop

- The download button now saves the theme's CSS variables as a
  globals.css snippet (à la /create's Copy Theme) — plain declarations,
  no !important; shadcnStyleSheet() gains an { important } option and
  the forcing path passes it explicitly. The CLI-install path is already
  covered by the copied data: URL command.
- pnpm dev runs tsup --watch and Storybook together (concurrently):
  paired with the click-time cache-buster, every source change is live
  on the next bookmarklet click. The tailwind-as-text plugin declares
  watchFiles (the css + its @source scans) so the inlined CSS can't go
  stale in watch mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
The info button, in a group of its own, opens a popover over an animated
Flowfield backdrop painted with the host page's own shadcn variables —
they inherit through the shadow boundary, so the About card previews the
theme being edited, dark mode included. Dimmed, or the peaks swallow the
text.

The popover portals into the shadow root like the tooltips do, so the
container context moves out of tooltip.tsx into lib/portalContainer.ts,
shared by both.

Also: `ThemePanel` gains a `customColors` prop (default `true`), which
the bookmarklet turns off. Custom colors map to no shadcn variable —
on a host page they were a control that did nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
`bookmarklet.` prefix on everything the feature owns, tests included, so
dropping the bookmarklet one day is a matter of deleting its files rather
than working out which of lib/ belonged to it. The shadcn components stay
as they are — they are generated files that happen to read the portal
container context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
…oses the popover

It leads the cluster now, in its own group before the panel. And the
popover ignores outside interaction: every tweak on the panel repaints
its flowfield backdrop, which dismiss-on-outside-click made impossible to
watch. The button toggles it back off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
`size="lg"` everywhere in the bookmarklet — 36px hit targets on someone
else's site, where the panel competes with the page's own chrome — via a
new `size` prop on ThemePanel that scales its buttons with it.

And the host is now a click-through full-width strip that centers the
panel, rather than a box pinned bottom-right. Not `left:50%` + a
translate: a transformed ancestor would become the containing block of
the fixed-positioned Radix popper living in the shadow root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
A bookmarklet asks a lot of a user: create a bookmark by hand, paste a
`javascript:` URL, hope the browser doesn't strip it. A toolbar button
asks for one click, so the panel now ships as an MV3 extension built out
of `chrome-extension/` with Vite + CRXJS — the same idiom as
`figma-plugin/`, and the same shared `src/` panel underneath.

`src/bookmarklet.tsx` becomes the content script nearly verbatim: the
shadow root, the forced shadcn variables, the iframe walk, the About
popover all carry over untouched. What changed is how it gets there.

It is *not* a declared content script. React on every page someone
browses would be a poor trade for a panel they open now and then, so the
service worker injects it on click. That is also what lets the manifest
get by on `activeTab`: no host permissions, nothing read until the button
is pressed.

Injection is not the toggle, either. `executeScript` re-runs the loader,
but the module it dynamic-imports stays cached in that tab's isolated
world, so a second click would be a no-op. The live instance toggles
itself over a message instead, and a failed ping is what tells the worker
there is no instance yet.

The `?raw` + custom tsup postcss plugin that compiled Tailwind for the
shadow root is gone: Vite's `?inline` does it, through the repo's own
postcss config.

Distribution: `pnpm zip-extension` on every version bump, attached to the
GitHub release (the documented "load unpacked" path), plus a Web Store
upload that stays skipped until `CHROME_EXTENSION_ID` and its OAuth
secrets exist. The listing copy and the one-time store setup live in
chrome-extension/README.md.

Icons come out of `builder()` itself — a seed disc ringed by three of the
tones it derives — so the mark can't drift from what the tool produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja
@abernier abernier changed the title feat: add a ThemePanel bookmarklet for any shadcn site feat: a Chrome extension putting the ThemePanel on any shadcn site Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant