feat: a Chrome extension putting the ThemePanel on any shadcn site - #170
Open
abernier wants to merge 14 commits into
Open
feat: a Chrome extension putting the ThemePanel on any shadcn site#170abernier wants to merge 14 commits into
ThemePanel on any shadcn site#170abernier wants to merge 14 commits into
Conversation
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
ThemePanel bookmarklet for any shadcn siteThemePanel on any shadcn site
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.
Stacked on #168.
Puts the
ThemePanelon any shadcn-based site behind a toolbar button, as an MV3 Chrome extension built out ofchrome-extension/with Vite + CRXJS — the same per-target idiom asfigma-plugin/, sharing the samesrc/panel.How it works
MutationObservermirrors the page's.darkclass for compileddark:variants).builder().toShadcn()and is forced onto the page as unlayered!important:root/.darkblocks, re-appended last in<head>— this wins against@layer basevars, unlayered site styles, and inline-styletheme switchers. Applied to the host document and every same-origin iframe (ui.shadcn.com/create renders its preview in one).--primary(1×1 canvas pixel-readback normalizesoklch(…)to hex).chrome.scripting.executeScripton click. That is also what lets the manifest get by onactiveTab— no host permissions, nothing read until the button is pressed.executeScriptre-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-tabONbadge tracks the state, including the panel closing itself via ✕.?inlinecompilescontent.cssthrough 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.tsxtake aPortalContainerContextso Radix portals land inside the shadow root instead ofdocument.body.Layout
manifest.config.tspackage.jsonsrc/background.tssrc/content.tsxsrc/bookmarklet.tsx)src/content.css?inlinesrc/messages.tsicons/scripts/make-extension-icons.mjs— a seed disc ringed by three tones, both out ofbuilder()itself, so the mark can't drift from what the tool producesTested
shadcnStyleSheet()(both mode blocks, every shadcn var,!important, unlayered).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:togglereplies{open:true}then{open:false}— the exact contract the service worker relies on. No console errors.import()on a site with a very strict CSP. Fallback if it bites:./content?script→./content?iifeinbackground.ts.Distribution
pnpm zip-extensionon every version bump, attached to the GitHub release — the "load unpacked" path documented in the README.vars.CHROME_EXTENSION_IDand the OAuth secrets exist. Listing copy, permission justifications and the one-time store setup live inchrome-extension/README.md.minorchangeset (rewritten — the bookmarklet one never shipped).The extension is not part of the npm package;
dist/bookmarklet.global.jsand its tsup entry are gone.🤖 Generated with Claude Code
https://claude.ai/code/session_01G69EjEY89Q6nNGj1xvgeja