Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/mtb-chrome-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"material-theme-builder": minor
---

Add a Chrome extension: click its toolbar button on any shadcn-based site and the `ThemePanel` mounts on the page, re-theming it live by forcing the shadcn variables — same mapping as `toShadcn()`, same-origin preview iframes included. The panel renders in a shadow root, styled by the very variables it edits, and closing it restores the site untouched. Next to the panel: a button downloading the theme's CSS variables as a `globals.css` snippet (à la ui.shadcn.com/create's "Copy Theme") and one copying a self-contained install command — the theme rides along as a base64 `data:` URL (`npx shadcn@latest add "data:application/json;base64,…"`), nothing to host or download first. An info button opens an About popover backed by an animated `Flowfield`, itself painted with the theme's variables.

It asks for `activeTab` alone: nothing is read or injected until the button is clicked, and the panel is not a declared content script. Built with Vite + CRXJS out of `chrome-extension/`, shipped as a zip on each GitHub release (and to the Chrome Web Store once its credentials are configured) — not as part of this npm package.

`ThemePanel` also gains two props: `customColors` (default `true`), which the extension turns off since custom colors map to no shadcn variable and would be a control that does nothing there, and `size` (`"default" | "lg"`), which scales every control up for an overlay meant to be hit on any page.
96 changes: 96 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,99 @@ jobs:
fi

echo "Figma plugin v${VERSION} published successfully"

#
# Chrome extension — only on version bump (release merge).
#
# Two outputs, independent: the zip is always attached to the GitHub release
# (that's the "load unpacked" install path documented in the README), and the
# Web Store upload runs only once the store credentials exist as repo
# secrets. See chrome-extension/README.md for how to obtain them.
#

chrome-extension:
needs: ci
if: needs.ci.outputs.changed == 'true'
runs-on: ubuntu-latest

permissions:
contents: write # gh release upload

steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "pnpm"
- run: pnpm install --frozen-lockfile

- name: Build & zip
run: pnpm run zip-extension

# Changesets tags a single-package repo `v<version>` and creates the
# release; this only adds the asset to it.
- name: Attach to the GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "v${{ needs.ci.outputs.version }}" \
chrome-extension/material-theme-builder-chrome.zip --clobber

- name: Publish to the Chrome Web Store
if: vars.CHROME_EXTENSION_ID != ''
env:
EXTENSION_ID: ${{ vars.CHROME_EXTENSION_ID }}
CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
run: |
set -euo pipefail

VERSION="${{ needs.ci.outputs.version }}"
ZIP="chrome-extension/material-theme-builder-chrome.zip"

echo "Publishing Chrome extension v${VERSION} (item id: ${EXTENSION_ID})"

ACCESS_TOKEN=$(curl -s -X POST "https://oauth2.googleapis.com/token" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "refresh_token=${REFRESH_TOKEN}" \
-d "grant_type=refresh_token" | jq -r '.access_token')

if [[ -z "$ACCESS_TOKEN" || "$ACCESS_TOKEN" == "null" ]]; then
echo "::error::Could not refresh the Chrome Web Store access token"
exit 1
fi

# Upload the new package, then flip it to published. `uploadState`
# is FAILURE on e.g. a version that isn't higher than the live one.
UPLOAD=$(curl -s \
-X PUT \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "x-goog-api-version: 2" \
-T "$ZIP" \
"https://www.googleapis.com/upload/chromewebstore/v1.1/items/${EXTENSION_ID}")

echo "$UPLOAD" | jq . 2>/dev/null || echo "$UPLOAD"

if [[ "$(echo "$UPLOAD" | jq -r '.uploadState')" != "SUCCESS" ]]; then
echo "::error::Chrome Web Store upload failed"
exit 1
fi

PUBLISH=$(curl -s \
-X POST \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "x-goog-api-version: 2" \
-H "Content-Length: 0" \
"https://www.googleapis.com/chromewebstore/v1.1/items/${EXTENSION_ID}/publish")

echo "$PUBLISH" | jq . 2>/dev/null || echo "$PUBLISH"

if echo "$PUBLISH" | jq -e '.error' >/dev/null 2>&1; then
echo "::error::Chrome Web Store publish failed"
exit 1
fi

