diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..2dfe4a8
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) KALEIDOS INC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 482b120..946e085 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,83 @@
-# penpot-github-sync-plugin
-A Penpot plugin that syncs design tokens stored in Penpot with files stored in GitHub repositories.
+# Penpot Tokens Sync Plugin
+
+A professional, high-quality Penpot plugin designed to bridge the gap between Penpot design tokens and developer-friendly token formats (Tokens Studio/DTCG). Sync your tokens directly with GitHub repositories for a seamless Design-to-Code workflow.
+
+## ๐ Purpose
+
+This plugin serves as a "Source of Truth" connector. It allows designers using Penpot to:
+1. **Sync** the tokens with a GitHub repository using Push and Pull operations.
+2. **Review** semantic differences before committing or applying changes to ensure design consistency.
+
+## โจ Features
+
+- **DTCG Support**: Full compatibility with the Design Tokens Community Group (DTCG) specification.
+- **GitHub Integration**: Built-in support for GitHub sync using Personal Access Tokens (PAT).
+- **Visual Diffing**: Review exactly what changed (Added, Modified, Removed) before applying updates.
+- **Theme Management**: Support for multi-theme exports and token sets.
+- **Zero Dependencies (Runtime)**: Lightweight, modular JavaScript architecture with no heavy external libraries in the plugin bundle.
+
+## ๐ Setup Instructions
+
+### Prerequisites
+- A Penpot account and a Penpot project.
+- A GitHub account and a repository to store your tokens.
+- [GitHub Personal Access Token (PAT)](https://github.com/settings/tokens) with `repo` scope.
+
+### Installation
+1. Clone this repository:
+ ```bash
+ git clone https://github.com/tokens-studio/penpot-github-sync-plugin.git
+ cd penpot-github-sync-plugin
+ ```
+2. Install dependencies:
+ ```bash
+ npm install
+ ```
+3. Start the development server:
+ ```bash
+ npm run dev
+ ```
+4. In Penpot, go to **Plugins** -> **Developer** -> **Create Plugin**.
+5. Set the **URL** to `http://localhost:4400/manifest.json`.
+6. Open the plugin in your Penpot file.
+
+## ๐ GitHub Sync Functionality
+
+### 1. Adding a Provider
+Click the **+ Add Provider** button and fill in the details:
+- **Name**: A label for this configuration.
+- **Token**: Your GitHub PAT.
+- **Repository**: The path to your repo (e.g., `owner/repo`).
+- **Branch**: The branch to sync with (e.g., `main`).
+- **Path**: The file path in the repo (e.g., `tokens/design-tokens.json`).
+
+### 2. Pushing Tokens (Design -> Code)
+- Click **โ Push** next to a provider.
+- The plugin extracts your Penpot tokens and compares them with the remote file.
+- Review the differences in the modal.
+- Enter a commit message and confirm.
+
+### 3. Pulling Tokens (Code -> Design)
+- Click **โ Pull** next to a provider.
+- The plugin fetches the remote tokens.
+- Review the changes that will be applied to your Penpot file.
+- Confirm to update your local sets, tokens, and themes.
+
+## ๐งช Development
+
+### Build for Production
+To generate a production-ready bundle:
+```bash
+npm run build
+```
+The output will be in the `dist/` directory.
+
+### Project Structure
+- `src/plugin/`: Penpot API logic (runs in the Penpot environment).
+- `src/ui/`: Plugin UI logic (runs in the iframe).
+- `src/common/`: Shared utilities (e.g., semantic diff logic).
+- `index.html`: Main UI template.
+- `style.css`: Design system and component styles.
+
+## ๐ License
+MIT License. See [LICENSE](LICENSE) for details.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..ccb2ee3
--- /dev/null
+++ b/index.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+ Penpot Tokens Sync
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..462d378
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1347 @@
+{
+ "name": "penpot-plugin-starter-template",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "penpot-plugin-starter-template",
+ "version": "0.0.0",
+ "dependencies": {
+ "@octokit/rest": "^22.0.1",
+ "@penpot/plugin-styles": "1.3.2",
+ "@penpot/plugin-types": "1.3.2"
+ },
+ "devDependencies": {
+ "typescript": "^5.8.3",
+ "vite": "^7.0.5",
+ "vite-live-preview": "^0.3.2"
+ }
+ },
+ "node_modules/@commander-js/extra-typings": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-12.1.0.tgz",
+ "integrity": "sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==",
+ "dev": true,
+ "peerDependencies": {
+ "commander": "~12.1.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
+ "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz",
+ "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz",
+ "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz",
+ "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz",
+ "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz",
+ "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz",
+ "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz",
+ "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz",
+ "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz",
+ "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz",
+ "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz",
+ "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz",
+ "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz",
+ "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz",
+ "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz",
+ "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz",
+ "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz",
+ "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz",
+ "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz",
+ "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz",
+ "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz",
+ "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz",
+ "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz",
+ "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz",
+ "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz",
+ "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@octokit/auth-token": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
+ "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==",
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/core": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz",
+ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==",
+ "dependencies": {
+ "@octokit/auth-token": "^6.0.0",
+ "@octokit/graphql": "^9.0.3",
+ "@octokit/request": "^10.0.6",
+ "@octokit/request-error": "^7.0.2",
+ "@octokit/types": "^16.0.0",
+ "before-after-hook": "^4.0.0",
+ "universal-user-agent": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/endpoint": {
+ "version": "11.0.2",
+ "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz",
+ "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==",
+ "dependencies": {
+ "@octokit/types": "^16.0.0",
+ "universal-user-agent": "^7.0.2"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/graphql": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz",
+ "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==",
+ "dependencies": {
+ "@octokit/request": "^10.0.6",
+ "@octokit/types": "^16.0.0",
+ "universal-user-agent": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/openapi-types": {
+ "version": "27.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz",
+ "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="
+ },
+ "node_modules/@octokit/plugin-paginate-rest": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz",
+ "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==",
+ "dependencies": {
+ "@octokit/types": "^16.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ },
+ "peerDependencies": {
+ "@octokit/core": ">=6"
+ }
+ },
+ "node_modules/@octokit/plugin-request-log": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz",
+ "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==",
+ "engines": {
+ "node": ">= 20"
+ },
+ "peerDependencies": {
+ "@octokit/core": ">=6"
+ }
+ },
+ "node_modules/@octokit/plugin-rest-endpoint-methods": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz",
+ "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==",
+ "dependencies": {
+ "@octokit/types": "^16.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ },
+ "peerDependencies": {
+ "@octokit/core": ">=6"
+ }
+ },
+ "node_modules/@octokit/request": {
+ "version": "10.0.7",
+ "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz",
+ "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==",
+ "dependencies": {
+ "@octokit/endpoint": "^11.0.2",
+ "@octokit/request-error": "^7.0.2",
+ "@octokit/types": "^16.0.0",
+ "fast-content-type-parse": "^3.0.0",
+ "universal-user-agent": "^7.0.2"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/request-error": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz",
+ "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==",
+ "dependencies": {
+ "@octokit/types": "^16.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/rest": {
+ "version": "22.0.1",
+ "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz",
+ "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==",
+ "dependencies": {
+ "@octokit/core": "^7.0.6",
+ "@octokit/plugin-paginate-rest": "^14.0.0",
+ "@octokit/plugin-request-log": "^6.0.0",
+ "@octokit/plugin-rest-endpoint-methods": "^17.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/types": {
+ "version": "16.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz",
+ "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==",
+ "dependencies": {
+ "@octokit/openapi-types": "^27.0.0"
+ }
+ },
+ "node_modules/@penpot/plugin-styles": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@penpot/plugin-styles/-/plugin-styles-1.3.2.tgz",
+ "integrity": "sha512-/xeJpAujCoeN2oMxNsr31EcCpZ2ztmy8LIvG19GEXgwdaRXLWjh6P5FNptk7jBybOMxeI6o18Woy8oB0j+LqYQ=="
+ },
+ "node_modules/@penpot/plugin-types": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@penpot/plugin-types/-/plugin-types-1.3.2.tgz",
+ "integrity": "sha512-f0kmmZaFNs9sGtSmqmSJQYCs5Qt+KYgTD8RneUjL+Dv+zfNQnd5e4L+iHSYFJ4HWvcDvTiK7F/gya7PwMTu7WA=="
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz",
+ "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz",
+ "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz",
+ "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz",
+ "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz",
+ "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz",
+ "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz",
+ "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz",
+ "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz",
+ "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz",
+ "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz",
+ "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz",
+ "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz",
+ "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz",
+ "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz",
+ "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz",
+ "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz",
+ "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz",
+ "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz",
+ "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz",
+ "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/ansi-html": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/@types/ansi-html/-/ansi-html-0.0.0.tgz",
+ "integrity": "sha512-PEBpUlteD0VW02udY7UjjgjxHwVXmkdanhmRIMkzatGmORJGjzqKylrXVxz1G5xRTEECMxIkwTHpPmZ9Jb7ANQ==",
+ "dev": true
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
+ "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true
+ },
+ "node_modules/@types/ms": {
+ "version": "0.7.34",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
+ "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "24.0.14",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz",
+ "integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~7.8.0"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.5.12",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz",
+ "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/ansi-html": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.9.tgz",
+ "integrity": "sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==",
+ "dev": true,
+ "engines": [
+ "node >= 0.8.0"
+ ],
+ "bin": {
+ "ansi-html": "bin/ansi-html"
+ }
+ },
+ "node_modules/before-after-hook": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
+ "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="
+ },
+ "node_modules/chalk": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+ "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+ "dev": true,
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/commander": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
+ "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.6",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
+ "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.6",
+ "@esbuild/android-arm": "0.25.6",
+ "@esbuild/android-arm64": "0.25.6",
+ "@esbuild/android-x64": "0.25.6",
+ "@esbuild/darwin-arm64": "0.25.6",
+ "@esbuild/darwin-x64": "0.25.6",
+ "@esbuild/freebsd-arm64": "0.25.6",
+ "@esbuild/freebsd-x64": "0.25.6",
+ "@esbuild/linux-arm": "0.25.6",
+ "@esbuild/linux-arm64": "0.25.6",
+ "@esbuild/linux-ia32": "0.25.6",
+ "@esbuild/linux-loong64": "0.25.6",
+ "@esbuild/linux-mips64el": "0.25.6",
+ "@esbuild/linux-ppc64": "0.25.6",
+ "@esbuild/linux-riscv64": "0.25.6",
+ "@esbuild/linux-s390x": "0.25.6",
+ "@esbuild/linux-x64": "0.25.6",
+ "@esbuild/netbsd-arm64": "0.25.6",
+ "@esbuild/netbsd-x64": "0.25.6",
+ "@esbuild/openbsd-arm64": "0.25.6",
+ "@esbuild/openbsd-x64": "0.25.6",
+ "@esbuild/openharmony-arm64": "0.25.6",
+ "@esbuild/sunos-x64": "0.25.6",
+ "@esbuild/win32-arm64": "0.25.6",
+ "@esbuild/win32-ia32": "0.25.6",
+ "@esbuild/win32-x64": "0.25.6"
+ }
+ },
+ "node_modules/escape-goat": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz",
+ "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/fast-content-type-parse": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
+ "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ]
+ },
+ "node_modules/fdir": {
+ "version": "6.4.6",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
+ "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
+ "dev": true,
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/p-defer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-4.0.1.tgz",
+ "integrity": "sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.45.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz",
+ "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.45.1",
+ "@rollup/rollup-android-arm64": "4.45.1",
+ "@rollup/rollup-darwin-arm64": "4.45.1",
+ "@rollup/rollup-darwin-x64": "4.45.1",
+ "@rollup/rollup-freebsd-arm64": "4.45.1",
+ "@rollup/rollup-freebsd-x64": "4.45.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.45.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.45.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.45.1",
+ "@rollup/rollup-linux-arm64-musl": "4.45.1",
+ "@rollup/rollup-linux-loongarch64-gnu": "4.45.1",
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.45.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.45.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.45.1",
+ "@rollup/rollup-linux-x64-gnu": "4.45.1",
+ "@rollup/rollup-linux-x64-musl": "4.45.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.45.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.45.1",
+ "@rollup/rollup-win32-x64-msvc": "4.45.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
+ "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
+ "dev": true,
+ "dependencies": {
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.8.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
+ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
+ "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
+ "dev": true
+ },
+ "node_modules/universal-user-agent": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
+ "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="
+ },
+ "node_modules/vite": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.5.tgz",
+ "integrity": "sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.6",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.6",
+ "rollup": "^4.40.0",
+ "tinyglobby": "^0.2.14"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-live-preview": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/vite-live-preview/-/vite-live-preview-0.3.2.tgz",
+ "integrity": "sha512-NrmGaAc85qvkx/+6FluiTo9rLnoY+/NOYnuUvcW5Yb5tSJzUxuloXYrCSS1dtxQB9YKUbpQ95JCb0GRuF//JEQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@commander-js/extra-typings": "^12.1.0",
+ "@types/ansi-html": "^0.0.0",
+ "@types/debug": "^4.1.12",
+ "@types/ws": "^8.5.10",
+ "ansi-html": "^0.0.9",
+ "chalk": "^5.3.0",
+ "commander": "^12.1.0",
+ "debug": "^4.3.5",
+ "escape-goat": "^4.0.0",
+ "p-defer": "^4.0.1",
+ "ws": "^8.17.0"
+ },
+ "bin": {
+ "vite-live-preview": "bin.js",
+ "vlp": "bin.js"
+ },
+ "peerDependencies": {
+ "vite": ">=5.2.13"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
+ "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0778fbe
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "penpot-plugin-starter-template",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite build --watch",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "@octokit/rest": "^22.0.1",
+ "@penpot/plugin-styles": "1.3.2",
+ "@penpot/plugin-types": "1.3.2"
+ },
+ "devDependencies": {
+ "vite": "^7.0.5",
+ "vite-live-preview": "^0.3.2"
+ }
+}
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..e278cc0
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,10 @@
+{
+ "name": "Penpot Tokens Exporter",
+ "code": "plugin.js",
+ "description": "Export all design tokens from your Penpot file as JSON",
+ "permissions": [
+ "content:read",
+ "content:write",
+ "library:read"
+ ]
+}
\ No newline at end of file
diff --git a/src/common/diff.js b/src/common/diff.js
new file mode 100644
index 0000000..cc08d1f
--- /dev/null
+++ b/src/common/diff.js
@@ -0,0 +1,154 @@
+/**
+ * Computes a semantic diff between two token data objects (DTCG format).
+ * @param {Object} oldData
+ * @param {Object} newData
+ * @returns {Object} A record of differences by set/theme.
+ */
+export function computeSemanticDiff(oldData, newData) {
+ const result = {};
+
+ // Find all keys from both objects
+ const allKeys = new Set([
+ ...Object.keys(oldData || {}),
+ ...Object.keys(newData || {}),
+ ]);
+
+ allKeys.forEach((key) => {
+ // Skip internal metadata
+ if (key === "$metadata") return;
+
+ const oldVal = oldData ? oldData[key] : undefined;
+ const newVal = newData ? newData[key] : undefined;
+
+ if (key === "$themes") {
+ result[key] = computeThemesDiff(oldVal || [], newVal || []);
+ } else {
+ // It's a token set
+ result[key] = computeSetDiff(oldVal, newVal);
+ }
+ });
+
+ return result;
+}
+
+/**
+ * Computes difference between two theme arrays.
+ */
+function computeThemesDiff(oldThemes, newThemes) {
+ const result = {};
+
+ const oldThemeMap = new Map((oldThemes || []).map((t) => [t.name, t]));
+ const newThemeMap = new Map((newThemes || []).map((t) => [t.name, t]));
+
+ const allThemeNames = new Set([
+ ...oldThemeMap.keys(),
+ ...newThemeMap.keys(),
+ ]);
+
+ allThemeNames.forEach((name) => {
+ const oldTheme = oldThemeMap.get(name);
+ const newTheme = newThemeMap.get(name);
+
+ if (!oldTheme && newTheme) {
+ result[name] = { type: "added", newValue: newTheme };
+ } else if (oldTheme && !newTheme) {
+ result[name] = { type: "removed", oldValue: oldTheme };
+ } else {
+ if (JSON.stringify(oldTheme) !== JSON.stringify(newTheme)) {
+ result[name] = {
+ type: "modified",
+ oldValue: oldTheme,
+ newValue: newTheme,
+ };
+ } else {
+ result[name] = {
+ type: "unchanged",
+ oldValue: oldTheme,
+ newValue: newTheme,
+ };
+ }
+ }
+ });
+
+ let type = "unchanged";
+ if (
+ (!oldThemes || oldThemes.length === 0) &&
+ newThemes &&
+ newThemes.length > 0
+ )
+ type = "modified";
+ else if (
+ oldThemes &&
+ oldThemes.length > 0 &&
+ (!newThemes || newThemes.length === 0)
+ )
+ type = "modified";
+ else if (Object.keys(result).some((k) => result[k].type !== "unchanged"))
+ type = "modified";
+
+ return { type, children: result };
+}
+
+/**
+ * Computes difference between two token sets.
+ */
+function computeSetDiff(oldSet, newSet) {
+ const result = {};
+ const allTokenNames = new Set([
+ ...Object.keys(oldSet || {}),
+ ...Object.keys(newSet || {}),
+ ]);
+
+ allTokenNames.forEach((tokenName) => {
+ const oldToken = oldSet ? oldSet[tokenName] : undefined;
+ const newToken = newSet ? newSet[tokenName] : undefined;
+
+ if (!oldToken && newToken) {
+ result[tokenName] = { type: "added", newValue: newToken };
+ } else if (oldToken && !newToken) {
+ result[tokenName] = { type: "removed", oldValue: oldToken };
+ } else {
+ if (JSON.stringify(oldToken) !== JSON.stringify(newToken)) {
+ result[tokenName] = {
+ type: "modified",
+ oldValue: oldToken,
+ newValue: newToken,
+ };
+ } else {
+ result[tokenName] = {
+ type: "unchanged",
+ oldValue: oldToken,
+ newValue: newToken,
+ };
+ }
+ }
+ });
+
+ let type = "unchanged";
+ if (!oldSet && newSet) type = "added";
+ else if (oldSet && !newSet) type = "removed";
+ else if (
+ Array.from(allTokenNames).some((name) => result[name].type !== "unchanged")
+ )
+ type = "modified";
+
+ return { type, children: result };
+}
+
+/**
+ * Checks if the diff result contains any actual changes.
+ */
+export function hasChanges(diffResult) {
+ for (const key in diffResult) {
+ const setDiff = diffResult[key];
+ if (setDiff.type !== "unchanged") {
+ if (setDiff.type === "added" || setDiff.type === "removed") return true;
+ if (setDiff.children) {
+ for (const childKey in setDiff.children) {
+ if (setDiff.children[childKey].type !== "unchanged") return true;
+ }
+ }
+ }
+ }
+ return false;
+}
diff --git a/src/plugin/applier.js b/src/plugin/applier.js
new file mode 100644
index 0000000..04d57b5
--- /dev/null
+++ b/src/plugin/applier.js
@@ -0,0 +1,198 @@
+/**
+ * Recursively flattens nested token groups into a flat map of tokens.
+ * Supports both DTCG ($value, $type) and standard (value, type) formats.
+ *
+ * @param {Object} obj The token object or group.
+ * @param {string} prefix The current namespace prefix.
+ * @param {Map} result Accumulated flat tokens.
+ */
+function flattenTokens(obj, prefix = "", result = new Map()) {
+ if (!obj || typeof obj !== "object") return result;
+
+ // Check if this object is a token leaf node
+ const hasValue = obj.$value !== undefined || obj.value !== undefined;
+
+ if (hasValue) {
+ const value = obj.$value !== undefined ? obj.$value : obj.value;
+ const type = obj.$type || obj.type || "color";
+ const description = obj.$description || obj.description || "";
+ result.set(prefix, { value, type, description });
+ } else {
+ // If it doesn't have a value, treat it as a group and recurse
+ for (const key of Object.keys(obj)) {
+ if (key.startsWith("$")) continue; // Skip metadata keys at group level
+ const newPrefix = prefix ? `${prefix}.${key}` : key;
+ flattenTokens(obj[key], newPrefix, result);
+ }
+ }
+ return result;
+}
+
+/**
+ * Returns a safe, reference-free placeholder value for a given token type
+ * to satisfy initial validation during creation.
+ */
+function getDefaultValueForType(type) {
+ switch (type) {
+ case "color":
+ return "#000000";
+ case "boolean":
+ return false;
+ case "number":
+ case "opacity":
+ return 1;
+ default:
+ return "0px";
+ }
+}
+
+/**
+ * Applies DTCG tokens to the Penpot library.
+ * @param {Object} tokens
+ */
+export function applyTokens(tokens) {
+ try {
+ if (!tokens) throw new Error("No token data provided");
+
+ const library = penpot.library.local;
+ const tokensCatalog = library.tokens;
+
+ // 1. Index Existing Sets
+ const existingSets = new Map();
+ tokensCatalog.sets.forEach((set) => {
+ existingSets.set(set.name, set);
+ });
+
+ // 2. Process Imported Sets
+ const importedSetNames = new Set();
+
+ for (const setName of Object.keys(tokens)) {
+ if (setName.startsWith("$")) continue;
+ importedSetNames.add(setName);
+
+ let set = existingSets.get(setName);
+ if (!set) {
+ set = tokensCatalog.addSet({ name: setName });
+ }
+
+ if (!set) {
+ console.error(`Failed to create or retrieve set: ${setName}`);
+ continue;
+ }
+
+ // Sync Tokens in Set
+ const setTokensJson = tokens[setName];
+ const existingTokens = new Map();
+ set.tokens.forEach((t) => existingTokens.set(t.name, t));
+
+ const importedTokens = flattenTokens(setTokensJson);
+ const importedTokenNames = new Set(importedTokens.keys());
+
+ // Pass 1: Pre-create all missing tokens with a safe reference-free value
+ for (const [tokenName, tokenData] of importedTokens.entries()) {
+ let token = existingTokens.get(tokenName);
+ if (!token) {
+ const type = tokenData.type;
+ let initialValue = tokenData.value;
+
+ // If the value contains a reference, use a safe reference-free default for Pass 1
+ if (typeof initialValue === "string" && initialValue.includes("{")) {
+ initialValue = getDefaultValueForType(type);
+ }
+
+ try {
+ token = set.addToken({ type, name: tokenName, value: initialValue });
+ existingTokens.set(tokenName, token); // Keep our map updated
+ } catch (e) {
+ console.warn(`Failed to pre-create token ${tokenName}:`, e);
+ }
+ }
+ }
+
+ // Pass 2: Set the actual values (resolving any references since all tokens now exist)
+ for (const [tokenName, tokenData] of importedTokens.entries()) {
+ const token = existingTokens.get(tokenName);
+ if (token) {
+ const value = tokenData.value;
+ try {
+ if (token.value !== value) {
+ token.value = value;
+ }
+ if (tokenData.description) {
+ token.description = tokenData.description;
+ }
+ } catch (e) {
+ console.warn(`Failed to set actual value for token ${tokenName}:`, e);
+ }
+ }
+ }
+
+ // Remove tokens not in import
+ set.tokens.forEach((t) => {
+ if (!importedTokenNames.has(t.name)) {
+ if (typeof t.remove === "function") t.remove();
+ }
+ });
+ }
+
+ // 3. Remove Sets not in import
+ existingSets.forEach((set, name) => {
+ if (!importedSetNames.has(name)) {
+ if (typeof set.remove === "function") set.remove();
+ }
+ });
+
+ // 4. Update Themes
+ if (tokens.$themes && Array.isArray(tokens.$themes)) {
+ const existingThemes = new Map();
+ tokensCatalog.themes.forEach((t) => existingThemes.set(t.name, t));
+
+ const importedThemeNames = new Set();
+
+ for (const themeData of tokens.$themes) {
+ const themeName = themeData.name;
+ importedThemeNames.add(themeName);
+
+ let theme = existingThemes.get(themeName);
+ if (!theme) {
+ try {
+ theme = tokensCatalog.addTheme({
+ name: themeName,
+ group: themeData.group || "",
+ });
+ } catch (e) {
+ console.error(`Failed to create theme ${themeName}:`, e);
+ continue;
+ }
+ }
+
+ // Sync active sets
+ if (theme && themeData.selectedTokenSets) {
+ for (const startName of Object.keys(themeData.selectedTokenSets)) {
+ const targetSet = tokensCatalog.sets.find((s) => s.name === startName);
+
+ if (targetSet) {
+ try {
+ theme.addSet(targetSet);
+ } catch (e) {
+ console.warn(`Failed adding set ${startName} to theme ${themeName}`, e);
+ }
+ }
+ }
+ }
+ }
+
+ // Remove themes not in import
+ existingThemes.forEach((theme, name) => {
+ if (!importedThemeNames.has(name)) {
+ if (typeof theme.remove === "function") theme.remove();
+ }
+ });
+ }
+
+ return true;
+ } catch (error) {
+ console.error("Error applying tokens:", error);
+ throw error;
+ }
+}
diff --git a/src/plugin/extractor.js b/src/plugin/extractor.js
new file mode 100644
index 0000000..9a1f1a2
--- /dev/null
+++ b/src/plugin/extractor.js
@@ -0,0 +1,90 @@
+/**
+ * Extracts design tokens from Penpot and formats them into Tokens Studio (DTCG) format.
+ * @returns {Object|null} The formatted tokens or null if extraction fails.
+ */
+export function extractAllTokens() {
+ try {
+ const library = penpot.library.local;
+
+ if (!library || !("tokens" in library)) {
+ console.warn("Tokens API not available in this Penpot environment");
+ return null;
+ }
+
+ const tokensCatalog = library.tokens;
+
+ if (!tokensCatalog || !tokensCatalog.themes || !tokensCatalog.sets) {
+ console.log("No tokens found - file may not have any design tokens yet");
+ return null;
+ }
+
+ const output = {};
+ const setOrder = [];
+ const activeSets = [];
+
+ // Process Token Sets
+ for (const set of tokensCatalog.sets) {
+ const setName = set.name;
+ setOrder.push(setName);
+
+ if (set.active) {
+ activeSets.push(setName);
+ }
+
+ output[setName] = {};
+
+ if (Array.isArray(set.tokens)) {
+ for (const token of set.tokens) {
+ output[setName][token.name] = {
+ $value: token.value,
+ $type: token.type,
+ $description: token.description || "",
+ };
+ }
+ }
+ }
+
+ // Process Themes
+ output.$themes = tokensCatalog.themes.map((theme) => {
+ const selectedTokenSets = {};
+
+ // Handle active sets for theme
+ let setsSource = theme.activeSets || theme.sets || [];
+
+ if ((!setsSource || setsSource.length === 0) && theme.active) {
+ setsSource = tokensCatalog.sets.filter((s) => s.active);
+ }
+
+ if (Array.isArray(setsSource)) {
+ for (const activeSet of setsSource) {
+ if (activeSet && activeSet.name) {
+ selectedTokenSets[activeSet.name] = "enabled";
+ }
+ }
+ }
+
+ return {
+ id: theme.id,
+ name: theme.name,
+ group: theme.group || "",
+ description: "",
+ isSource: false,
+ selectedTokenSets,
+ };
+ });
+
+ // Metadata
+ output.$metadata = {
+ tokenSetOrder: setOrder,
+ activeThemes: tokensCatalog.themes
+ .filter((theme) => theme.active)
+ .map((theme) => theme.id),
+ activeSets: activeSets,
+ };
+
+ return output;
+ } catch (error) {
+ console.error("Error extracting tokens:", error);
+ return null;
+ }
+}
diff --git a/src/plugin/index.js b/src/plugin/index.js
new file mode 100644
index 0000000..0ace8c7
--- /dev/null
+++ b/src/plugin/index.js
@@ -0,0 +1,55 @@
+import { extractAllTokens } from "./extractor.js";
+import { applyTokens } from "./applier.js";
+
+// Open the UI
+penpot.ui.open("Penpot Tokens Sync", `?theme=${penpot.theme}`, {
+ width: 550,
+ height: 450,
+});
+
+// Handle messages from the UI
+penpot.ui.onMessage((message) => {
+ switch (message.type) {
+ case "request-current-tokens": {
+ const tokensData = extractAllTokens();
+ penpot.ui.sendMessage({
+ type: "current-tokens-data",
+ data: tokensData || {},
+ originalRequest: message.originalRequest,
+ provider: message.provider,
+ githubData: message.githubData,
+ });
+ break;
+ }
+
+ case "pull-payload":
+ try {
+ applyTokens(message.content);
+ penpot.ui.sendMessage({
+ type: "pull-success",
+ provider: `Imported from ${message.provider.repository}`,
+ });
+ } catch (e) {
+ penpot.ui.sendMessage({
+ type: "pull-error",
+ error: `Import failed: ${e.message}`,
+ });
+ }
+ break;
+
+ default:
+ console.log("Unknown message type:", message.type);
+ }
+});
+
+// Update the theme in the UI when it changes in Penpot
+penpot.on("themechange", (theme) => {
+ penpot.ui.sendMessage({
+ source: "penpot",
+ type: "themechange",
+ theme,
+ });
+});
+
+// Initial stats load
+// No initial stats sent anymore
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..7c9622e
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,365 @@
+:root {
+ --primary: #5952ee;
+ --primary-hover: #4a44d6;
+ --bg-main: #ffffff;
+ --bg-secondary: #f8f9fa;
+ --text-main: #1a1a1a;
+ --text-secondary: #6c757d;
+ --border: #e9ecef;
+ --success: #28a745;
+ --danger: #dc3545;
+ --warning: #ffc107;
+ --radius: 8px;
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+
+ --diff-added-bg: #e6ffed;
+ --diff-added-text: #24292e;
+ --diff-removed-bg: #ffeef0;
+ --diff-removed-text: #24292e;
+ --diff-modified-bg: #fffdef;
+}
+
+[data-theme="dark"] {
+ --primary: #7c75ff;
+ --primary-hover: #9691ff;
+ --bg-main: #18191c;
+ --bg-secondary: #1f2125;
+ --text-main: #e1e3e6;
+ --text-secondary: #9aa0a6;
+ --border: #323639;
+ --success: #34c759;
+ --danger: #ff453a;
+
+ --diff-added-bg: #1c3326;
+ --diff-added-text: #e6ffed;
+ --diff-removed-bg: #3c1e21;
+ --diff-removed-text: #ffeef0;
+ --diff-modified-bg: #3c3218;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: var(--font-sans);
+ background: var(--bg-main);
+ color: var(--text-main);
+ line-height: 1.5;
+ font-size: 14px;
+}
+
+/* Typography */
+h1 { font-size: 18px; font-weight: 600; margin-bottom: 4px; }
+h2 { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
+.subtitle { color: var(--text-secondary); font-size: 13px; }
+
+/* Layout */
+.view {
+ padding: 24px;
+ max-width: 600px;
+ margin: 0 auto;
+}
+
+.view-header {
+ margin-bottom: 24px;
+}
+
+/* Buttons */
+button {
+ cursor: pointer;
+ border-radius: var(--radius);
+ font-family: inherit;
+ font-weight: 500;
+ transition: all 0.2s;
+ border: 1px solid transparent;
+}
+
+.primary-btn {
+ background: var(--primary);
+ color: white;
+ padding: 10px 16px;
+ width: 100%;
+ font-size: 14px;
+}
+
+.primary-btn:hover:not(:disabled) {
+ background: var(--primary-hover);
+}
+
+.secondary-btn {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-main);
+ padding: 10px 16px;
+}
+
+.secondary-btn:hover:not(:disabled) {
+ background: var(--bg-secondary);
+}
+
+.secondary-btn.small {
+ padding: 6px 12px;
+ font-size: 12px;
+}
+
+.primary-btn:disabled, .secondary-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+/* Providers List */
+.providers-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.provider-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+
+.provider-info {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+
+.provider-icon { font-size: 20px; }
+
+.provider-name { font-weight: 600; }
+.provider-path { font-size: 12px; color: var(--text-secondary); }
+
+.provider-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.provider-actions button {
+ padding: 6px 10px;
+ font-size: 12px;
+ background: var(--bg-main);
+ border: 1px solid var(--border);
+}
+
+.provider-actions button:hover {
+ border-color: var(--primary);
+}
+
+/* Forms */
+.form-group {
+ margin-bottom: 16px;
+}
+
+label {
+ display: block;
+ font-size: 12px;
+ font-weight: 600;
+ margin-bottom: 6px;
+ color: var(--text-secondary);
+}
+
+input, textarea {
+ width: 100%;
+ padding: 10px;
+ background: var(--bg-main);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text-main);
+ font-family: inherit;
+}
+
+input:focus, textarea:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 2px rgba(89, 82, 238, 0.1);
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ gap: 12px;
+}
+
+.form-actions {
+ display: flex;
+ gap: 12px;
+ margin-top: 24px;
+}
+
+.help-text {
+ font-size: 11px;
+ color: var(--text-secondary);
+ margin-top: 4px;
+}
+
+/* Modals */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+}
+
+.modal-content {
+ background: var(--bg-main);
+ border-radius: 12px;
+ width: 90%;
+ max-width: 450px;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.modal-content.large {
+ max-width: 500px;
+}
+
+.modal-header {
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.close-btn {
+ background: none;
+ border: none;
+ font-size: 24px;
+ color: var(--text-secondary);
+}
+
+.modal-body {
+ padding: 20px;
+ overflow-y: auto;
+}
+
+.modal-footer {
+ padding: 16px 20px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+}
+
+/* Diff Styling */
+.diff-scroll-area {
+ background: var(--bg-secondary);
+ border-radius: var(--radius);
+ padding: 12px;
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+}
+
+.diff-category {
+ margin-bottom: 20px;
+}
+
+.diff-category-header {
+ font-weight: bold;
+ font-size: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 8px;
+ color: var(--primary);
+}
+
+.diff-item {
+ font-size: 12px;
+ padding: 4px 8px;
+ margin-bottom: 4px;
+ border-radius: 4px;
+}
+
+.diff-added { background: var(--diff-added-bg); color: var(--diff-added-text); }
+.diff-removed { background: var(--diff-removed-bg); color: var(--diff-removed-text); }
+.diff-modified { background: var(--diff-modified-bg); }
+
+.diff-name { font-weight: 600; margin-bottom: 2px; }
+.diff-value { font-size: 11px; white-space: pre-wrap; word-break: break-all; }
+
+.diff-modified-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.diff-arrow { color: var(--text-secondary); }
+.old-val { color: var(--danger); text-decoration: line-through; }
+.new-val { color: var(--success); }
+
+/* Context Menu */
+.context-menu {
+ position: absolute;
+ background: var(--bg-main);
+ border: 1px solid var(--border);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ border-radius: 6px;
+ padding: 4px;
+ z-index: 1000;
+}
+
+.context-menu button {
+ display: block;
+ width: 100%;
+ padding: 8px 12px;
+ text-align: left;
+ background: none;
+ font-size: 13px;
+ border-radius: 4px;
+}
+
+.context-menu button:hover {
+ background: var(--bg-secondary);
+}
+
+.context-menu button.danger {
+ color: var(--danger);
+}
+
+/* Status Bar */
+.status {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 8px 16px;
+ font-size: 12px;
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border);
+ min-height: 32px;
+ z-index: 50;
+}
+
+.status.success { color: var(--success); font-weight: 500; }
+.status.error { color: var(--danger); font-weight: 500; }
+
+.empty-state {
+ text-align: center;
+ padding: 40px 20px;
+ color: var(--text-secondary);
+ background: var(--bg-secondary);
+ border: 1px dashed var(--border);
+ border-radius: var(--radius);
+}
\ No newline at end of file
diff --git a/src/ui/diff-renderer.js b/src/ui/diff-renderer.js
new file mode 100644
index 0000000..371d9ec
--- /dev/null
+++ b/src/ui/diff-renderer.js
@@ -0,0 +1,68 @@
+import { escapeHtml } from "./ui-utils.js";
+import { hasChanges } from "../common/diff.js";
+
+/**
+ * Renders the semantic diff into the results container.
+ */
+export function renderDiff(diff, container) {
+ if (!container) return;
+ container.innerHTML = "";
+
+ if (!hasChanges(diff)) {
+ container.innerHTML =
+ 'No changes detected. Everything is up to date!
';
+ return;
+ }
+
+ for (const [setName, result] of Object.entries(diff)) {
+ if (result.type === "unchanged") continue;
+
+ const categoryEl = document.createElement("div");
+ categoryEl.className = "diff-category";
+
+ const headerEl = document.createElement("div");
+ headerEl.className = "diff-category-header";
+
+ let badgeHtml = "";
+ if (result.type === "added")
+ badgeHtml = 'New ';
+ if (result.type === "removed")
+ badgeHtml = 'Removed ';
+
+ headerEl.innerHTML = `Set: ${escapeHtml(setName)} ${badgeHtml}`;
+ categoryEl.appendChild(headerEl);
+
+ if (result.children) {
+ for (const [itemName, childResult] of Object.entries(result.children)) {
+ if (childResult.type === "unchanged") continue;
+
+ const itemEl = document.createElement("div");
+ itemEl.className = `diff-item diff-${childResult.type}`;
+
+ let contentHtml = `${escapeHtml(itemName)}
`;
+
+ if (childResult.type === "added") {
+ const val = childResult.newValue?.$value ?? JSON.stringify(childResult.newValue);
+ contentHtml += `+ ${escapeHtml(String(val))}
`;
+ } else if (childResult.type === "removed") {
+ const val = childResult.oldValue?.$value ?? JSON.stringify(childResult.oldValue);
+ contentHtml += `- ${escapeHtml(String(val))}
`;
+ } else if (childResult.type === "modified") {
+ const oldVal = childResult.oldValue?.$value ?? JSON.stringify(childResult.oldValue);
+ const newVal = childResult.newValue?.$value ?? JSON.stringify(childResult.newValue);
+ contentHtml += `
+
+
${escapeHtml(String(oldVal))}
+
โ
+
${escapeHtml(String(newVal))}
+
+ `;
+ }
+ itemEl.innerHTML = contentHtml;
+ categoryEl.appendChild(itemEl);
+ }
+ }
+
+ container.appendChild(categoryEl);
+ }
+}
diff --git a/src/ui/index.js b/src/ui/index.js
new file mode 100644
index 0000000..7743b7d
--- /dev/null
+++ b/src/ui/index.js
@@ -0,0 +1,307 @@
+import "../style.css";
+import { computeSemanticDiff } from "../common/diff.js";
+import { loadProviders, saveProviders, generateId } from "./services/storage.js";
+import { getFileFromGitHub, pushFileToGitHub } from "./services/github.js";
+import {
+ showStatus,
+ toggleButtonLoading,
+ downloadJSON,
+ escapeHtml
+} from "./ui-utils.js";
+import { renderDiff } from "./diff-renderer.js";
+
+// DOM - State
+let providers = [];
+let editingProviderId = null;
+let pendingDiffOperation = null;
+
+// DOM Elements
+const elements = {
+ status: document.getElementById("status"),
+ mainView: document.getElementById("main-view"),
+ syncFormView: document.getElementById("sync-form-view"),
+ addSyncBtn: document.getElementById("add-sync-btn"),
+ cancelSyncBtn: document.getElementById("cancel-sync"),
+ githubSyncForm: document.getElementById("github-sync-form"),
+ providersList: document.getElementById("providers-list"),
+ formTitle: document.getElementById("form-title"),
+
+ // Modals
+ diffModal: document.getElementById("diff-modal"),
+ diffTitle: document.getElementById("diff-title"),
+ diffMessage: document.getElementById("diff-message"),
+ diffResults: document.getElementById("diff-results"),
+ closeDiffBtn: document.getElementById("close-diff-btn"),
+ cancelDiffBtn: document.getElementById("cancel-diff-btn"),
+ confirmDiffBtn: document.getElementById("confirm-diff-btn"),
+
+ commitModal: document.getElementById("commit-modal"),
+ closeCommitBtn: document.getElementById("close-commit-btn"),
+ cancelCommitBtn: document.getElementById("cancel-commit-btn"),
+ confirmCommitBtn: document.getElementById("confirm-commit-btn"),
+ commitMessageInput: document.getElementById("commit-message-input"),
+};
+
+// Initial Theme
+const searchParams = new URLSearchParams(window.location.search);
+document.body.dataset.theme = searchParams.get("theme") || "light";
+
+/**
+ * Views Management
+ */
+function showView(viewName) {
+ elements.mainView.style.display = viewName === "main" ? "block" : "none";
+ elements.syncFormView.style.display = viewName === "form" ? "block" : "none";
+ if (viewName === "main") editingProviderId = null;
+}
+
+/**
+ * Provider Rendering
+ */
+function renderProviderList() {
+ if (!elements.providersList) return;
+
+ if (providers.length === 0) {
+ elements.providersList.innerHTML = 'No sync providers added yet.
';
+ return;
+ }
+
+ elements.providersList.innerHTML = providers.map(p => `
+
+
+
๐
+
+
${escapeHtml(p.name)}
+
${escapeHtml(p.repository)} (${escapeHtml(p.branch)})
+
+
+
+ โ Pull
+ โ Push
+
+
+
+ `).join("");
+
+ attachProviderEvents();
+}
+
+function attachProviderEvents() {
+ elements.providersList.querySelectorAll(".pull-btn").forEach(btn =>
+ btn.addEventListener("click", () => handlePullRequest(btn.dataset.id)));
+
+ elements.providersList.querySelectorAll(".push-btn").forEach(btn =>
+ btn.addEventListener("click", () => handlePushRequest(btn.dataset.id)));
+
+ elements.providersList.querySelectorAll(".menu-btn").forEach(btn =>
+ btn.addEventListener("click", (e) => showContextMenu(e, btn.dataset.id)));
+}
+
+/**
+ * Handlers - GitHub Operations
+ */
+async function handlePullRequest(id) {
+ const provider = providers.find(p => p.id === id);
+ const btn = document.querySelector(`.pull-btn[data-id="${id}"]`);
+ toggleButtonLoading(btn, true, "โ Pull");
+
+ try {
+ showStatus("โณ Fetching from GitHub...", "success");
+ const githubFile = await getFileFromGitHub(provider);
+
+ // Request current Penpot tokens to compare
+ parent.postMessage({
+ type: "request-current-tokens",
+ originalRequest: "pull",
+ provider,
+ githubData: githubFile.content
+ }, "*");
+ } catch (error) {
+ showStatus(`โ ${error.message}`, "error");
+ toggleButtonLoading(btn, false, "โ Pull");
+ }
+}
+
+async function handlePushRequest(id) {
+ const provider = providers.find(p => p.id === id);
+ const btn = document.querySelector(`.push-btn[data-id="${id}"]`);
+ toggleButtonLoading(btn, true, "โ Push");
+
+ parent.postMessage({
+ type: "request-current-tokens",
+ originalRequest: "push",
+ provider: provider
+ }, "*");
+}
+
+/**
+ * Handlers - Dialogs & Modals
+ */
+function showContextMenu(e, id) {
+ document.querySelectorAll(".context-menu").forEach(m => m.remove());
+ const menu = document.createElement("div");
+ menu.className = "context-menu";
+ menu.innerHTML = `
+ Edit
+ Delete
+ `;
+
+ const rect = e.target.getBoundingClientRect();
+ menu.style.top = `${rect.bottom + 4}px`;
+ menu.style.right = `${window.innerWidth - rect.right}px`;
+ document.body.appendChild(menu);
+
+ menu.querySelector('[data-action="edit"]').onclick = () => {
+ const p = providers.find(x => x.id === id);
+ editingProviderId = id;
+ elements.formTitle.textContent = "๐ Edit Sync Provider";
+ const form = elements.githubSyncForm;
+ form.name.value = p.name;
+ form.token.value = p.token;
+ form.repository.value = p.repository;
+ form.branch.value = p.branch;
+ form.storagePath.value = p.storagePath;
+ showView("form");
+ menu.remove();
+ };
+
+ menu.querySelector('[data-action="delete"]').onclick = () => {
+ if (confirm("Delete this provider?")) {
+ providers = providers.filter(x => x.id !== id);
+ saveProviders(providers);
+ renderProviderList();
+ }
+ menu.remove();
+ };
+
+ setTimeout(() => document.addEventListener("click", () => menu.remove(), { once: true }), 0);
+}
+
+function closeModals() {
+ elements.diffModal.style.display = "none";
+ elements.commitModal.style.display = "none";
+ pendingDiffOperation = null;
+ // Reset all loading states
+ document.querySelectorAll(".pull-btn, .push-btn").forEach(btn => {
+ btn.disabled = false;
+ btn.textContent = btn.dataset.originalText || btn.textContent;
+ });
+}
+
+/**
+ * Message Handling
+ */
+window.addEventListener("message", async (event) => {
+ const msg = event.data;
+
+ if (msg.source === "penpot") {
+ document.body.dataset.theme = msg.theme;
+ } else if (msg.type === "current-tokens-data") {
+ const penpotTokens = msg.data || {};
+
+ if (msg.originalRequest === "pull") {
+ const githubData = msg.githubData || {};
+ const diff = computeSemanticDiff(penpotTokens, githubData);
+ pendingDiffOperation = { type: "pull", provider: msg.provider, newData: githubData };
+
+ elements.diffTitle.textContent = `Review Pull from ${msg.provider.repository}`;
+ renderDiff(diff, elements.diffResults);
+ elements.diffModal.style.display = "flex";
+ } else if (msg.originalRequest === "push") {
+ try {
+ showStatus("โณ Comparing with remote...", "success");
+ const githubFile = await getFileFromGitHub(msg.provider);
+ const diff = computeSemanticDiff(githubFile.content, penpotTokens);
+ pendingDiffOperation = {
+ type: "push",
+ provider: msg.provider,
+ newData: penpotTokens,
+ sha: githubFile.sha
+ };
+
+ elements.diffTitle.textContent = `Review Push to ${msg.provider.repository}`;
+ renderDiff(diff, elements.diffResults);
+ elements.diffModal.style.display = "flex";
+ } catch (e) {
+ showStatus(`โ ${e.message}`, "error");
+ closeModals();
+ }
+ }
+ } else if (msg.type === "pull-success") {
+ showStatus(msg.provider, "success");
+ closeModals();
+ } else if (msg.type === "pull-error") {
+ showStatus(msg.error, "error");
+ closeModals();
+ }
+});
+
+/**
+ * Event Listeners
+ */
+elements.addSyncBtn.onclick = () => {
+ elements.formTitle.textContent = "๐ฆ Add Sync Provider";
+ elements.githubSyncForm.reset();
+ showView("form");
+};
+
+elements.cancelSyncBtn.onclick = () => showView("main");
+
+elements.githubSyncForm.onsubmit = (e) => {
+ e.preventDefault();
+ const fd = new FormData(elements.githubSyncForm);
+ const data = Object.fromEntries(fd.entries());
+
+ if (editingProviderId) {
+ const idx = providers.findIndex(p => p.id === editingProviderId);
+ providers[idx] = { id: editingProviderId, ...data };
+ } else {
+ providers.push({ id: generateId(), ...data });
+ }
+
+ saveProviders(providers);
+ renderProviderList();
+ showView("main");
+};
+
+// Modal Actions
+elements.closeDiffBtn.onclick = closeModals;
+elements.cancelDiffBtn.onclick = closeModals;
+elements.closeCommitBtn.onclick = closeModals;
+elements.cancelCommitBtn.onclick = closeModals;
+
+elements.confirmDiffBtn.onclick = () => {
+ if (!pendingDiffOperation) return;
+ if (pendingDiffOperation.type === "pull") {
+ showStatus("โณ Syncing to Penpot...", "success");
+ parent.postMessage({
+ type: "pull-payload",
+ content: pendingDiffOperation.newData,
+ provider: pendingDiffOperation.provider
+ }, "*");
+ } else {
+ elements.diffModal.style.display = "none";
+ elements.commitModal.style.display = "flex";
+ }
+};
+
+elements.confirmCommitBtn.onclick = async () => {
+ const op = pendingDiffOperation;
+ const msg = elements.commitMessageInput.value.trim() || `Update tokens from Penpot (${new Date().toISOString()})`;
+
+ try {
+ showStatus("โณ Pushing to GitHub...", "success");
+ elements.confirmCommitBtn.disabled = true;
+ await pushFileToGitHub(op.provider, JSON.stringify(op.newData, null, 2), msg, op.sha);
+ showStatus("โ
Push successful!", "success");
+ } catch (e) {
+ showStatus(`โ ${e.message}`, "error");
+ } finally {
+ elements.confirmCommitBtn.disabled = false;
+ closeModals();
+ }
+};
+
+// Init
+providers = loadProviders();
+renderProviderList();
diff --git a/src/ui/services/github.js b/src/ui/services/github.js
new file mode 100644
index 0000000..7d3b752
--- /dev/null
+++ b/src/ui/services/github.js
@@ -0,0 +1,87 @@
+const GITHUB_API_BASE = "https://api.github.com";
+
+/**
+ * Fetches file content from GitHub.
+ */
+export async function getFileFromGitHub(provider) {
+ const { repository, branch, storagePath, token } = provider;
+ const [owner, repo] = repository.split("/");
+
+ if (!owner || !repo) {
+ throw new Error("Invalid repository format (expected owner/repo)");
+ }
+
+ const cleanToken = token.trim();
+ const response = await fetch(
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/contents/${storagePath}?ref=${branch}`,
+ {
+ method: "GET",
+ headers: {
+ Authorization: `token ${cleanToken}`,
+ Accept: "application/vnd.github.v3+json",
+ },
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error("File not found in repository.");
+ } else if (response.status === 401) {
+ throw new Error("Unauthorized. Check your GitHub token.");
+ } else {
+ throw new Error(`GitHub error: ${response.status}`);
+ }
+ }
+
+ const data = await response.json();
+ const rawContent = data.content.replace(/\n/g, "");
+ const decodedContent = decodeURIComponent(escape(window.atob(rawContent)));
+
+ return {
+ content: JSON.parse(decodedContent),
+ sha: data.sha,
+ };
+}
+
+/**
+ * Pushes file content to GitHub.
+ */
+export async function pushFileToGitHub(provider, content, commitMessage, sha) {
+ const { repository, branch, storagePath, token } = provider;
+ const [owner, repo] = repository.split("/");
+ const cleanToken = token.trim();
+
+ // Robust UTF-8 to Base64 conversion
+ const base64Content = window.btoa(
+ encodeURIComponent(content).replace(/%([0-9A-F]{2})/g, (_match, p1) => {
+ return String.fromCharCode(parseInt(p1, 16));
+ })
+ );
+
+ const payload = {
+ message: commitMessage,
+ content: base64Content,
+ branch,
+ ...(sha && { sha }),
+ };
+
+ const response = await fetch(
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/contents/${storagePath}`,
+ {
+ method: "PUT",
+ headers: {
+ Authorization: `token ${cleanToken}`,
+ Accept: "application/vnd.github.v3+json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.message || `Push failed (${response.status})`);
+ }
+
+ return response.json();
+}
diff --git a/src/ui/services/storage.js b/src/ui/services/storage.js
new file mode 100644
index 0000000..f363b78
--- /dev/null
+++ b/src/ui/services/storage.js
@@ -0,0 +1,54 @@
+const STORAGE_KEY = "github-sync-providers";
+const OLD_STORAGE_KEY = "github-sync-config";
+
+/**
+ * Loads sync providers from localStorage.
+ * Handles migration from legacy format.
+ * @returns {Array} List of providers.
+ */
+export function loadProviders() {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ try {
+ return JSON.parse(stored);
+ } catch (e) {
+ console.error("Failed to parse providers from storage:", e);
+ return [];
+ }
+ }
+
+ // Migrate from old format
+ const oldConfig = localStorage.getItem(OLD_STORAGE_KEY);
+ if (oldConfig) {
+ try {
+ const config = JSON.parse(oldConfig);
+ const migratedProvider = {
+ id: generateId(),
+ ...config,
+ };
+ const providers = [migratedProvider];
+ saveProviders(providers);
+ localStorage.removeItem(OLD_STORAGE_KEY);
+ return providers;
+ } catch (e) {
+ console.error("Failed to migrate legacy config:", e);
+ }
+ }
+
+ return [];
+}
+
+/**
+ * Saves providers to localStorage.
+ * @param {Array} providers
+ */
+export function saveProviders(providers) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(providers));
+}
+
+/**
+ * Generates a unique ID for a provider.
+ */
+export function generateId() {
+ return `provider-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+}
diff --git a/src/ui/ui-utils.js b/src/ui/ui-utils.js
new file mode 100644
index 0000000..dd7cf18
--- /dev/null
+++ b/src/ui/ui-utils.js
@@ -0,0 +1,67 @@
+/**
+ * Escapes HTML characters in a string.
+ * @param {string} text
+ * @returns {string}
+ */
+export function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+/**
+ * Shows a status message in the UI.
+ * @param {string} message
+ * @param {string} type 'success' | 'error'
+ */
+export function showStatus(message, type) {
+ const statusEl = document.getElementById("status");
+ if (statusEl) {
+ statusEl.textContent = message;
+ statusEl.className = `status ${type}`;
+
+ // Clear after timeout
+ if (statusEl.statusTimeout) clearTimeout(statusEl.statusTimeout);
+ statusEl.statusTimeout = setTimeout(() => {
+ statusEl.textContent = "";
+ statusEl.className = "status";
+ }, 5000);
+ }
+}
+
+/**
+ * Toggles a loading state on a button.
+ */
+export function toggleButtonLoading(btn, isLoading, originalText = "โ Push") {
+ if (isLoading) {
+ btn.disabled = true;
+ btn.dataset.originalText = btn.textContent || originalText;
+ btn.textContent = "โณ";
+ } else {
+ btn.disabled = false;
+ btn.textContent = btn.dataset.originalText || originalText;
+ }
+}
+
+/**
+ * Downloads a JSON file in the browser.
+ */
+export function downloadJSON(data, filename) {
+ const json = JSON.stringify(data, null, 2);
+ const blob = new Blob([json], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.style.display = "none";
+ document.body.appendChild(a);
+
+ setTimeout(() => {
+ a.click();
+ setTimeout(() => {
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }, 100);
+ }, 10);
+}
diff --git a/test-github.js b/test-github.js
new file mode 100644
index 0000000..d805576
--- /dev/null
+++ b/test-github.js
@@ -0,0 +1,168 @@
+// GitHub API Test Module (Octokit Version)
+// Run with: node test-github.js
+
+// ============ CONFIGURATION ============
+// Replace these with your actual values
+const CONFIG = {
+ token: '',
+ repository: 'akshay-gupta7/test-tokens-sync',
+ branch: 'master',
+ filePath: 'tokens8.json',
+};
+
+// Import Octokit
+import { Octokit } from '@octokit/rest';
+
+// ============ TEST DATA ============
+const testTokens = {
+ themes: [
+ {
+ id: 'test-theme-1',
+ name: 'Test Theme',
+ active: true,
+ activeSets: ['test-set-1']
+ }
+ ],
+ sets: [
+ {
+ id: 'test-set-1',
+ name: 'Test Set',
+ active: true,
+ tokens: [
+ {
+ id: 'test-token-1',
+ name: 'primary-color',
+ type: 'color',
+ value: '#FF0000'
+ }
+ ]
+ }
+ ],
+ metadata: {
+ themesCount: 1,
+ setsCount: 1,
+ totalTokens: 1,
+ exportedAt: new Date().toISOString()
+ }
+};
+
+// ============ GITHUB API FUNCTIONS ============
+
+async function pushToGitHub(repository, branch, filePath, content, token) {
+ const [owner, repo] = repository.split('/');
+
+ if (!owner || !repo) {
+ throw new Error('Invalid repository format. Use: owner/repo');
+ }
+
+ console.log('\n=== PUSH ATTEMPT (Octokit) ===');
+ console.log('Repository:', `${owner}/${repo}`);
+ console.log('Branch:', branch);
+ console.log('File path:', filePath);
+
+ // Initialize Octokit
+ const octokit = new Octokit({
+ auth: token,
+ userAgent: 'penpot-tokens-plugin-test'
+ });
+
+ try {
+ // Step 0: Authenticate User First
+ console.log('Verifying credentials via GET /user (Octokit)...');
+ const { data: user } = await octokit.rest.users.getAuthenticated();
+ console.log(`โ
Authenticated as user: ${user.login}`);
+
+ // Step 1: Try to get current file SHA (for update)
+ let sha = undefined;
+ console.log('\nStep 1: Checking if file exists...');
+
+ try {
+ const { data: fileData } = await octokit.rest.repos.getContent({
+ owner,
+ repo,
+ path: filePath,
+ ref: branch,
+ });
+
+ if (Array.isArray(fileData)) {
+ throw new Error('Path is a directory, not a file');
+ }
+
+ sha = fileData.sha;
+ console.log('โ
File exists, SHA:', sha);
+ } catch (error) {
+ if (error.status === 404) {
+ console.log('โ
File does not exist, will create new file');
+ } else {
+ throw error;
+ }
+ }
+
+ // Step 2: Create or update file
+ console.log('\nStep 2: Pushing file to GitHub...');
+
+ // Encode content to Base64
+ // Node.js uses Buffer
+ const base64Content = Buffer.from(content).toString('base64');
+
+ const { data: commit } = await octokit.rest.repos.createOrUpdateFileContents({
+ owner,
+ repo,
+ path: filePath,
+ message: `Update tokens via Octokit (${new Date().toISOString()})`,
+ content: base64Content,
+ branch,
+ ...(sha && { sha }),
+ });
+
+ console.log('\n=== PUSH SUCCESS ===');
+ console.log('Commit SHA:', commit.commit.sha);
+ console.log('File URL:', commit.content.html_url);
+ console.log('\nโ
Successfully pushed to GitHub!');
+
+ return commit;
+
+ } catch (error) {
+ console.error('\n=== PUSH ERROR ===');
+ console.error('Error:', error.message);
+ if (error.response) {
+ console.error('Status:', error.status);
+ console.error('Response data:', error.response.data);
+ }
+ throw error;
+ }
+}
+
+
+// ============ RUN TEST ============
+
+async function runTest() {
+ console.log('==========================================');
+ console.log(' GitHub API Push Test (Octokit)');
+ console.log('==========================================');
+
+ try {
+ const content = JSON.stringify(testTokens, null, 2);
+
+ await pushToGitHub(
+ CONFIG.repository,
+ CONFIG.branch,
+ CONFIG.filePath,
+ JSON.stringify(testTokens, null, 2),
+ CONFIG.token
+ );
+
+ console.log('\n==========================================');
+ console.log('โ
TEST PASSED - Octokit is working!');
+ console.log('==========================================\n');
+
+ } catch (error) {
+ console.error('\n==========================================');
+ console.error('โ TEST FAILED');
+ console.error('==========================================');
+ process.exit(1);
+ }
+}
+
+// Run the test
+runTest();
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..973bdfd
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,30 @@
+import { defineConfig } from "vite";
+import livePreview from "vite-live-preview";
+
+export default defineConfig({
+ plugins: [
+ livePreview({
+ reload: true,
+ config: {
+ build: {
+ sourcemap: true,
+ },
+ },
+ }),
+ ],
+ build: {
+ rollupOptions: {
+ input: {
+ plugin: "src/plugin/index.js",
+ index: "./index.html",
+ },
+ output: {
+ entryFileNames: "[name].js",
+ },
+ },
+ },
+ preview: {
+ port: 4400,
+ cors: true,
+ },
+});