echo "Chrome extension v${VERSION} submitted for review"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules
dist
*.zip
*storybook.log
storybook-static
package-lock.json
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,29 @@ import { Mtb, ThemePanel } from "material-theme-builder/react";
</Mtb>;
```

### Chrome extension

Try the `ThemePanel` on **any shadcn-based site**, without touching its code.

Grab the latest `material-theme-builder-chrome.zip` from
[Releases](https://github.com/abernier/material-theme-builder/releases), unzip it, then
`chrome://extensions` → **Developer mode** → **Load unpacked** → pick the folder. Pin the
extension and click its toolbar button on a shadcn site (try
[ui.shadcn.com/create](https://ui.shadcn.com/create)).

The panel appears at the bottom of the page and every tweak re-themes it live, by forcing
the [shadcn variables](https://ui.shadcn.com/docs/theming#list-of-variables) (same mapping
as `toShadcn()`) onto the page — same-origin preview iframes included. The panel itself
lives in a shadow root and is styled by the very variables it edits. Click the toolbar
button again — or the panel's ✕ — to restore the site untouched.

Next to the panel: a button to download the theme as a `globals.css` snippet, and one to
copy a self-contained `npx shadcn add` command with the theme embedded.

The extension asks for `activeTab` and nothing else: it can't see any page until you click
its button, and it sends nothing anywhere. Source and build in
[`chrome-extension/`](chrome-extension/).

## Tailwind

Compatible through [theme variables](https://tailwindcss.com/docs/theme):
Expand Down
99 changes: 99 additions & 0 deletions chrome-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Chrome extension

`ThemePanel` on any shadcn site, from the toolbar. See the
[main README](../README.md#chrome-extension) for what it does and how to install a build.

## Layout

| File | What |
| -------------------- | ----------------------------------------------------------------------------------------- |
| `manifest.config.ts` | The MV3 manifest, built by CRXJS. Version tracks `package.json`. |
| `src/background.ts` | Service worker. The toolbar button, the badge, and injecting the panel the first time. |
| `src/content.tsx` | The panel itself, in a shadow root. Injected on demand, never a declared content script. |
| `src/content.css` | Tailwind for the shadow root, imported `?inline` (a string) so it never reaches the page. |
| `src/messages.ts` | The two messages crossing between the two. |
| `icons/` | Generated — `node scripts/make-extension-icons.mjs` (needs ImageMagick). |

## Develop

```sh
pnpm dev-extension
```

Vite writes `chrome-extension/dist/` and keeps it live: load that folder once via
`chrome://extensions` → Developer mode → **Load unpacked**, and edits to `content.tsx` (or
anything under `src/` it imports) hot-reload in the page with the panel open.

Two things are not HMR: `content.css` and the manifest. Both trigger a rebuild, but you
have to close and reopen the panel to see the CSS change, and reload the extension for a
manifest change.

## Build & package

```sh
pnpm build-extension # -> chrome-extension/dist/
pnpm zip-extension # -> chrome-extension/material-theme-builder-chrome.zip
```

CI attaches that zip to every GitHub release, and uploads it to the Chrome Web Store when
the store credentials are configured (see below).

## Publishing to the Chrome Web Store

One-time setup, all of it outside this repo:

1. Register a developer account (one-off $5 fee) at the
[Developer Dashboard](https://chrome.google.com/webstore/devconsole).
2. Upload `material-theme-builder-chrome.zip` by hand once — the API can only update an
item that already exists. Fill in the listing (copy below), submit for review.
3. Enable the Chrome Web Store API in a Google Cloud project, create an OAuth client
(type: Desktop), and mint a refresh token — see
[the official guide](https://developer.chrome.com/docs/webstore/using-api).
4. Add to the repo: variable `CHROME_EXTENSION_ID`, secrets `CHROME_CLIENT_ID`,
`CHROME_CLIENT_SECRET`, `CHROME_REFRESH_TOKEN`.

With those set, `.github/workflows/ci.yml` uploads and publishes a new version on every
release. Without them the job is skipped, and only the GitHub release zip is produced.

### Listing

**Category** Developer Tools — **Language** English

**Short description** (132 char max)

> Rebuild any shadcn site's theme from a single source color, the Material You way — live, on the page.

**Detailed description**

> Material Theme Builder brings Google's Material You color system to shadcn/ui.
>
> Open it on any shadcn-based site and the page's theme is regenerated from a single source
> color: pick a color, a scheme (tonal spot, vibrant, expressive, …) and a contrast level,
> and every shadcn variable on the page updates live — light and dark mode, same-origin
> preview iframes included.
>
> When you like what you see, download the theme as a `globals.css` snippet, or copy a
> one-line `npx shadcn add` command with the theme embedded — nothing to host first. Close
> the panel and the site is exactly as you found it.
>
> The same color engine is available as an npm package, a CLI and a Figma plugin:
> https://github.com/abernier/material-theme-builder
>
> Open source, MIT.

**Permission justifications**

- `activeTab` — the panel is injected into the page you are on, and only when you click the
toolbar button. This is what lets the extension read that page's shadcn variables and
restyle it.
- `scripting` — required to perform that injection.
- `clipboardWrite` — the panel's "copy install command" button writes an `npx shadcn add`
line to the clipboard.
- No host permissions, no remote code, no network requests, no analytics. Nothing leaves
your browser.

**Privacy** Single purpose: preview a Material You color theme on the current page. The
extension collects and transmits no user data.

**Screenshots** (1280×800 or 640×400, at least one) — the panel open on
`ui.shadcn.com/create`, collapsed and expanded, in light and dark mode.
Binary file added chrome-extension/icons/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added chrome-extension/icons/icon-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added chrome-extension/icons/icon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added chrome-extension/icons/icon-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions chrome-extension/icons/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions chrome-extension/manifest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { defineManifest } from "@crxjs/vite-plugin";

import pkg from "../package.json" with { type: "json" };

/*
* MV3 manifest. Built by CRXJS from this object rather than a static
* manifest.json so the version tracks the npm package's — CI publishes the
* extension off the same changesets version bump as the Figma plugin.
*
* No `content_scripts` entry on purpose: the panel is a React bundle, and
* declaring it here would load it into every page the user visits. It is
* injected on demand instead (see src/background.ts), which is also what
* lets the extension get by on `activeTab` — no host permissions, no
* access to anything until the toolbar button is clicked.
*/
export default defineManifest({
manifest_version: 3,
name: "Material Theme Builder",
description:
"Rebuild any shadcn site's theme from a single source color, the Material You way — live, on the page.",
version: pkg.version,
// None of the three carries a user-facing warning. `activeTab` is the
// reason there are no `host_permissions`: it grants access to one tab,
// on click, and expires. `clipboardWrite` is for the panel's copy-install-
// command button — the async Clipboard API is unreliable from an isolated
// world without it.
permissions: ["scripting", "activeTab", "clipboardWrite"],
background: {
service_worker: "src/background.ts",
type: "module",
},
action: {
default_title: "Material Theme Builder",
default_icon: {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png",
},
},
icons: {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png",
},
});
62 changes: 62 additions & 0 deletions chrome-extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* MV3 service worker — the toolbar button, and nothing else.
*
* Clicking it toggles the panel on the active tab. The panel is not a
* declared content script (see manifest.config.ts): it is injected the
* first time, then driven by messages.
*
* Injection is not the toggle. `executeScript` re-runs the loader file, 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
* instead, and a failed ping is what tells us there is no instance yet.
*/

// CRXJS emits a loader for this entry and hands back its filename — HMR
// included, so editing the panel updates it in place. Swap for `?iife` if a
// page's CSP ever rejects the loader's dynamic import.
import contentScript from "./content?script";

import {
PANEL_CLOSED,
PANEL_TOGGLE,
type PanelMessage,
type PanelState,
} from "./messages";

const BADGE_TEXT = "ON";

async function toggle(tabId: number) {
const state: PanelState | undefined = await chrome.tabs
.sendMessage(tabId, { type: PANEL_TOGGLE })
.catch(() => undefined);

if (state) return setBadge(tabId, state.open);

await chrome.scripting.executeScript({
target: { tabId },
files: [contentScript],
});
setBadge(tabId, true);
}

function setBadge(tabId: number, open: boolean) {
void chrome.action.setBadgeText({ tabId, text: open ? BADGE_TEXT : "" });
}

chrome.action.onClicked.addListener((tab) => {
if (tab.id !== undefined) void toggle(tab.id);
});

// The ✕ button closes the panel without going through us.
chrome.runtime.onMessage.addListener((message, sender) => {
const tabId = sender.tab?.id;
if (tabId !== undefined && (message as PanelMessage).type === PANEL_CLOSED) {
setBadge(tabId, false);
}
});

// A navigation takes the panel with it; the badge is per-tab and would
// otherwise outlive it.
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.status === "loading") setBadge(tabId, false);
});
Loading
Loading