diff --git a/.opencode/skills/create-pr/SKILL.md b/.opencode/skills/create-pr/SKILL.md new file mode 100644 index 0000000000..88b5464481 --- /dev/null +++ b/.opencode/skills/create-pr/SKILL.md @@ -0,0 +1,81 @@ +--- +name: create-pr +description: Create a GitHub PR following Penpot conventions, with a concise engineer-focused description +--- + +# Create Pull Request + +Create a GitHub PR with proper title format and a concise description that explains reasoning, not implementation details. + +## When to Use + +- Opening a new pull request +- The user asks to create a PR +- Code changes are ready and committed + +## Workflow + +### 1. Verify Prerequisites + +```bash +git branch --show-current +git log --oneline main..HEAD +``` + +### 2. Check if Branch is Pushed + +```bash +BRANCH=$(git branch --show-current) +if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then + echo "Branch is pushed, proceeding with PR creation" +else + echo "ERROR: Branch '$BRANCH' is not pushed to remote. Please push the branch first." + exit 1 +fi +``` + +**If the branch is not pushed, STOP here and ask the user to push it. The LLM does not have push permissions.** + +### 3. Create PR Body + +Write to `/tmp/pr-body.md` to avoid shell quoting issues: + +```bash +cat > /tmp/pr-body.md << 'EOF' +**Note:** This PR was created with AI assistance. + +## What + + + +## Why + + + +## How + + +EOF +``` + +### 4. Create the PR + +Follow title and description format from `mem:workflow/creating-prs` and `mem:workflow/creating-commits`. + +```bash +gh pr create --base main --project "Main" --title "" --body-file /tmp/pr-body.md +``` + +### 5. What NOT to Include + +- ❌ List of files changed (visible in diff) +- ❌ Testing steps (CI handles this) +- ❌ Screenshots unless UI-visible +- ❌ Migration notes unless breaking changes +- ❌ Regression fixes introduced during the PR (they're part of the development process, not the feature) + +## Key Principles + +- **Write for humans.** The diff shows what changed. The description explains why. +- **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it? +- **Skip the obvious.** Don't explain what `git diff` already shows. diff --git a/CHANGES.md b/CHANGES.md index 34a9d5c49a..58d54e8199 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -112,7 +112,7 @@ - Fix exported path with strokes being cut off in SVG file [#9995](https://github.com/penpot/penpot/issues/9995) (PR: [#9996](https://github.com/penpot/penpot/pull/9996)) - Fix French Canada locale falling back to French translations instead of French Canadian (by @alexismo) [#10017](https://github.com/penpot/penpot/issues/10017) (PR: [#10027](https://github.com/penpot/penpot/pull/10027)) -## 2.16.0 (Unreleased) +## 2.16.0 ### :boom: Breaking changes & Deprecations diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 5c23d7ab31..130d9a86be 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -311,11 +311,30 @@ :object-id object-id :tag tag}))) +(defn- delete-file-object-thumbnails! + "Soft-deletes multiple object thumbnails in a single UPDATE statement + with RETURNING, then touches all returned media objects." + [{:keys [::db/conn ::sto/storage]} object-ids] + (let [ids (db/create-array conn "text" (seq object-ids)) + sql (str/concat + "UPDATE file_tagged_object_thumbnail" + " SET deleted_at = now()" + " WHERE object_id = ANY(?)" + " AND deleted_at IS NULL" + " RETURNING media_id") + rows (db/exec! conn [sql ids])] + (doseq [{:keys [media-id]} rows] + (sto/touch-object! storage media-id)))) + (def ^:private schema:delete-file-object-thumbnail [:map {:title "delete-file-object-thumbnail"} [:file-id ::sm/uuid] [:object-id [:string {:max 250}]]]) +(def ^:private schema:delete-file-object-thumbnails + [:map {:title "delete-file-object-thumbnails"} + [:object-ids [:vector {:max 200} [:string {:max 250}]]]]) + (sv/defmethod ::delete-file-object-thumbnail {::doc/added "1.19" ::doc/module :files @@ -329,6 +348,30 @@ (delete-file-object-thumbnail! file-id object-id)) nil))) +(sv/defmethod ::delete-file-object-thumbnails + {::doc/added "1.19" + ::doc/module :files + ::climit/id [[:file-thumbnail-ops/by-profile ::rpc/profile-id] + [:file-thumbnail-ops/global]] + ::sm/params schema:delete-file-object-thumbnails + ::audit/skip true} + [cfg {:keys [::rpc/profile-id object-ids]}] + (when (seq object-ids) + ;; Extract unique file-ids from object-ids for permission checks + (let [file-ids (->> object-ids + (map thc/get-file-id) + (into #{}))] + ;; Check permissions for each unique file using a single connection + (db/run! cfg (fn [{:keys [::db/conn]}] + (doseq [file-id file-ids] + (files/check-edition-permissions! conn profile-id file-id)))) + ;; Delete all matching thumbnails in one transaction + (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] + (-> cfg + (update ::sto/storage sto/configure conn) + (delete-file-object-thumbnails! object-ids)) + nil))))) + ;; --- MUTATION COMMAND: create-file-thumbnail (defn- create-file-thumbnail diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 72cb355636..4fb4ab12e1 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -380,3 +380,538 @@ (t/is (nil? (:error out))) (t/is (map? (:result out)))))) +;; --- delete-file-object-thumbnails (batch) + +(t/deftest delete-file-object-thumbnails-basic + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page-id (first (get-in file [:data :pages])) + oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame") + oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame") + oid3 (thc/fmt-object-id (:id file) page-id (uuid/random) "component")] + + ;; Create three thumbnails + (doseq [oid [oid1 oid2 oid3]] + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id oid + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out))))) + + ;; Verify all three exist and are not soft-deleted + (let [rows (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file)} + {::db/remove-deleted false + :order-by [[:created-at :asc]]})] + (t/is (= 3 (count rows))) + (doseq [row rows] + (t/is (nil? (:deleted-at row))))) + + ;; Batch delete all three + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids [oid1 oid2 oid3]} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; Verify all three are now soft-deleted + (let [rows (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file)} + {::db/remove-deleted false + :order-by [[:created-at :asc]]})] + (t/is (= 3 (count rows))) + (doseq [row rows] + (t/is (some? (:deleted-at row))))))) + +(t/deftest delete-file-object-thumbnails-empty + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids []} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out))))) + +(t/deftest delete-file-object-thumbnails-non-existent + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page-id (first (get-in file [:data :pages])) + oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame") + oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")] + + ;; Batch delete non-existent object-ids (no thumbnails were created) + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids [oid1 oid2]} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))))) + +(t/deftest delete-file-object-thumbnails-mixed-exists + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page-id (first (get-in file [:data :pages])) + oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame") + oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame") + oid3 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")] + + ;; Create only one thumbnail + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id oid1 + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out)))) + + ;; Batch delete mix of existing and non-existing + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids [oid1 oid2 oid3]} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; Verify oid1 is soft-deleted, others don't exist + (let [rows (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file)} + {::db/remove-deleted false})] + (t/is (= 1 (count rows))) + (t/is (= oid1 (:object-id (first rows)))) + (t/is (some? (:deleted-at (first rows))))))) + +(t/deftest delete-file-object-thumbnails-already-deleted + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page-id (first (get-in file [:data :pages])) + oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")] + + ;; Create a thumbnail + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id oid + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out)))) + + ;; First batch delete + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids [oid]} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; Second batch delete (idempotent — no rows match deleted_at IS NULL) + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids [oid]} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; Verify still 1 row, still soft-deleted, not duplicated + (let [rows (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file)} + {::db/remove-deleted false})] + (t/is (= 1 (count rows))) + (t/is (= oid (:object-id (first rows)))) + (t/is (some? (:deleted-at (first rows))))))) + +(t/deftest delete-file-object-thumbnails-unauthorized + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + file (th/create-file* 1 {:profile-id (:id profile1) + :project-id (:default-project-id profile1) + :is-shared false}) + page-id (first (get-in file [:data :pages])) + oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")] + + ;; profile1 creates a thumbnail on their file + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile1) + :file-id (:id file) + :object-id oid + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out)))) + + ;; profile2 tries to batch delete thumbnails from profile1's file + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile2) + :object-ids [oid]} + out (th/command! data)] + (t/is (some? (:error out))) + (t/is (th/ex-info? (:error out))) + (t/is (= :not-found (th/ex-type (:error out))))) + + ;; Verify the thumbnail is NOT deleted + (let [rows (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file)} + {::db/remove-deleted false})] + (t/is (= 1 (count rows))) + (t/is (nil? (:deleted-at (first rows))))))) + +(t/deftest delete-file-object-thumbnails-cross-file + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page1-id (first (get-in file1 [:data :pages])) + page2-id (first (get-in file2 [:data :pages])) + oid1 (thc/fmt-object-id (:id file1) page1-id (uuid/random) "frame") + oid2 (thc/fmt-object-id (:id file2) page2-id (uuid/random) "frame")] + + ;; Create thumbnails on both files + (doseq [[oid fid] [[oid1 (:id file1)] [oid2 (:id file2)]]] + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id fid + :object-id oid + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out))))) + + ;; Batch delete from both files in one call + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids [oid1 oid2]} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; Verify both are soft-deleted + (let [rows1 (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file1)} + {::db/remove-deleted false}) + rows2 (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file2)} + {::db/remove-deleted false})] + (t/is (= 1 (count rows1))) + (t/is (some? (:deleted-at (first rows1)))) + (t/is (= 1 (count rows2))) + (t/is (some? (:deleted-at (first rows2))))))) + +(t/deftest delete-file-object-thumbnails-cross-file-unauthorized + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + file1 (th/create-file* 1 {:profile-id (:id profile1) + :project-id (:default-project-id profile1) + :is-shared false}) + file2 (th/create-file* 2 {:profile-id (:id profile2) + :project-id (:default-project-id profile2) + :is-shared false}) + page1-id (first (get-in file1 [:data :pages])) + page2-id (first (get-in file2 [:data :pages])) + oid1 (thc/fmt-object-id (:id file1) page1-id (uuid/random) "frame") + oid2 (thc/fmt-object-id (:id file2) page2-id (uuid/random) "frame")] + + ;; Create thumbnails on both files (by their respective owners) + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile1) + :file-id (:id file1) + :object-id oid1 + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out)))) + + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile2) + :file-id (:id file2) + :object-id oid2 + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out)))) + + ;; profile1 tries to batch delete thumbnails from both files + ;; (profile1 does NOT have access to file2) + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile1) + :object-ids [oid1 oid2]} + out (th/command! data)] + (t/is (some? (:error out))) + (t/is (th/ex-info? (:error out))) + (t/is (= :not-found (th/ex-type (:error out))))) + + ;; Verify NEITHER thumbnail was deleted (all-or-nothing) + (let [rows1 (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file1)} + {::db/remove-deleted false}) + rows2 (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file2)} + {::db/remove-deleted false})] + (t/is (= 1 (count rows1))) + (t/is (nil? (:deleted-at (first rows1)))) + (t/is (= 1 (count rows2))) + (t/is (nil? (:deleted-at (first rows2))))))) + +(t/deftest delete-file-object-thumbnails-media-touch + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page-id (first (get-in file [:data :pages])) + oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame") + oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")] + + ;; Create two thumbnails + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id oid1 + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out)))) + + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id oid2 + :media {:filename "sample.jpg" + :size 312043 + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out)))) + + ;; Get media-ids for both thumbnails + (let [rows (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file)} + {:order-by [[:created-at :asc]]}) + mid1 (:media-id (first rows)) + mid2 (:media-id (second rows))] + + ;; Verify storage objects exist (they are created with touched-at already set) + (t/is (some? (th/db-get :storage-object {:id mid1}))) + (t/is (some? (th/db-get :storage-object {:id mid2}))) + + ;; Batch delete both thumbnails + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids [oid1 oid2]} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; After soft-delete, storage objects should STILL exist + ;; (they are only garbage-collected later by storage-gc-touched task) + (t/is (some? (th/db-get :storage-object {:id mid1}))) + (t/is (some? (th/db-get :storage-object {:id mid2})))))) + +(t/deftest delete-file-object-thumbnails-max-batch + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page-id (first (get-in file [:data :pages])) + cnt 200 + oids (vec (repeatedly cnt + #(thc/fmt-object-id (:id file) page-id + (uuid/random) "frame")))] + + ;; Create 200 thumbnails + (doseq [oid oids] + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id oid + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out))))) + + ;; Verify all 200 exist + (let [rows (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file)} + {::db/remove-deleted false})] + (t/is (= cnt (count rows)))) + + ;; Batch delete all 200 in one call + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids oids} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; Verify all 200 are now soft-deleted + (let [rows (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file)} + {::db/remove-deleted false})] + (t/is (= cnt (count rows))) + (doseq [row rows] + (t/is (some? (:deleted-at row))))))) + +(t/deftest delete-file-object-thumbnails-single + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page-id (first (get-in file [:data :pages])) + oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")] + + ;; Create a single thumbnail + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id oid + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out)))) + + ;; Batch delete just one + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids [oid]} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; Verify it's soft-deleted + (let [rows (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file)} + {::db/remove-deleted false})] + (t/is (= 1 (count rows))) + (t/is (some? (:deleted-at (first rows))))))) + +(t/deftest delete-file-object-thumbnails-same-object-twice-in-batch + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page-id (first (get-in file [:data :pages])) + oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")] + + ;; Create one thumbnail + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id oid + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out)))) + + ;; Batch delete with the same object-id listed twice + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids [oid oid]} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; Verify it's soft-deleted (only one row) + (let [rows (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file)} + {::db/remove-deleted false})] + (t/is (= 1 (count rows))) + (t/is (some? (:deleted-at (first rows))))))) + +(t/deftest delete-file-object-thumbnails-keeps-other-files-intact + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page1-id (first (get-in file1 [:data :pages])) + page2-id (first (get-in file2 [:data :pages])) + oid1 (thc/fmt-object-id (:id file1) page1-id (uuid/random) "frame") + oid2 (thc/fmt-object-id (:id file2) page2-id (uuid/random) "frame")] + + ;; Create thumbnails on both files + (doseq [[oid fid] [[oid1 (:id file1)] [oid2 (:id file2)]]] + (let [data {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id fid + :object-id oid + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (map? (:result out))))) + + ;; Delete only thumbnail from file1 + (let [data {::th/type :delete-file-object-thumbnails + ::rpc/profile-id (:id profile) + :object-ids [oid1]} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; Verify file1's thumbnail is deleted, file2's is not + (let [rows1 (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file1)} + {::db/remove-deleted false}) + rows2 (th/db-query :file-tagged-object-thumbnail + {:file-id (:id file2)} + {::db/remove-deleted false})] + (t/is (= 1 (count rows1))) + (t/is (some? (:deleted-at (first rows1)))) + (t/is (= 1 (count rows2))) + (t/is (nil? (:deleted-at (first rows2))))))) + diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 24ec6dfd54..c4587b798e 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -32,7 +32,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.15.0 \ +ENV NODE_VERSION=v24.16.0 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index c27d2c4363..46dc7277aa 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ JAVA_HOME="/opt/jdk" \ DEBIAN_FRONTEND=noninteractive \ - NODE_VERSION=v24.15.0 \ + NODE_VERSION=v24.16.0 \ TZ=Etc/UTC RUN set -ex; \ @@ -16,6 +16,7 @@ RUN set -ex; \ ca-certificates \ curl \ ; \ + apt-get clean; \ rm -rf /var/lib/apt/lists/* RUN set -eux; \ @@ -115,13 +116,13 @@ RUN set -ex; \ woff2 \ ; \ find tmp/usr/share/zoneinfo/* -type d ! -name 'Etc' |xargs rm -rf; \ + apt-get clean; \ rm -rf /var/lib /var/cache; \ rm -rf /usr/include; \ mkdir -p /opt/data/assets; \ mkdir -p /opt/penpot; \ chown -R penpot:penpot /opt/penpot; \ - chown -R penpot:penpot /opt/data; \ - rm -rf /var/lib/apt/lists/*; + chown -R penpot:penpot /opt/data; COPY --from=build /opt/jre /opt/jre COPY --from=build /opt/node /opt/node diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 27ab66af71..2d028b5ff6 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>" ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - NODE_VERSION=v24.15.0 \ + NODE_VERSION=v24.16.0 \ DEBIAN_FRONTEND=noninteractive \ PATH=/opt/node/bin:/opt/imagick/bin:$PATH \ PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers @@ -20,6 +20,7 @@ RUN set -ex; \ locales \ ca-certificates \ ; \ + apt-get clean; \ rm -rf /var/lib/apt/lists/*; \ echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \ locale-gen; \ @@ -81,6 +82,7 @@ RUN set -ex; \ libzip4t64 \ libzstd1 \ ; \ + apt-get clean; \ rm -rf /var/lib/apt/lists/*; RUN set -eux; \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index f77a6d187a..fe1ba7833c 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,10 +1,15 @@ -FROM nginxinc/nginx-unprivileged:1.30.0 +FROM nginxinc/nginx-unprivileged:1.30.2-alpine LABEL maintainer="Penpot <docker@penpot.app>" USER root RUN set -ex; \ - useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ + apk update; \ + apk upgrade; \ + apk add --no-cache bash gettext; \ + rm -rf /var/cache/apk/*; \ + addgroup -g 1001 penpot; \ + adduser -D -H -u 1001 -s /bin/false -h /opt/penpot -G penpot penpot; \ mkdir -p /opt/data/assets; \ chown -R penpot:penpot /opt/data; \ mkdir -p /etc/nginx/overrides/main.d/; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index 5b9952a34d..f13fbd5e99 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -20,6 +20,7 @@ RUN set -ex; \ locales \ ca-certificates \ ; \ + apt-get clean; \ rm -rf /var/lib/apt/lists/*; \ echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \ locale-gen; \ diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 9cccbe799b..9eb5a55663 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,10 +1,14 @@ -FROM nginxinc/nginx-unprivileged:1.30.0 +FROM nginxinc/nginx-unprivileged:1.30.2-alpine LABEL maintainer="Penpot <docker@penpot.app>" USER root RUN set -ex; \ - useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; + apk update; \ + apk upgrade; \ + rm -rf /var/cache/apk/*; \ + addgroup -g 1001 penpot; \ + adduser -D -H -u 1001 -s /bin/false -h /opt/penpot -G penpot penpot; ARG BUNDLE_PATH="./bundle-storybook/" COPY $BUNDLE_PATH /var/www/ diff --git a/docs/technical-guide/getting-started/docker.md b/docs/technical-guide/getting-started/docker.md index 53560235d3..a8c7b55ffa 100644 --- a/docs/technical-guide/getting-started/docker.md +++ b/docs/technical-guide/getting-started/docker.md @@ -206,6 +206,12 @@ server { proxy_pass http://localhost:9001/ws/notifications; } + location /mcp/ws { + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_pass http://localhost:9001/mcp/ws; + } + # Proxy pass location / { proxy_set_header Host $http_host; diff --git a/exporter/package.json b/exporter/package.json index a5f9dc2267..9ae777ceaf 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -20,7 +20,7 @@ "playwright": "^1.60.0", "raw-body": "^3.0.2", "source-map-support": "^0.5.21", - "svgo": "penpot/svgo#v3.1", + "@penpot/svgo": "penpot/svgo#3.3.0", "undici": "^8.4.1", "xml-js": "^1.6.11", "xregexp": "^5.1.2" diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 2375106e10..0bac244214 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -4,10 +4,18 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + lodash@<=4.17.23: ^4.17.24 + lodash@>=4.0.0 <=4.17.22: ^4.17.23 + lodash@>=4.0.0 <=4.17.23: ^4.17.24 + importers: .: dependencies: + '@penpot/svgo': + specifier: penpot/svgo#3.3.0 + version: https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021 archiver: specifier: 8.0.0 version: 8.0.0 @@ -35,9 +43,6 @@ importers: source-map-support: specifier: ^0.5.21 version: 0.5.21 - svgo: - specifier: penpot/svgo#v3.1 - version: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180 undici: specifier: ^8.4.1 version: 8.4.1 @@ -54,16 +59,16 @@ importers: packages: - '@babel/runtime-corejs3@7.28.4': - resolution: {integrity: sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==} + '@babel/runtime-corejs3@7.29.7': + resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==} engines: {node: '>=6.9.0'} '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} - '@trysound/sax@0.2.0': - resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} - engines: {node: '>=10.13.0'} + '@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021': + resolution: {gitHosted: true, integrity: sha512-hG/pgVEWhmHEFMU+evGZkB5kHauff5Zo6ZO+Ro7HY0efsQTJft6svM4isH5jDISeSVrZ1CDGnhWBXuqkztsTWw==, tarball: https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021} + version: 3.3.0 abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} @@ -132,8 +137,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} @@ -165,8 +171,8 @@ packages: resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} engines: {node: '>= 0.8'} - core-js-pure@3.47.0: - resolution: {integrity: sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==} + core-js-pure@3.49.0: + resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==} core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -180,20 +186,21 @@ packages: resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==} engines: {node: '>=18'} - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} css-tree@2.2.1: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - css-tree@3.1.0: - resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} csso@5.0.5: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} @@ -219,22 +226,25 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} @@ -263,8 +273,8 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - iconv-lite@0.7.1: - resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} ieee754@1.2.1: @@ -291,20 +301,19 @@ packages: keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} - mdn-data@2.12.2: - resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} @@ -317,8 +326,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} @@ -369,8 +379,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sax@1.4.3: - resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -402,11 +413,6 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180: - resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180} - version: 4.0.0 - engines: {node: '>=16.0.0'} - tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -460,13 +466,19 @@ packages: snapshots: - '@babel/runtime-corejs3@7.28.4': + '@babel/runtime-corejs3@7.29.7': dependencies: - core-js-pure: 3.47.0 + core-js-pure: 3.49.0 '@ioredis/commands@1.10.0': {} - '@trysound/sax@0.2.0': {} + '@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021': + dependencies: + css-select: 7.0.0 + css-tree: 3.2.1 + csso: 5.0.5 + lodash: 4.18.1 + sax: 1.6.0 abort-controller@3.0.0: dependencies: @@ -528,7 +540,7 @@ snapshots: base64-js@1.5.1: {} - boolbase@1.0.0: {} + boolbase@2.0.0: {} brace-expansion@5.0.6: dependencies: @@ -560,7 +572,7 @@ snapshots: depd: 2.0.0 keygrip: 1.1.0 - core-js-pure@3.47.0: {} + core-js-pure@3.49.0: {} core-util-is@1.0.3: {} @@ -571,25 +583,25 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 - css-select@5.2.2: + css-select@7.0.0: dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 css-tree@2.2.1: dependencies: mdn-data: 2.0.28 source-map-js: 1.2.1 - css-tree@3.1.0: + css-tree@3.2.1: dependencies: - mdn-data: 2.12.2 + mdn-data: 2.27.1 source-map-js: 1.2.1 - css-what@6.2.2: {} + css-what@8.0.0: {} csso@5.0.5: dependencies: @@ -605,25 +617,25 @@ snapshots: depd@2.0.0: {} - dom-serializer@2.0.0: + dom-serializer@3.1.1: dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 - domelementtype@2.3.0: {} + domelementtype@3.0.0: {} - domhandler@5.0.3: + domhandler@6.0.1: dependencies: - domelementtype: 2.3.0 + domelementtype: 3.0.0 - domutils@3.2.2: + domutils@4.0.2: dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 - entities@4.5.0: {} + entities@8.0.0: {} event-target-shim@5.0.1: {} @@ -650,7 +662,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - iconv-lite@0.7.1: + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -684,11 +696,11 @@ snapshots: dependencies: readable-stream: 2.3.8 - lodash@4.17.21: {} + lodash@4.18.1: {} mdn-data@2.0.28: {} - mdn-data@2.12.2: {} + mdn-data@2.27.1: {} minimatch@10.2.5: dependencies: @@ -698,9 +710,9 @@ snapshots: normalize-path@3.0.0: {} - nth-check@2.1.1: + nth-check@3.0.1: dependencies: - boolbase: 1.0.0 + boolbase: 2.0.0 playwright-core@1.60.0: {} @@ -718,7 +730,7 @@ snapshots: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.1 + iconv-lite: 0.7.2 unpipe: 1.0.0 readable-stream@2.3.8: @@ -755,7 +767,7 @@ snapshots: safer-buffer@2.1.2: {} - sax@1.4.3: {} + sax@1.6.0: {} setprototypeof@1.2.0: {} @@ -789,14 +801,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180: - dependencies: - '@trysound/sax': 0.2.0 - css-select: 5.2.2 - css-tree: 3.1.0 - csso: 5.0.5 - lodash: 4.17.21 - tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -835,11 +839,11 @@ snapshots: xml-js@1.6.11: dependencies: - sax: 1.4.3 + sax: 1.6.0 xregexp@5.1.2: dependencies: - '@babel/runtime-corejs3': 7.28.4 + '@babel/runtime-corejs3': 7.29.7 zip-stream@7.0.5: dependencies: diff --git a/exporter/pnpm-workspace.yaml b/exporter/pnpm-workspace.yaml index e18f8f5482..d71aa3ebaf 100644 --- a/exporter/pnpm-workspace.yaml +++ b/exporter/pnpm-workspace.yaml @@ -1,2 +1,9 @@ allowBuilds: core-js-pure: false +minimumReleaseAgeExclude: + - lodash@4.17.24 + - lodash@4.17.23 +overrides: + lodash@<=4.17.23: ^4.17.24 + lodash@>=4.0.0 <=4.17.22: ^4.17.23 + lodash@>=4.0.0 <=4.17.23: ^4.17.24 diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index ebac22349c..7a47e46fe0 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -6,7 +6,7 @@ (ns app.renderer.svg (:require - ["svgo" :as svgo] + ["@penpot/svgo" :as svgo] ["xml-js" :as xml] [app.browser :as bw] [app.common.data :as d] diff --git a/frontend/package.json b/frontend/package.json index 26b3467cea..e4e3ff5a49 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -53,18 +53,18 @@ "@penpot/draft-js": "link:packages/draft-js", "@penpot/mousetrap": "link:packages/mousetrap", "@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime", - "@penpot/svgo": "github:penpot/svgo#v3.2", + "@penpot/svgo": "penpot/svgo#3.3.0", "@penpot/text-editor": "link:text-editor", "@penpot/tokenscript": "link:packages/tokenscript", "@penpot/ui": "link:packages/ui", "@penpot/ua-parser": "penpot/ua-parser#1.0.0", "@playwright/test": "1.60.0", - "@storybook/addon-docs": "10.4.3", - "@storybook/addon-themes": "10.4.3", - "@storybook/addon-vitest": "10.4.3", - "@storybook/react-vite": "10.4.3", + "@storybook/addon-docs": "10.4.4", + "@storybook/addon-themes": "10.4.4", + "@storybook/addon-vitest": "10.4.4", + "@storybook/react-vite": "10.4.4", "@tokens-studio/sd-transforms": "2.0.3", - "@types/node": "^25.9.2", + "@types/node": "^25.9.3", "@vitest/browser": "4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "4.1.8", @@ -73,7 +73,7 @@ "compression": "^1.8.1", "concurrently": "^10.0.3", "date-fns": "^4.4.0", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "eventsource-parser": "^3.1.0", "express": "^5.1.0", "fancy-log": "^2.0.0", @@ -112,7 +112,7 @@ "sax": "^1.6.0", "scheduler": "^0.27.0", "source-map-support": "^0.5.21", - "storybook": "10.4.3", + "storybook": "10.4.4", "style-dictionary": "5.4.4", "stylelint": "^17.13.0", "stylelint-config-standard-scss": "^17.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b86e797358..cd629cd4fd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -35,8 +35,8 @@ importers: specifier: link:../plugins/libs/plugins-runtime version: link:../plugins/libs/plugins-runtime '@penpot/svgo': - specifier: github:penpot/svgo#v3.2 - version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b + specifier: penpot/svgo#3.3.0 + version: https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021 '@penpot/text-editor': specifier: link:text-editor version: link:text-editor @@ -53,22 +53,22 @@ importers: specifier: 1.60.0 version: 1.60.0 '@storybook/addon-docs': - specifier: 10.4.3 - version: 10.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + specifier: 10.4.4 + version: 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) '@storybook/addon-themes': - specifier: 10.4.3 - version: 10.4.3(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) + specifier: 10.4.4 + version: 10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) '@storybook/addon-vitest': - specifier: 10.4.3 - version: 10.4.3(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8)(@vitest/runner@4.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.8) + specifier: 10.4.4 + version: 10.4.4(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8)(@vitest/runner@4.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.8) '@storybook/react-vite': - specifier: 10.4.3 - version: 10.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + specifier: 10.4.4 + version: 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) '@tokens-studio/sd-transforms': specifier: 2.0.3 version: 2.0.3(style-dictionary@5.4.4(tslib@2.8.1)) '@types/node': - specifier: ^25.9.2 + specifier: ^25.9.3 version: 25.9.3 '@vitest/browser': specifier: 4.1.8 @@ -95,14 +95,14 @@ importers: specifier: ^4.4.0 version: 4.4.0 esbuild: - specifier: ^0.28.0 + specifier: ^0.28.1 version: 0.28.1 eventsource-parser: specifier: ^3.1.0 version: 3.1.0 express: specifier: ^5.1.0 - version: 5.2.1 + version: 5.2.1(supports-color@5.5.0) fancy-log: specifier: ^2.0.0 version: 2.0.0 @@ -212,23 +212,23 @@ importers: specifier: ^0.5.21 version: 0.5.21 storybook: - specifier: 10.4.3 - version: 10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 10.4.4 + version: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) style-dictionary: specifier: 5.4.4 version: 5.4.4(tslib@2.8.1) stylelint: specifier: ^17.13.0 - version: 17.13.0(typescript@6.0.3) + version: 17.13.0(supports-color@5.5.0)(typescript@6.0.3) stylelint-config-standard-scss: specifier: ^17.0.0 - version: 17.0.0(postcss@8.5.15)(stylelint@17.13.0(typescript@6.0.3)) + version: 17.0.0(postcss@8.5.15)(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)) stylelint-scss: specifier: ^7.2.0 - version: 7.2.0(stylelint@17.13.0(typescript@6.0.3)) + version: 7.2.0(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)) stylelint-use-logical-spec: specifier: ^5.0.1 - version: 5.0.1(stylelint@17.13.0(typescript@6.0.3)) + version: 5.0.1(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)) svg-sprite: specifier: ^2.0.4 version: 2.0.4 @@ -249,7 +249,7 @@ importers: version: 4.1.8(@types/node@25.9.3)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) wait-on: specifier: ^9.0.4 - version: 9.0.10 + version: 9.0.10(supports-color@5.5.0) watcher: specifier: ^2.3.1 version: 2.3.1 @@ -346,7 +346,7 @@ importers: version: 10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) vite-plugin-dts: specifier: ^5.0.2 - version: 5.0.2(esbuild@0.28.1)(rolldown@1.0.3)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + version: 5.0.2(esbuild@0.28.1)(rolldown@1.0.3)(supports-color@5.5.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) text-editor: devDependencies: @@ -1549,6 +1549,10 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021': + resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021} + version: 3.3.0 + '@penpot/ua-parser@https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4': resolution: {gitHosted: true, integrity: sha512-BxcjiWGtCbGBT+dsOnEODk1jASZLNYp27BuGQaJR7fxU4gLws3251r90Sp9seubcpRhGrfRdsA5WU0ExRdPOgg==, tarball: https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4} version: 1.0.0 @@ -1765,27 +1769,27 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-docs@10.4.3': - resolution: {integrity: sha512-CJGEXSo0zpIy7gvEeeUi09ZbjQUSNDi4YipAeb+lZGGEn8ShZUr2Pk330yd2ZO+ngNWJXD4ZxOb0e3/aIlxb3Q==} + '@storybook/addon-docs@10.4.4': + resolution: {integrity: sha512-yPshCvtmQTq52T2sXuXgjy7B/QbhA/WIZxLYggptNjBL8BJMvbOfp9bAfCKh7+KpRWGqDZ6Y6tWL1Q48Wj3vtw==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.3 + storybook: ^10.4.4 peerDependenciesMeta: '@types/react': optional: true - '@storybook/addon-themes@10.4.3': - resolution: {integrity: sha512-pL73C5h6uyIpeLXjptefjo52Kjhq6ZQh+XgTs8b93ytYy5fkupvQeoGjUnMknTjoIabNPSOhvb0Mcj5OUA+XCw==} + '@storybook/addon-themes@10.4.4': + resolution: {integrity: sha512-VH443z7o/JO5K9QFVuB9IzwaMu0jEiq4ybpzTlAmt0ZUEqNBuM+ESBvkVMkZ5QeNghKrs/J9yvum2g2t94YR4Q==} peerDependencies: - storybook: ^10.4.3 + storybook: ^10.4.4 - '@storybook/addon-vitest@10.4.3': - resolution: {integrity: sha512-np5qbyc/A7bZTvRlap9eaNmp9ix9yBBhMc3ClF4u2NkyI9MNLRH2xh66mI9lsShTyUZ6NAD8Uj72YJXkcigP6w==} + '@storybook/addon-vitest@10.4.4': + resolution: {integrity: sha512-VPpBwf1Elr+0g33am8ZE6aHhLB+r1TPxUsnDuCVNhxGjRxMFyQkAE8+jPJFPvS/YIUGMbVXarzaV7PcI/sJuVQ==} peerDependencies: '@vitest/browser': ^3.0.0 || ^4.0.0 '@vitest/browser-playwright': ^4.0.0 '@vitest/runner': ^3.0.0 || ^4.0.0 - storybook: ^10.4.3 + storybook: ^10.4.4 vitest: ^3.0.0 || ^4.0.0 peerDependenciesMeta: '@vitest/browser': @@ -1803,6 +1807,12 @@ packages: storybook: ^10.4.3 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@storybook/builder-vite@10.4.4': + resolution: {integrity: sha512-VyuZ4mEvhhVXjJa1qXMWKH8ohnas0rgEuJDf6u4aJ54XeENFebPUEAHde1Qo2PflJ4rUdVdXieOZzKbYwP5RAQ==} + peerDependencies: + storybook: ^10.4.4 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@storybook/csf-plugin@10.4.3': resolution: {integrity: sha512-D+XF5CVhZmIOI0uhfTKxlQr+gR1z8X9djPy9phiA1USLPAOHagBAucp/PhLwlFVUxrKzEIf8yImrvkCv50IcDg==} peerDependencies: @@ -1821,6 +1831,24 @@ packages: webpack: optional: true + '@storybook/csf-plugin@10.4.4': + resolution: {integrity: sha512-1mzZyAwVUmAcw4WEUsJDVdSupkJf+Kf/f5uNAs4RzlBXA75P8YRkDKAb2EoMwsB5URiXFi9XoeAN/vWke0G6+w==} + peerDependencies: + esbuild: '*' + rollup: '*' + storybook: ^10.4.4 + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -1844,6 +1872,20 @@ packages: '@types/react-dom': optional: true + '@storybook/react-dom-shim@10.4.4': + resolution: {integrity: sha512-y6SObmoW78AydE6VfKQSUmCkuqiaMPy9LgMpMdMEyWfJ/pSxBDMIKycr9dlRMJP1cvNgByaJgrusWtA46ndSQw==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.4.4 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@storybook/react-vite@10.4.3': resolution: {integrity: sha512-Pk/hi10JFuwJ5sj/HAapWrgaa9Z83oT4MJPbHqeKzMt3A3jkIru4L9ibnt82bzV3crOaiErprvOlAFDsjxhrrQ==} peerDependencies: @@ -1852,6 +1894,14 @@ packages: storybook: ^10.4.3 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@storybook/react-vite@10.4.4': + resolution: {integrity: sha512-hXw1c9Jq2eFzwmJ3u9phmszbHoPjwPLYjcR1Grd6Xbe2g3bReGH35urm/fTZ0HNdjXAgQlUaXp2bWw6vz0BHQw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.4.4 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@storybook/react@10.4.3': resolution: {integrity: sha512-Td+Zoi8ylJTPC1jg5vHw8OK7U2kJgqc5kuAn92UvD4IbAkcpMTBRPHDziK1piv6q7r8yNLVah+ku6IKHpTLeXA==} peerDependencies: @@ -1869,6 +1919,23 @@ packages: typescript: optional: true + '@storybook/react@10.4.4': + resolution: {integrity: sha512-6K5/uHrvjswrueyVpUt6IWGuSgYCMtMOYyVs86XJZYqKBV3Pv7nGsGNH7YSMLAVQBZW4CQqm2etd5Op0GHY9Kg==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.4.4 + typescript: '>= 4.9.x' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + typescript: + optional: true + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1912,10 +1979,6 @@ packages: '@tokens-studio/types@0.5.2': resolution: {integrity: sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==} - '@trysound/sax@0.2.0': - resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} - engines: {node: '>=10.13.0'} - '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -2269,6 +2332,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -2546,8 +2613,9 @@ packages: css-select@4.3.0: resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} css-selector-parser@1.4.1: resolution: {integrity: sha512-HYPSb7y/Z7BNDCOrakL4raGO2zltZkbeXyAd6Tg9obzix6QhzxCotdBl6VT0Dv4vZfJGVz3WL/xaEI9Ly3ul0g==} @@ -2568,6 +2636,10 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -2718,25 +2790,31 @@ packages: dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0: resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0} @@ -2792,10 +2870,6 @@ packages: entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -4015,6 +4089,10 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -4858,6 +4936,21 @@ packages: vite-plus: optional: true + storybook@10.4.4: + resolution: {integrity: sha512-Nn0qFRxU5fyABa6dGRftfL3lz0Y+HkKOaAkfytF8S4Q2K6Szwwq7TwPAEs3Wsj8hBQbYhsobrKADcPsyXQpJaA==} + hasBin: true + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + prettier: ^2 || ^3 + vite-plus: ^0.1.15 + peerDependenciesMeta: + '@types/react': + optional: true + prettier: + optional: true + vite-plus: + optional: true + stream@0.0.3: resolution: {integrity: sha512-aMsbn7VKrl4A2T7QAQQbzgN7NVc70vgF5INQrBXqn4dCXN1zy3L9HGgLO5s7PExmdrzTJ8uR/27aviW8or8/+A==} @@ -5035,11 +5128,6 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b: - resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b} - version: 4.0.0 - engines: {node: '>=16.0.0'} - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -6519,6 +6607,14 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true + '@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021': + dependencies: + css-select: 7.0.0 + css-tree: 3.2.1 + csso: 5.0.5 + lodash: 4.18.1 + sax: 1.6.0 + '@penpot/ua-parser@https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4': {} '@pkgjs/parseargs@0.11.0': @@ -6649,15 +6745,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + '@storybook/addon-docs@10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.7) - '@storybook/csf-plugin': 10.4.3(esbuild@0.28.1)(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + '@storybook/csf-plugin': 10.4.4(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@storybook/react-dom-shim': 10.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) + '@storybook/react-dom-shim': 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - storybook: 10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) ts-dedent: 2.3.0 optionalDependencies: '@types/react': 19.2.17 @@ -6668,16 +6764,16 @@ snapshots: - vite - webpack - '@storybook/addon-themes@10.4.3(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))': + '@storybook/addon-themes@10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))': dependencies: - storybook: 10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) ts-dedent: 2.3.0 - '@storybook/addon-vitest@10.4.3(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8)(@vitest/runner@4.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.8)': + '@storybook/addon-vitest@10.4.4(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8)(@vitest/runner@4.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.8)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - storybook: 10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) optionalDependencies: '@vitest/browser': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.8) '@vitest/browser-playwright': 4.1.8(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.8) @@ -6698,6 +6794,17 @@ snapshots: - rollup - webpack + '@storybook/builder-vite@10.4.4(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + dependencies: + '@storybook/csf-plugin': 10.4.4(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + ts-dedent: 2.3.0 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + transitivePeerDependencies: + - esbuild + - rollup + - webpack + '@storybook/csf-plugin@10.4.3(esbuild@0.28.1)(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': dependencies: storybook: 10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -6706,6 +6813,14 @@ snapshots: esbuild: 0.28.1 vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + '@storybook/csf-plugin@10.4.4(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + dependencies: + storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.28.1 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + '@storybook/global@5.0.0': {} '@storybook/icons@2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': @@ -6722,6 +6837,15 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@storybook/react-dom-shim@10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))': + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@storybook/react-vite@10.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) @@ -6746,6 +6870,30 @@ snapshots: - typescript - webpack + '@storybook/react-vite@10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + '@rollup/pluginutils': 5.4.0 + '@storybook/builder-vite': 10.4.4(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + '@storybook/react': 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3) + empathic: 2.0.1 + magic-string: 0.30.21 + react: 19.2.7 + react-docgen: 8.0.3 + react-dom: 19.2.7(react@19.2.7) + resolve: 1.22.12 + storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tsconfig-paths: 4.2.0 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - esbuild + - rollup + - supports-color + - typescript + - webpack + '@storybook/react@10.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.3(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 @@ -6762,6 +6910,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@storybook/react@10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) + react: 19.2.7 + react-docgen: 8.0.3 + react-docgen-typescript: 2.4.0(typescript@6.0.3) + react-dom: 19.2.7(react@19.2.7) + storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -6815,8 +6979,6 @@ snapshots: '@tokens-studio/types@0.5.2': {} - '@trysound/sax@0.2.0': {} - '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -7036,7 +7198,7 @@ snapshots: acorn@8.17.0: {} - agent-base@6.0.2: + agent-base@6.0.2(supports-color@5.5.0): dependencies: debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: @@ -7209,11 +7371,11 @@ snapshots: axe-core@4.12.1: {} - axios@1.17.0: + axios@1.17.0(supports-color@5.5.0): dependencies: follow-redirects: 1.16.0 form-data: 4.0.5 - https-proxy-agent: 5.0.1 + https-proxy-agent: 5.0.1(supports-color@5.5.0) proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -7247,7 +7409,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@2.2.2: + body-parser@2.2.2(supports-color@5.5.0): dependencies: bytes: 3.1.2 content-type: 1.0.5 @@ -7263,6 +7425,8 @@ snapshots: boolbase@1.0.0: {} + boolbase@2.0.0: {} + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -7555,13 +7719,13 @@ snapshots: domutils: 2.8.0 nth-check: 2.1.1 - css-select@5.2.2: + css-select@7.0.0: dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 css-selector-parser@1.4.1: {} @@ -7582,6 +7746,8 @@ snapshots: css-what@6.2.2: {} + css-what@8.0.0: {} + css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -7709,21 +7875,23 @@ snapshots: domhandler: 4.3.1 entities: 2.2.0 - dom-serializer@2.0.0: + dom-serializer@3.1.1: dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 domelementtype@2.3.0: {} + domelementtype@3.0.0: {} + domhandler@4.3.1: dependencies: domelementtype: 2.3.0 - domhandler@5.0.3: + domhandler@6.0.1: dependencies: - domelementtype: 2.3.0 + domelementtype: 3.0.0 domutils@2.8.0: dependencies: @@ -7731,11 +7899,11 @@ snapshots: domelementtype: 2.3.0 domhandler: 4.3.1 - domutils@3.2.2: + domutils@4.0.2: dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: @@ -7788,8 +7956,6 @@ snapshots: entities@2.2.0: {} - entities@4.5.0: {} - entities@8.0.0: {} env-paths@2.2.1: {} @@ -8149,10 +8315,10 @@ snapshots: expr-eval-fork@3.0.3: {} - express@5.2.1: + express@5.2.1(supports-color@5.5.0): dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.2.2(supports-color@5.5.0) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -8162,7 +8328,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@5.5.0) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -8173,8 +8339,8 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.2 range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 + router: 2.2.0(supports-color@5.5.0) + send: 1.2.1(supports-color@5.5.0) serve-static: 2.2.1 statuses: 2.0.2 type-is: 2.1.0 @@ -8244,7 +8410,7 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@5.5.0): dependencies: debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 @@ -8501,9 +8667,9 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - https-proxy-agent@5.0.1: + https-proxy-agent@5.0.1(supports-color@5.5.0): dependencies: - agent-base: 6.0.2 + agent-base: 6.0.2(supports-color@5.5.0) debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -9155,6 +9321,10 @@ snapshots: dependencies: boolbase: 1.0.0 + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -9736,7 +9906,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 - router@2.2.0: + router@2.2.0(supports-color@5.5.0): dependencies: debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 @@ -9903,7 +10073,7 @@ snapshots: semver@7.8.4: {} - send@1.2.1: + send@1.2.1(supports-color@5.5.0): dependencies: debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 @@ -9924,7 +10094,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -10091,6 +10261,33 @@ snapshots: - react-dom - utf-8-validate + storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@storybook/global': 5.0.0 + '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/expect': 3.2.4 + '@vitest/spy': 3.2.4 + '@webcontainer/env': 1.1.1 + esbuild: 0.27.7 + open: 10.2.0 + oxc-parser: 0.127.0 + oxc-resolver: 11.20.0 + recast: 0.23.11 + semver: 7.8.4 + use-sync-external-store: 1.6.0(react@19.2.7) + ws: 8.21.0 + optionalDependencies: + '@types/react': 19.2.17 + prettier: 3.8.4 + transitivePeerDependencies: + - '@testing-library/dom' + - bufferutil + - react + - react-dom + - utf-8-validate + stream@0.0.3: dependencies: component-emitter: 2.0.0 @@ -10226,33 +10423,33 @@ snapshots: transitivePeerDependencies: - tslib - stylelint-config-recommended-scss@17.0.1(postcss@8.5.15)(stylelint@17.13.0(typescript@6.0.3)): + stylelint-config-recommended-scss@17.0.1(postcss@8.5.15)(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)): dependencies: postcss-scss: 4.0.9(postcss@8.5.15) - stylelint: 17.13.0(typescript@6.0.3) - stylelint-config-recommended: 18.0.0(stylelint@17.13.0(typescript@6.0.3)) - stylelint-scss: 7.2.0(stylelint@17.13.0(typescript@6.0.3)) + stylelint: 17.13.0(supports-color@5.5.0)(typescript@6.0.3) + stylelint-config-recommended: 18.0.0(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)) + stylelint-scss: 7.2.0(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)) optionalDependencies: postcss: 8.5.15 - stylelint-config-recommended@18.0.0(stylelint@17.13.0(typescript@6.0.3)): + stylelint-config-recommended@18.0.0(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - stylelint: 17.13.0(typescript@6.0.3) + stylelint: 17.13.0(supports-color@5.5.0)(typescript@6.0.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.15)(stylelint@17.13.0(typescript@6.0.3)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.15)(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - stylelint: 17.13.0(typescript@6.0.3) - stylelint-config-recommended-scss: 17.0.1(postcss@8.5.15)(stylelint@17.13.0(typescript@6.0.3)) - stylelint-config-standard: 40.0.0(stylelint@17.13.0(typescript@6.0.3)) + stylelint: 17.13.0(supports-color@5.5.0)(typescript@6.0.3) + stylelint-config-recommended-scss: 17.0.1(postcss@8.5.15)(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)) + stylelint-config-standard: 40.0.0(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)) optionalDependencies: postcss: 8.5.15 - stylelint-config-standard@40.0.0(stylelint@17.13.0(typescript@6.0.3)): + stylelint-config-standard@40.0.0(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - stylelint: 17.13.0(typescript@6.0.3) - stylelint-config-recommended: 18.0.0(stylelint@17.13.0(typescript@6.0.3)) + stylelint: 17.13.0(supports-color@5.5.0)(typescript@6.0.3) + stylelint-config-recommended: 18.0.0(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)) - stylelint-scss@7.2.0(stylelint@17.13.0(typescript@6.0.3)): + stylelint-scss@7.2.0(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)): dependencies: '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) @@ -10265,13 +10462,13 @@ snapshots: postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 7.1.4 postcss-value-parser: 4.2.0 - stylelint: 17.13.0(typescript@6.0.3) + stylelint: 17.13.0(supports-color@5.5.0)(typescript@6.0.3) - stylelint-use-logical-spec@5.0.1(stylelint@17.13.0(typescript@6.0.3)): + stylelint-use-logical-spec@5.0.1(stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - stylelint: 17.13.0(typescript@6.0.3) + stylelint: 17.13.0(supports-color@5.5.0)(typescript@6.0.3) - stylelint@17.13.0(typescript@6.0.3): + stylelint@17.13.0(supports-color@5.5.0)(typescript@6.0.3): dependencies: '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) @@ -10365,14 +10562,6 @@ snapshots: sax: 1.6.0 stable: 0.1.8 - svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b: - dependencies: - '@trysound/sax': 0.2.0 - css-select: 5.2.2 - css-tree: 3.2.1 - csso: 5.0.5 - lodash: 4.18.1 - symbol-tree@3.2.4: {} sync-child-process@1.0.2: @@ -10558,7 +10747,7 @@ snapshots: unpipe@1.0.0: {} - unplugin-dts@1.0.2(esbuild@0.28.1)(rolldown@1.0.3)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)): + unplugin-dts@1.0.2(esbuild@0.28.1)(rolldown@1.0.3)(supports-color@5.5.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)): dependencies: '@rollup/pluginutils': 5.4.0 '@volar/typescript': 2.4.28 @@ -10634,9 +10823,9 @@ snapshots: remove-trailing-separator: 1.1.0 replace-ext: 1.0.1 - vite-plugin-dts@5.0.2(esbuild@0.28.1)(rolldown@1.0.3)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)): + vite-plugin-dts@5.0.2(esbuild@0.28.1)(rolldown@1.0.3)(supports-color@5.5.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)): dependencies: - unplugin-dts: 1.0.2(esbuild@0.28.1)(rolldown@1.0.3)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + unplugin-dts: 1.0.2(esbuild@0.28.1)(rolldown@1.0.3)(supports-color@5.5.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) optionalDependencies: vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) transitivePeerDependencies: @@ -10699,9 +10888,9 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wait-on@9.0.10: + wait-on@9.0.10(supports-color@5.5.0): dependencies: - axios: 1.17.0 + axios: 1.17.0(supports-color@5.5.0) joi: 18.2.1 lodash: 4.18.1 minimist: 1.2.8 diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index 2ed86cf968..77f5d55013 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -9,6 +9,7 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.files.changes-builder :as pcb] + [app.common.logging :as log] [app.common.time :as ct] [app.main.data.changes :as dch] [app.main.data.event :as ev] @@ -57,45 +58,57 @@ (defn start-plugin! [{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions] - (-> (.ɵloadPlugin - ^js ug/global - #js {:pluginId plugin-id - :name name - :version version - :description description - :host host - :code code - :allowBackground (boolean allow-background) - :permissions (apply array permissions)} - nil - extensions) + (let [load-plugin (unchecked-get ug/global "ɵloadPlugin")] + (if (fn? load-plugin) + (-> (load-plugin + #js {:pluginId plugin-id + :name name + :version version + :description description + :host host + :code code + :allowBackground (boolean allow-background) + :permissions (apply array permissions)} + nil + extensions) - (p/catch (fn [cause] - (ex/print-throwable cause :prefix "Plugin Error") - (errors/flash :cause cause :type :handled))))) + (p/catch (fn [cause] + (ex/print-throwable cause :prefix "Plugin Error") + (errors/flash :cause cause :type :handled)))) + + (log/warn :hint "Plugin runtime not initialized yet" + :plugin-id plugin-id + :action "start-plugin!")))) (defn- load-plugin! [{:keys [plugin-id name version description host code icon permissions]}] (st/emit! (pflag/clear plugin-id) (save-current-plugin plugin-id)) - (-> (.ɵloadPlugin - ^js ug/global - #js {:pluginId plugin-id - :name name - :description description - :version version - :host host - :code code - :icon icon - :permissions (apply array permissions)} - (fn [] - (st/emit! (remove-current-plugin plugin-id)))) + (let [load-plugin (unchecked-get ug/global "ɵloadPlugin")] + (if (fn? load-plugin) + (-> (load-plugin + #js {:pluginId plugin-id + :name name + :description description + :version version + :host host + :code code + :icon icon + :permissions (apply array permissions)} + (fn [] + (st/emit! (remove-current-plugin plugin-id)))) - (p/catch (fn [cause] - (st/emit! (remove-current-plugin plugin-id)) - (ex/print-throwable cause :prefix "Plugin Error") - (errors/flash :cause cause :type :handled))))) + (p/catch (fn [cause] + (st/emit! (remove-current-plugin plugin-id)) + (ex/print-throwable cause :prefix "Plugin Error") + (errors/flash :cause cause :type :handled)))) + + (do + (log/warn :hint "Plugin runtime not initialized yet" + :plugin-id plugin-id + :action "load-plugin!") + (st/emit! (remove-current-plugin plugin-id)))))) (defn open-plugin! [{:keys [url] :as manifest} user-can-edit?] @@ -135,10 +148,15 @@ (defn close-plugin! [{:keys [plugin-id]}] - (try - (.ɵunloadPlugin ^js ug/global plugin-id) - (catch :default e - (.error js/console "Error" e)))) + (let [unload-plugin (unchecked-get ug/global "ɵunloadPlugin")] + (if (fn? unload-plugin) + (try + (unload-plugin plugin-id) + (catch :default e + (.error js/console "Error" e))) + (log/warn :hint "Plugin runtime not initialized yet" + :plugin-id plugin-id + :action "close-plugin!")))) (defn close-current-plugin [& {:keys [close-only-edition-plugins?]}] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index cdf9149b97..16115314b0 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -504,8 +504,7 @@ (rx/of (dwu/append-undo entry stack-undo?))) (rx/empty)))))) - (rx/take-until stoper-s)) - (rx/of (mcp/notify-other-tabs-disconnect))))) + (rx/take-until stoper-s))))) ptk/EffectEvent (effect [_ _ _] diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 7f0b0e3363..f2c3c069db 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -10,13 +10,10 @@ [app.common.uri :as u] [app.config :as cf] [app.main.broadcast :as mbc] - [app.main.data.event :as ev] - [app.main.data.notifications :as ntf] [app.main.data.plugins :as dp] [app.main.repo :as rp] [app.main.store :as st] - [app.plugins.register :refer [mcp-plugin-id]] - [app.util.i18n :refer [tr]] + [app.plugins.register :as preg] [app.util.timers :as ts] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -29,7 +26,7 @@ {:code "plugin.js" :name "Penpot MCP Plugin" :version 2 - :plugin-id mcp-plugin-id + :plugin-id preg/mcp-plugin-id :description "This plugin enables interaction with the Penpot MCP server" :allow-background true :permissions @@ -39,6 +36,14 @@ (defonce interval-sub (atom nil)) +(defn connect-mcp + [] + (ptk/reify ::connect-mcp + ptk/WatchEvent + (watch [_ _ _] + (rx/of (mbc/event :mcp/force-disconnect {}) + (ptk/data-event ::connect))))) + (defn finalize-workspace? [event] (= (ptk/type event) :app.main.data.workspace/finalize-workspace)) @@ -72,45 +77,6 @@ (rx/dispose! @interval-sub) (reset! interval-sub nil))) -(declare manage-mcp-notification) - -(defn handle-pong - [{:keys [id data]}] - (ptk/reify ::handle-pong - ptk/UpdateEvent - (update [_ state] - (let [mcp-state (get state :mcp)] - (cond - (= "connected" (:connection-status data)) - (update state :mcp assoc :connected-tab id) - - (and (= "disconnected" (:connection-status data)) - (= id (:connected-tab mcp-state))) - (update state :mcp dissoc :connected-tab) - - :else - state))) - - ptk/WatchEvent - (watch [_ _ _] - (rx/of (manage-mcp-notification))))) - -;; This event will arrive when a new workspace is open in another tab -(defn handle-ping - [] - (ptk/reify ::handle-ping - ptk/WatchEvent - (watch [_ state _] - (let [conn-status (get-in state [:mcp :connection-status])] - (rx/of (mbc/event :mcp/pong {:connection-status conn-status})))))) - -(defn notify-other-tabs-disconnect - [] - (ptk/reify ::notify-other-tabs-disconnect - ptk/WatchEvent - (watch [_ _ _] - (rx/of (mbc/event :mcp/pong {:connection-status "disconnected"}))))) - ;; This event will arrive when the mcp is enabled in the dashboard (defn update-mcp-status [value] @@ -121,12 +87,10 @@ ptk/WatchEvent (watch [_ _ _] - (rx/merge - (rx/of (manage-mcp-notification)) - (case value - true (rx/of (ptk/data-event ::connect)) - false (rx/of (ptk/data-event ::disconnect)) - nil))))) + (case value + true (rx/of (connect-mcp)) + false (rx/of (ptk/data-event ::disconnect)) + nil)))) (defn update-mcp-connection-status [value] @@ -137,20 +101,13 @@ ptk/WatchEvent (watch [_ _ _] - (rx/of (manage-mcp-notification) - (mbc/event :mcp/pong {:connection-status value}))))) - -(defn connect-mcp - [] - (ptk/reify ::connect-mcp - ptk/UpdateEvent - (update [_ state] - (update state :mcp assoc :connected-tab (:session-id state))) - - ptk/WatchEvent - (watch [_ _ _] - (rx/of (mbc/event :mcp/force-disconect {}) - (ptk/data-event ::connect))))) + ;; Only one MCP plugin instance may be active across browser tabs. + ;; When this tab becomes connected, tell every other tab to + ;; disconnect (which also stops their reconnect watcher). Otherwise + ;; several tabs stay connected at once and the MCP server reports + ;; "multiple instances connected" and the agent fails. + (when (= "connected" value) + (rx/of (mbc/event :mcp/force-disconnect {})))))) ;; This event will arrive when the user selects disconnect on the menu ;; or there is a broadcast message for disconnection @@ -166,77 +123,54 @@ (effect [_ _ _] (stop-reconnect-watcher!)))) -(defn- manage-mcp-notification - [] - (ptk/reify ::manage-mcp-notification - ptk/WatchEvent - (watch [_ state _] - (let [mcp-state (get state :mcp) - - mcp-enabled? (-> state :profile :props :mcp-enabled) - - current-tab-id (get state :session-id) - connected-tab-id (get mcp-state :connected-tab)] - - (if mcp-enabled? - (if (= connected-tab-id current-tab-id) - (rx/of (ntf/hide)) - (rx/of (ntf/dialog - {:content (tr "notifications.mcp.active-in-another-tab") - :cancel {:label (tr "labels.dismiss") - :callback #(st/emit! (ntf/hide) - (ev/event {::ev/name "dismiss-mcp-tab-switch" - ::ev/origin "workspace-notification"}))} - :accept {:label (tr "labels.switch") - :callback #(st/emit! (connect-mcp) - (ev/event {::ev/name "confirm-mcp-tab-switch" - ::ev/origin "workspace-notification"}))}}))) - (rx/of (ntf/hide))))))) - (defn init-mcp [stream] - (->> (rp/cmd! :get-current-mcp-token) - (rx/tap - (fn [{:keys [token]}] - (when token - (dp/start-plugin! - (assoc default-manifest - :url (str (u/join cf/public-uri "plugins/mcp/manifest.json")) - :host (str (u/join cf/public-uri "plugins/mcp/"))) + ;; Wait for plugins runtime to be initialized before starting the MCP plugin. + ;; This ensures global.ɵloadPlugin is available when start-plugin! is called. + (->> (rx/from (preg/wait-for-runtime)) + (rx/mapcat + (fn [_] + (->> (rp/cmd! :get-current-mcp-token) + (rx/tap + (fn [{:keys [token]}] + (when token + (dp/start-plugin! + (assoc default-manifest + :url (str (u/join cf/public-uri "plugins/mcp/manifest.json")) + :host (str (u/join cf/public-uri "plugins/mcp/"))) - ;; API extension for MCP server - #js {:mcp - #js - {:getToken (constantly token) - :getServerUrl #(str cf/mcp-ws-uri) - :setMcpStatus - (fn [status] - (when (= status "connected") - (start-reconnect-watcher!)) - (st/emit! (update-mcp-connection-status status)) - (log/info :hint "MCP STATUS" :status status)) + ;; API extension for MCP server + #js {:mcp + #js + {:getToken (constantly token) + :getServerUrl #(str cf/mcp-ws-uri) + :setMcpStatus + (fn [status] + (when (= status "connected") + (start-reconnect-watcher!)) + (st/emit! (update-mcp-connection-status status)) + (log/info :hint "MCP STATUS" :status status)) - :on - (fn [event cb] - (when-let [event - (case event - "disconnect" ::disconnect - "connect" ::connect - nil)] + :on + (fn [event cb] + (when-let [event + (case event + "disconnect" ::disconnect + "connect" ::connect + nil)] - (let [stopper (rx/filter finalize-workspace? stream)] - (->> stream - (rx/filter (ptk/type? event)) - (rx/take-until stopper) - (rx/subs! #(cb))))))}})))) - (rx/ignore))) + (let [stopper (rx/filter finalize-workspace? stream)] + (->> stream + (rx/filter (ptk/type? event)) + (rx/take-until stopper) + (rx/subs! #(cb))))))}}))))))))) (defn init [] (ptk/reify ::init ptk/UpdateEvent (update [_ state] - (update state :mcp assoc :connected-tab (:session-id state) :active true)) + (update state :mcp assoc :active true)) ptk/WatchEvent (watch [_ state stream] @@ -251,22 +185,8 @@ (rx/merge (init-mcp stream) - (rx/of (mbc/event :mcp/ping {})) - (->> mbc/stream - (rx/filter (mbc/type? :mcp/ping)) - (rx/filter (fn [{:keys [id]}] - (not= session-id id))) - (rx/map handle-ping)) - - (->> mbc/stream - (rx/filter (mbc/type? :mcp/pong)) - (rx/filter (fn [{:keys [id]}] - (not= session-id id))) - (rx/map handle-pong)) - - (->> mbc/stream - (rx/filter (mbc/type? :mcp/force-disconect)) + (rx/filter (mbc/type? :mcp/force-disconnect)) (rx/filter (fn [{:keys [id]}] (not= session-id id))) (rx/map deref) @@ -276,9 +196,9 @@ (->> mbc/stream (rx/filter (mbc/type? :mcp/enable)) (rx/mapcat (fn [_] - ;; NOTE: we don't need an explicit - ;; connect because the plugin has - ;; auto-connect + ;; Re-init so the force-disconnect + ;; listener is set up now that MCP + ;; is enabled. (rx/of (update-mcp-status true) (init))))) @@ -286,7 +206,6 @@ (rx/filter (mbc/type? :mcp/disable)) (rx/mapcat (fn [_] (rx/of (update-mcp-status false) - (init) (user-disconnect-mcp)))))) (rx/take-until stoper-s)))))) diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs index 80c83f6153..3770a24a4a 100644 --- a/frontend/src/app/main/data/workspace/thumbnails.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails.cljs @@ -85,40 +85,70 @@ (let [request (create-request file-id page-id shape-id tag)] (q/enqueue-unique queue request (partial render-thumbnail state file-id page-id shape-id tag)))) +(defn- clear-thumbnail-batch + [] + (let [pending (volatile! nil)] + (ptk/reify ::clear-thumbnail-batch + ptk/UpdateEvent + (update [_ state] + (let [items (get state ::thumbnails-deletion-queue)] + (when (seq items) + (vreset! pending items)) + (dissoc state ::thumbnails-deletion-queue))) + + ptk/WatchEvent + (watch [_ _ _] + (let [items (reduce-kv (fn [acc object-id uri] + (when (str/starts-with? uri "blob:") + (tm/schedule-on-idle (partial wapi/revoke-uri uri))) + (conj acc object-id)) + [] + @pending)] + (l/dbg :hint "clear-thumbnail-batch" :total (count items)) + (->> (rx/from (partition-all 200 items)) + (rx/mapcat + (fn [batch] + (l/dbg :hint "clear-thumbnail-batch" :batch-size (count batch)) + (->> (rp/cmd! :delete-file-object-thumbnails + {:object-ids (vec batch)}) + (rx/catch rx/empty) + (rx/ignore)))))))))) + +(defn remove-from-deletion-queue + "Removes an object-id from the pending deletion queue in state. + Used by update-thumbnail to cancel a pending batched delete before + creating a new thumbnail for the same object." + [object-id] + (ptk/reify ::remove-from-deletion-queue + ptk/UpdateEvent + (update [_ state] + (update state ::thumbnails-deletion-queue dissoc object-id)))) + (defn clear-thumbnail ([file-id page-id frame-id tag] (clear-thumbnail file-id (thc/fmt-object-id file-id page-id frame-id tag))) - ([file-id object-id] - (let [pending (volatile! false)] - (ptk/reify ::clear-thumbnail - cljs.core/IDeref - (-deref [_] object-id) + ([_file-id object-id] + (ptk/reify ::clear-thumbnail + cljs.core/IDeref + (-deref [_] object-id) - ptk/UpdateEvent - (update [_ state] + ptk/UpdateEvent + (update [_ state] + (let [uri (dm/get-in state [:thumbnails object-id])] + (l/dbg :hint "clear-thumbnail" :object-id object-id :uri uri) (-> state - (update :thumbnails - (fn [thumbs] - (if-let [uri (get thumbs object-id)] - (do (vreset! pending uri) - (dissoc thumbs object-id)) - thumbs))) - (update :thumbnails-meta dissoc object-id))) + (update ::thumbnails-deletion-queue assoc object-id uri) + (update :thumbnails dissoc object-id) + (update :thumbnails-meta dissoc object-id)))) - ptk/WatchEvent - (watch [_ _ _] - (if-let [uri @pending] - (do - (l/trc :hint "clear-thumbnail" :uri uri) - (when (str/starts-with? uri "blob:") - (tm/schedule-on-idle (partial wapi/revoke-uri uri))) - - (let [params {:file-id file-id - :object-id object-id}] - (->> (rp/cmd! :delete-file-object-thumbnail params) - (rx/catch rx/empty) - (rx/ignore)))) - (rx/empty))))))) + ptk/WatchEvent + (watch [_ _ stream] + (let [stopper-s (->> stream + (rx/filter (ptk/type? ::clear-thumbnail)))] + (->> (rx/timer 200) + (rx/take 1) + (rx/map (fn [_] (clear-thumbnail-batch))) + (rx/take-until stopper-s))))))) (defn assoc-thumbnail [object-id uri] @@ -173,7 +203,8 @@ :tag (or tag "frame")}] (rx/merge - (rx/of (assoc-thumbnail object-id uri)) + (rx/of (assoc-thumbnail object-id uri) + (remove-from-deletion-queue object-id)) (->> (rp/cmd! :create-file-object-thumbnail params) (rx/catch rx/empty) (rx/ignore)))))) @@ -305,6 +336,14 @@ ;; and interrupt any ongoing update-thumbnail process ;; related to current frame-id (->> all-commits-s + ;; Ensure each clear-thumbnail event is dispatched in its + ;; own macrotask tick. Without this, multiple changes + ;; arriving on the same synchronous tick would emit + ;; several clear-thumbnail events back-to-back, causing + ;; their debounce timers (rx/take-until stopper-s) to + ;; race and potentially leave multiple clear-thumbnail-batch + ;; timers alive simultaneously. + (rx/observe-on :async) (rx/mapcat (fn [[tag frame-id]] (rx/of (clear-thumbnail file-id page-id frame-id tag))))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 791520efd9..771bbcd04f 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cph] + [app.common.time :as ct] [app.common.types.shape-tree :as ctt] [app.common.types.shape.layout :as ctl] [app.common.types.tokens-lib :as ctob] @@ -155,6 +156,14 @@ (def mcp (l/derived :mcp st/state)) +(def mcp-key-expired? + (l/derived (fn [state] + (when-let [expires-at (some->> (:access-tokens state) + (some #(when (= (:type %) "mcp") %)) + :expires-at)] + (> (ct/now) expires-at))) + st/state)) + (def workspace-drawing (l/derived :workspace-drawing st/state)) diff --git a/frontend/src/app/main/ui/error_boundary.cljs b/frontend/src/app/main/ui/error_boundary.cljs index 60b42b45c0..226b87369b 100644 --- a/frontend/src/app/main/ui/error_boundary.cljs +++ b/frontend/src/app/main/ui/error_boundary.cljs @@ -37,8 +37,20 @@ ;; If the error is a stale-asset error (cross-build ;; module mismatch), force a hard page reload instead ;; of showing the error page to the user. - (if (errors/stale-asset-error? error) + (cond + (errors/stale-asset-error? error) (cf/throttled-reload :reason (ex-message error)) + + ;; If the error is known to be harmless (browser + ;; extensions, React DOM conflicts, etc.), ignore it + ;; silently — the global uncaught-error-handler + ;; already does this, but react-error-boundary's + ;; onError fires independently of the window.onerror + ;; pipeline, so we must also filter here. + (errors/is-ignorable-exception? error) + nil + + :else (do (set! errors/last-exception error) (ex/print-throwable error) diff --git a/frontend/src/app/main/ui/notifications/context_notification.cljs b/frontend/src/app/main/ui/notifications/context_notification.cljs index e66c16592c..2c07d5218d 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.cljs +++ b/frontend/src/app/main/ui/notifications/context_notification.cljs @@ -54,16 +54,15 @@ ;; The content can arrive in markdown format, in these cases ;; we will use the prop is-html to true to indicate it and ;; that the html injection is performed and the necessary css classes are applied. - [:div {:class (stl/css :context-text) - :dangerouslySetInnerHTML (when is-html #js {:__html content})} - (when-not is-html - [:* - content - (when (some? links) - (for [[index link] (d/enumerate links)] - ;; TODO Review this component - [:> lb/link-button* {:class (stl/css :link) - :on-click (:callback link) - :value (:label link) - :key (dm/str "link-" index)}]))])]]) - + (if is-html + [:div {:class (stl/css :context-text) + :dangerouslySetInnerHTML #js {:__html content}}] + [:div {:class (stl/css :context-text)} + content + (when (some? links) + (for [[index link] (d/enumerate links)] + ;; TODO Review this component + [:> lb/link-button* {:class (stl/css :link) + :on-click (:callback link) + :value (:label link) + :key (dm/str "link-" index)}]))])]) diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs index 50b5bd57bd..e7d51438bc 100644 --- a/frontend/src/app/main/ui/settings/integrations.cljs +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -406,18 +406,16 @@ (mf/defc mcp-server-section* {::mf/private true} [] - (let [tokens (mf/deref refs/access-tokens) - profile (mf/deref refs/profile) + (let [tokens (mf/deref refs/access-tokens) + profile (mf/deref refs/profile) + mcp-key-expired? (mf/deref refs/mcp-key-expired?) mcp-key (some #(when (= (:type %) "mcp") %) tokens) mcp-token (:token mcp-key "") mcp-url (dm/str cf/mcp-server-url "?userToken=" mcp-token) mcp-enabled? (true? (-> profile :props :mcp-enabled)) - expires-at (:expires-at mcp-key) - expired? (and (some? expires-at) (> (ct/now) expires-at)) - - show-enabled? (and mcp-enabled? (false? expired?)) + show-enabled? (and mcp-enabled? (false? mcp-key-expired?)) tooltip-id (mf/use-id) @@ -494,7 +492,7 @@ (tr "integrations.mcp-server.status")] [:div {:class (stl/css :mcp-server-block)} - (when expired? + (when mcp-key-expired? [:> notification-pill* {:level :error :type :context} [:div {:class (stl/css :mcp-server-notification)} @@ -517,7 +515,7 @@ (when (and (false? mcp-enabled?) (nil? mcp-key)) [:div {:class (stl/css :mcp-server-switch-cover) :on-click handle-generate-mcp-key}]) - (when (true? expired?) + (when (true? mcp-key-expired?) [:div {:class (stl/css :mcp-server-switch-cover) :on-click handle-regenerate-mcp-key}])]]] diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 20a50c729a..53d51a7970 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -10,7 +10,6 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] - [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.common :as dcm] @@ -791,20 +790,15 @@ [{:keys [on-close]}] (let [plugins? (features/active-feature? @st/state "plugins/runtime") - profile (mf/deref refs/profile) - mcp (mf/deref refs/mcp) - tokens (mf/deref refs/access-tokens) - - expires-at (some->> tokens - (some #(when (= (:type %) "mcp") %)) - :expires-at) - expired? (and (some? expires-at) (> (ct/now) expires-at)) + profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) + mcp-key-expired? (mf/deref refs/mcp-key-expired?) mcp-enabled? (true? (-> profile :props :mcp-enabled)) mcp-connection (get mcp :connection-status) mcp-connected? (= mcp-connection "connected") - show-enabled? (and mcp-enabled? (false? expired?)) + show-enabled? (and mcp-enabled? (false? mcp-key-expired?)) on-nav-to-integrations (mf/use-fn @@ -843,7 +837,7 @@ :pos-6 plugins?) :on-close on-close} - (when (and show-enabled? (not expired?)) + (when (and show-enabled? (not mcp-key-expired?)) [:> dropdown-menu-item* {:id "mcp-menu-toggle-mcp-plugin" :class (stl/css :base-menu-item :submenu-item) :on-click on-toggle-mcp-plugin @@ -865,7 +859,6 @@ (mf/defc menu* [{:keys [layout file]}] (let [profile (mf/deref refs/profile) - mcp (mf/deref refs/mcp) show-menu* (mf/use-state false) show-menu? (deref show-menu*) @@ -1062,11 +1055,8 @@ :class (stl/css :item-arrow)}]]) (when (contains? cf/flags :mcp) - (let [tokens (mf/deref refs/access-tokens) - expires-at (some->> tokens - (some #(when (= (:type %) "mcp") %)) - :expires-at) - expired? (and (some? expires-at) (> (ct/now) expires-at)) + (let [mcp (mf/deref refs/mcp) + mcp-key-expired? (mf/deref refs/mcp-key-expired?) mcp-enabled? (true? (-> profile :props :mcp-enabled)) mcp-connection (get mcp :connection-status) @@ -1075,7 +1065,7 @@ active? (and mcp-enabled? mcp-connected?) failed? (or (and mcp-enabled? mcp-error?) - (true? expired?))] + (true? mcp-key-expired?))] [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 53dbf65e47..eb9675beae 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -8,15 +8,18 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.geom.point :as gpt] + [app.config :as cf] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.shortcuts :as sc] [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.context :as ctx] [app.main.ui.icons :as deprecated-icon] @@ -26,6 +29,61 @@ [okulary.core :as l] [rumext.v2 :as mf])) +(mf/defc mcp-indicator* + [] + (let [profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) + mcp-key-expired? (mf/deref refs/mcp-key-expired?) + + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + mcp-connected? (= "connected" (:connection-status mcp)) + show-indicator? (and mcp-enabled? (false? mcp-key-expired?)) + + mcp-menu-open* (mf/use-state false) + mcp-menu-open? (deref mcp-menu-open*) + + toggle-mcp-menu + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! mcp-menu-open* not))) + + close-mcp-menu + (mf/use-fn + #(reset! mcp-menu-open* false)) + + connect-mcp + (mf/use-fn + #(st/emit! (mcp/connect-mcp) + (ev/event {::ev/name "connect-mcp-plugin" + ::ev/origin "workspace:toolbar"})))] + (when show-indicator? + [:li + [:button + {:title (tr "workspace.toolbar.mcp") + :aria-label (tr "workspace.toolbar.mcp") + :class (stl/css-case :main-toolbar-options-button true + :mcp-button true + :selected mcp-menu-open?) + :on-click toggle-mcp-menu + :data-tool "mcp" + :data-testid "mcp-btn"} + [:span {:class (stl/css-case :mcp-status-dot true + :connected mcp-connected?)}] + [:span {:class (stl/css-case :mcp-button-label true + :connected mcp-connected?)} + (tr "workspace.toolbar.mcp")]] + [:> dropdown-menu* {:show mcp-menu-open? + :on-close close-mcp-menu + :class (stl/css :mcp-menu)} + (if mcp-connected? + [:li {:class (stl/css :mcp-menu-info) + :role "presentation"} + (tr "workspace.toolbar.mcp-connected")] + [:> dropdown-menu-item* {:class (stl/css :mcp-menu-item) + :on-click connect-mcp} + (tr "workspace.toolbar.mcp-connect-here")])]]))) + (mf/defc image-upload* {::mf/wrap [mf/memo]} [] @@ -221,12 +279,13 @@ {:title "Debugging tool" :class (stl/css-case :main-toolbar-options-button true :selected (contains? layout :debug-panel)) :on-click toggle-debug-panel} - deprecated-icon/bug]])]] + deprecated-icon/bug]]) + + (when (contains? cf/flags :mcp) + [:> mcp-indicator*])]] [:button {:title (tr "workspace.toolbar.toggle-toolbar") :aria-label (tr "workspace.toolbar.toggle-toolbar") :class (stl/css :toolbar-handler) :on-click toggle-toolbar} [:div {:class (stl/css :toolbar-handler-btn)}]]]))) - - diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index e543964db0..7a185eb692 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -5,6 +5,9 @@ // Copyright (c) KALEIDOS INC Sucursal en España SL @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as t; .main-toolbar { cursor: initial; @@ -83,6 +86,102 @@ } } +.mcp-button { + display: flex; + align-items: center; + gap: var(--sp-xs); + width: fit-content; + margin-inline-start: var(--sp-xs); + padding-inline: var(--sp-s); +} + +.mcp-button-label { + @include t.use-typography("body-small"); + + &.connected { + color: var(--color-accent-primary); + } +} + +.mcp-status-dot { + // Connection indicator placed before the label, vertically centered: + // a muted gray when disconnected, the primary accent when connected. + position: relative; + flex-shrink: 0; + inline-size: px2rem(6); + block-size: px2rem(6); + border-radius: 50%; + background-color: var(--color-background-disabled); + + &.connected { + background-color: var(--color-accent-primary); + } + + // One-shot "blob" ripple that confirms the moment the tab becomes + // connected (e.g. after using "Switch here"). Triggered purely by the + // `.connected` class appearing, in sync with the color change. + &.connected::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--color-accent-primary); + opacity: 0; + animation: mcp-status-blob 0.5s ease-out; + } +} + +@keyframes mcp-status-blob { + from { + opacity: 0.5; + transform: scale(1); + } + + to { + opacity: 0; + transform: scale(2.5); + } +} + +.mcp-menu { + @include deprecated.menu-shadow; + + position: absolute; + inset-block-start: calc(100% + var(--sp-xs)); + inset-inline-start: var(--sp-xs); + z-index: var(--z-index-dropdown); + margin: 0; + padding: var(--sp-xs); + list-style: none; + border: $b-1 solid var(--panel-border-color); + border-radius: $br-8; + background-color: var(--menu-background-color); +} + +// Non-interactive informational text inside the menu (no hover, not focusable). +.mcp-menu-info { + @include t.use-typography("body-small"); + + padding: var(--sp-s) var(--sp-m); + color: var(--color-foreground-secondary); + white-space: nowrap; +} + +.mcp-menu-item { + @include t.use-typography("body-small"); + + display: flex; + align-items: center; + padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; + color: var(--menu-foreground-color); + white-space: nowrap; + + &:hover { + background-color: var(--menu-background-color-hover); + } +} + .toolbar-handler { @include deprecated.flex-center; @include deprecated.button-style; diff --git a/frontend/src/app/plugins.cljs b/frontend/src/app/plugins.cljs index 779c09b9fd..8937b4dde6 100644 --- a/frontend/src/app/plugins.cljs +++ b/frontend/src/app/plugins.cljs @@ -17,14 +17,17 @@ [app.plugins.grid :as grid] [app.plugins.library :as library] [app.plugins.public-utils] + [app.plugins.register :as preg] [app.plugins.ruler-guides :as rg] [app.plugins.shape :as shape] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) -(defn init-plugins-runtime! +(defn init-plugins-runtime [] - (runtime/initPluginsRuntime (fn [plugin-id] (api/create-context plugin-id)))) + (runtime/initPluginsRuntime (fn [plugin-id] (api/create-context plugin-id))) + ;; Signal that runtime is ready + (preg/signal-runtime-ready)) (defn initialize [] @@ -38,7 +41,7 @@ (rx/observe-on :async) (rx/filter #(features/active-feature? @st/state "plugins/runtime")) (rx/take 1) - (rx/tap init-plugins-runtime!) + (rx/tap init-plugins-runtime) (rx/ignore))))) ;; Prevent circular dependency diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index d05b08d547..e4837ce75b 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -15,12 +15,28 @@ [app.main.repo :as rp] [app.main.store :as st] [app.util.object :as obj] - [beicon.v2.core :as rx])) + [beicon.v2.core :as rx] + [promesa.core :as p])) ;; Needs to be here because moving it to `app.main.data.workspace.mcp` will ;; cause a circular dependency (def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae") +;; Promise that resolves when plugins runtime is initialized. +;; Lives here to avoid circular dependency: workspace.mcp -> app.plugins -> app.plugins.api -> workspace +(defonce ^:private runtime-ready-promise (p/deferred)) + +(defn wait-for-runtime + "Returns a promise that resolves when plugins runtime is initialized." + [] + runtime-ready-promise) + +(defn signal-runtime-ready + "Signals that plugins runtime has been initialized. Called by app.plugins/init-plugins-runtime." + [] + (when (p/pending? runtime-ready-promise) + (p/resolve! runtime-ready-promise true))) + ;; Stores the installed plugins information (defonce ^:private registry (atom {})) diff --git a/frontend/src/app/rasterizer.cljs b/frontend/src/app/rasterizer.cljs index 66f906686f..ed516fc85f 100644 --- a/frontend/src/app/rasterizer.cljs +++ b/frontend/src/app/rasterizer.cljs @@ -49,7 +49,14 @@ :hint "operation aborted"))) (obj/set! image "src" uri) (fn [] - (obj/set! image "src" "") + ;; NOTE: We intentionally do NOT set `image.src = ""` here. + ;; Doing so discards the decoded pixel data immediately when this + ;; observable completes, but downstream operators (e.g. drawImage / + ;; createImageBitmap in `render-image-bitmap`) still need to read + ;; the pixel data after the image is emitted. Clearing `src` before + ;; the downstream pipeline finishes causes a NotReadableError + ;; ("The requested file could not be read..."). The image element + ;; will be garbage collected naturally when no references remain. (obj/set! image "onload" nil) (obj/set! image "onerror" nil) (obj/set! image "onabort" nil)))))) @@ -57,10 +64,13 @@ (defn- svg-get-adjusted-size "Returns the adjusted size of an SVG." [width height max] - (let [ratio (/ width height)] - (if (< width height) - [max (* max (/ 1 ratio))] - [(* max ratio) max]))) + ;; Guard against zero/NaN dimensions that would cause division by zero + ;; or produce invalid sizes, leading to createImageBitmap failures. + (when (and (pos? width) (pos? height)) + (let [ratio (/ width height)] + (if (< width height) + [max (* max (/ 1 ratio))] + [(* max ratio) max])))) (defn- svg-get-size-from-viewbox "Returns the size of an SVG from its viewbox." @@ -101,7 +111,10 @@ "Sets the intrinsic size of an SVG to the given max size." [^js svg max] (let [doc (get-document-element svg) - [w h] (svg-get-size svg max)] + [w h] (or (svg-get-size svg max) + ;; Fallback: if we can't determine size from viewBox or + ;; intrinsic attributes, use the max size as a square. + [max max])] (dom/set-attribute! doc "width" (dm/str w)) (dom/set-attribute! doc "height" (dm/str h))) svg) diff --git a/frontend/src/features.cljs b/frontend/src/features.cljs index 77f15664ba..be61548a1b 100644 --- a/frontend/src/features.cljs +++ b/frontend/src/features.cljs @@ -24,5 +24,5 @@ (defn ^:export plugins [] (st/emit! (features/enable-feature "plugins/runtime")) - (plugins/init-plugins-runtime!) + (plugins/init-plugins-runtime) nil) diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs new file mode 100644 index 0000000000..e81dab6780 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -0,0 +1,50 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.data.workspace-mcp-test + (:require + [app.main.data.workspace.mcp :as mcp] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +(t/deftest test-set-mcp-active + (t/testing "sets :active to true" + (let [state {:mcp {:active false}} + result (ptk/update (mcp/set-mcp-active true) state)] + (t/is (true? (get-in result [:mcp :active]))))) + + (t/testing "sets :active to false" + (let [state {:mcp {:active true}} + result (ptk/update (mcp/set-mcp-active false) state)] + (t/is (false? (get-in result [:mcp :active])))))) + +(t/deftest test-update-mcp-status + (t/testing "enables MCP in profile props" + (let [state {:profile {:props {:mcp-enabled false}}} + result (ptk/update (mcp/update-mcp-status true) state)] + (t/is (true? (get-in result [:profile :props :mcp-enabled]))))) + + (t/testing "disables MCP in profile props" + (let [state {:profile {:props {:mcp-enabled true}}} + result (ptk/update (mcp/update-mcp-status false) state)] + (t/is (false? (get-in result [:profile :props :mcp-enabled])))))) + +(t/deftest test-update-mcp-connection-status + (t/testing "sets connection status to connected" + (let [state {:mcp {:connection-status "disconnected"}} + result (ptk/update (mcp/update-mcp-connection-status "connected") state)] + (t/is (= "connected" (get-in result [:mcp :connection-status]))))) + + (t/testing "sets connection status to disconnected" + (let [state {:mcp {:connection-status "connected"}} + result (ptk/update (mcp/update-mcp-connection-status "disconnected") state)] + (t/is (= "disconnected" (get-in result [:mcp :connection-status])))))) + +(t/deftest test-init-sets-active + (t/testing "init sets :mcp :active to true" + (let [state {:mcp {:active false}} + result (ptk/update (mcp/init) state)] + (t/is (true? (get-in result [:mcp :active])))))) diff --git a/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs b/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs index f9c21e9be5..2ccf1c51fc 100644 --- a/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs @@ -6,9 +6,18 @@ (ns frontend-tests.data.workspace-thumbnails-test (:require + [app.common.thumbnails :as thc] [app.common.uuid :as uuid] [app.main.data.workspace.thumbnails :as thumbnails] - [cljs.test :as t :include-macros true])) + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) + +;; The qualified keyword used internally by app.main.data.workspace.thumbnails +;; for tracking the pending deletion queue in application state. +(def ^:private deletion-queue-key + :app.main.data.workspace.thumbnails/thumbnails-deletion-queue) (t/deftest extract-frame-changes-handles-cyclic-frame-links (let [page-id (uuid/next) @@ -33,4 +42,257 @@ :component-root true}}}}}] (t/is (= #{["frame" root-id] ["component" shape-b-id]} - (#'thumbnails/extract-frame-changes page-id [event [old-data new-data]]))))) + (#'thumbnails/extract-frame-changes page-id [event [old-data new-data]])))) + + ;; --- Batch deletion queue state management --- + + (t/deftest clear-thumbnail-adds-to-deletion-queue + (let [file-id (uuid/next) + object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri "blob:http://localhost/test-thumb" + event (thumbnails/clear-thumbnail file-id object-id) + state {:thumbnails {object-id uri}} + result (ptk/update event state)] + ;; Thumbnail removed from the map + (t/is (nil? (get-in result [:thumbnails object-id]))) + ;; Object-id added to the deletion queue with its URI + (t/is (= uri (get-in result [deletion-queue-key object-id]))))) + + (t/deftest clear-thumbnail-keeps-other-thumbnails + (let [file-id (uuid/next) + object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri1 "blob:http://localhost/thumb-1" + uri2 "blob:http://localhost/thumb-2" + event (thumbnails/clear-thumbnail file-id object-id1) + state {:thumbnails {object-id1 uri1 object-id2 uri2}} + result (ptk/update event state)] + ;; Only the cleared thumbnail is removed + (t/is (nil? (get-in result [:thumbnails object-id1]))) + (t/is (= uri2 (get-in result [:thumbnails object-id2]))) + ;; Only the cleared thumbnail is queued + (t/is (= uri1 (get-in result [deletion-queue-key object-id1]))) + (t/is (nil? (get-in result [deletion-queue-key object-id2]))))) + + (t/deftest clear-thumbnail-accumulates-in-queue + (let [file-id (uuid/next) + object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri1 "blob:http://localhost/thumb-1" + uri2 "blob:http://localhost/thumb-2" + event1 (thumbnails/clear-thumbnail file-id object-id1) + event2 (thumbnails/clear-thumbnail file-id object-id2) + state {:thumbnails {object-id1 uri1 object-id2 uri2}} + state1 (ptk/update event1 state) + state2 (ptk/update event2 state1)] + ;; Both removed from thumbnails + (t/is (nil? (get-in state2 [:thumbnails object-id1]))) + (t/is (nil? (get-in state2 [:thumbnails object-id2]))) + ;; Both accumulated in the queue + (t/is (= uri1 (get-in state2 [deletion-queue-key object-id1]))) + (t/is (= uri2 (get-in state2 [deletion-queue-key object-id2]))) + (t/is (= 2 (count (get state2 deletion-queue-key)))))) + + (t/deftest remove-from-deletion-queue-removes-entry + (let [file-id (uuid/next) + object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + event (thumbnails/remove-from-deletion-queue object-id) + state {deletion-queue-key {object-id "blob:http://localhost/thumb"}} + result (ptk/update event state)] + (t/is (nil? (get-in result [deletion-queue-key object-id]))) + (t/is (empty? (get result deletion-queue-key))))) + + (t/deftest remove-from-deletion-queue-keeps-other-entries + (let [file-id (uuid/next) + object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri1 "blob:http://localhost/thumb-1" + uri2 "blob:http://localhost/thumb-2" + event (thumbnails/remove-from-deletion-queue object-id1) + state {deletion-queue-key {object-id1 uri1 + object-id2 uri2}} + result (ptk/update event state)] + ;; Only the specified entry is removed + (t/is (nil? (get-in result [deletion-queue-key object-id1]))) + (t/is (= uri2 (get-in result [deletion-queue-key object-id2]))) + (t/is (= 1 (count (get result deletion-queue-key)))))) + + (t/deftest remove-before-clear-cancels-pending-delete + (let [file-id (uuid/next) + object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri "blob:http://localhost/thumb" + ;; Step 1: clear-thumbnail queues the delete + state1 (ptk/update (thumbnails/clear-thumbnail file-id object-id) + {:thumbnails {object-id uri}}) + ;; Step 2: remove-from-deletion-queue cancels the pending delete + state2 (ptk/update (thumbnails/remove-from-deletion-queue object-id) + state1)] + ;; Thumbnail was removed from :thumbnails map by clear-thumbnail + (t/is (nil? (get-in state2 [:thumbnails object-id]))) + ;; But the deletion queue entry was cancelled by remove-from-deletion-queue + (t/is (nil? (get-in state2 [deletion-queue-key object-id]))) + (t/is (empty? (get state2 deletion-queue-key))))) + + (t/deftest clear-thumbnail-batch-drains-queue + (let [file-id (uuid/next) + object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri1 "blob:http://localhost/thumb-1" + uri2 "blob:http://localhost/thumb-2" + ;; Build up the queue state manually (simulating accumulated clear-thumbnails) + state {deletion-queue-key {object-id1 uri1 object-id2 uri2}} + event (#'thumbnails/clear-thumbnail-batch) + result (ptk/update event state)] + ;; The queue is drained from application state + (t/is (empty? (get result deletion-queue-key))))) + + (t/deftest clear-thumbnail-batch-empty-queue-noop + (let [state {deletion-queue-key {}} + event (#'thumbnails/clear-thumbnail-batch) + result (ptk/update event state)] + ;; State unchanged when queue is already empty + (t/is (empty? (get result deletion-queue-key))) + (t/is (= state (dissoc result deletion-queue-key))))) + + (t/deftest assoc-thumbnail-adds-to-map + (let [object-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame") + uri "blob:http://localhost/new-thumb" + event (#'thumbnails/assoc-thumbnail object-id uri) + state {:thumbnails {}} + result (ptk/update event state)] + (t/is (= uri (get-in result [:thumbnails object-id]))))) + + (t/deftest duplicate-thumbnail-copies-entry + (let [old-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame") + new-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame") + uri "blob:http://localhost/dup-thumb" + event (thumbnails/duplicate-thumbnail old-id new-id) + state {:thumbnails {old-id uri}} + result (ptk/update event state)] + (t/is (= uri (get-in result [:thumbnails old-id]))) + (t/is (= uri (get-in result [:thumbnails new-id]))))) + + ;; --- Async WatchEvent tests --- + + (defn- make-obj-ids + "Helper to create n properly-formatted object-ids for a single file." + [file-id n] + (vec (repeatedly n #(thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")))) + + (t/deftest clear-thumbnail-batch-watch-calls-rpc-with-object-ids + (t/async done + (let [file-id (uuid/next) + oids (make-obj-ids file-id 3) + state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))} + event (#'thumbnails/clear-thumbnail-batch)] + (ptk/update event state) + (mock/with-mocks + {#'app.main.repo/cmd! mock/rpc-cmd!-mock + #'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} + (fn [done'] + (->> (ptk/watch event state nil) + (rx/reduce conj []) + (rx/subs! + (fn [_] nil) + (fn [err] (t/is (nil? err)) (done')) + (fn [_] + (t/is (= 1 (count @mock/rpc-calls))) + (let [[{:keys [cmd params]}] @mock/rpc-calls] + (t/is (= :delete-file-object-thumbnails cmd)) + (t/is (= (vec oids) (:object-ids params)))) + (done'))))) + done)))) + + (t/deftest clear-thumbnail-batch-watch-partitions-large-batch + (t/async done + (let [file-id (uuid/next) + oids (make-obj-ids file-id 250) + state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))} + event (#'thumbnails/clear-thumbnail-batch)] + (ptk/update event state) + (mock/with-mocks + {#'app.main.repo/cmd! mock/rpc-cmd!-mock + #'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} + (fn [done'] + (->> (ptk/watch event state nil) + (rx/reduce conj []) + (rx/subs! + (fn [_] nil) + (fn [err] (t/is (nil? err)) (done')) + (fn [_] + (t/is (= 2 (count @mock/rpc-calls))) + (let [[c1 c2] @mock/rpc-calls] + (t/is (= :delete-file-object-thumbnails (:cmd c1))) + (t/is (= :delete-file-object-thumbnails (:cmd c2))) + (t/is (= 200 (count (:object-ids (:params c1))))) + (t/is (= 50 (count (:object-ids (:params c2))))) + (t/is (= (set oids) + (set (concat (:object-ids (:params c1)) + (:object-ids (:params c2))))))) + (done'))))) + done)))) + + (t/deftest clear-thumbnail-batch-watch-revokes-blob-uris + (t/async done + (let [file-id (uuid/next) + oids (make-obj-ids file-id 2) + uris ["blob:http://localhost/thumb-1" + "blob:http://localhost/thumb-2"] + state {deletion-queue-key (zipmap oids uris)} + event (#'thumbnails/clear-thumbnail-batch)] + (ptk/update event state) + (mock/with-mocks + {#'app.main.repo/cmd! (fn [_ _] (rx/of nil)) + #'app.util.webapi/revoke-uri mock/revoke-uri-mock + #'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} + (fn [done'] + (->> (ptk/watch event state nil) + (rx/reduce conj []) + (rx/subs! + (fn [_] nil) + (fn [err] (t/is (nil? err)) (done')) + (fn [_] + (t/is (= (set uris) (set @mock/revoked-uris))) + (done'))))) + done)))) + + (t/deftest clear-thumbnail-batch-watch-empty-queue-no-rpc + (t/async done + (let [event (#'thumbnails/clear-thumbnail-batch) + state {}] + (ptk/update event state) + (mock/with-mocks + {#'app.main.repo/cmd! mock/rpc-cmd!-mock + #'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock} + (fn [done'] + (->> (ptk/watch event state nil) + (rx/reduce conj []) + (rx/subs! + (fn [_] nil) + (fn [err] (t/is (nil? err)) (done')) + (fn [_] + (t/is (empty? @mock/rpc-calls)) + (done'))))) + done)))) + + (t/deftest clear-thumbnail-watch-emits-batch-after-debounce + (t/async done + (let [file-id (uuid/next) + object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame") + uri "blob:http://localhost/thumb" + state {:thumbnails {object-id uri}} + event (thumbnails/clear-thumbnail file-id object-id)] + (ptk/update event state) + (mock/with-mocks + {#'beicon.v2.core/timer mock/timer-mock} + (fn [done'] + (->> (ptk/watch event state nil) + (rx/reduce conj []) + (rx/subs! + (fn [_] nil) + (fn [err] (t/is (nil? err)) (done')) + (fn [events] + (t/is (= 1 (count events))) + (t/is (ptk/event? (first events))) + (done'))))) + done))))) diff --git a/frontend/test/frontend_tests/helpers/mock.cljs b/frontend/test/frontend_tests/helpers/mock.cljs new file mode 100644 index 0000000000..bce234f4cf --- /dev/null +++ b/frontend/test/frontend_tests/helpers/mock.cljs @@ -0,0 +1,103 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.helpers.mock + "Async-first mocking primitives for ClojureScript tests. + + Uses `with-redefs` — the standard CLJS mechanism for rebinding Vars + within a dynamic scope. Recording atoms (`rpc-calls`, `revoked-uris`) + persist across the entire test lifecycle, making captured data + inspectable regardless of whether callbacks fire synchronously or + asynchronously. + + The `with-mocks` helper wraps the lifecycle: + 1. Reset recording atoms + 2. Install mocks via `with-redefs` + 3. Execute `(test-fn inner-done)` + 4. `inner-done` calls `outer-done` (typically `cljs.test/async`'s done) + + Since all mock functions return synchronous `rx/of` observables, + callbacks always fire within the `with-redefs` body." + (:require + [beicon.v2.core :as rx])) + +;; ═══════════════════════════════════════════════════════════════ +;; Recording atoms +;; ═══════════════════════════════════════════════════════════════ + +(def rpc-calls + "Atom accumulating mocked `rp/cmd!` calls as `{:cmd kw :params map}`." + (atom [])) + +(def revoked-uris + "Atom accumulating URIs passed to `wapi/revoke-uri`." + (atom [])) + +;; ═══════════════════════════════════════════════════════════════ +;; Mock implementations +;; ═══════════════════════════════════════════════════════════════ + +(defn rpc-cmd!-mock + "Records [cmd params] in [[rpc-calls]], returns `(rx/of nil)`." + [cmd params] + (swap! rpc-calls conj {:cmd cmd :params params}) + (rx/of nil)) + +(defn revoke-uri-mock + "Records `uri` in [[revoked-uris]]." + [uri] + (swap! revoked-uris conj uri)) + +(defn schedule-on-idle-mock + "Calls `f` immediately instead of deferring to the idle queue." + [f] + (f)) + +(defn timer-mock + "Returns `(rx/of :immediate)` so debounce timers fire instantly + during tests." + [_ms] + (rx/of :immediate)) + +;; ═══════════════════════════════════════════════════════════════ +;; Lifecycle +;; ═══════════════════════════════════════════════════════════════ + +(defn reset! + "Clear all recording atoms. Called automatically by [[with-mocks]]." + [] + (reset! rpc-calls []) + (reset! revoked-uris [])) + +;; ═══════════════════════════════════════════════════════════════ +;; Public API +;; ═══════════════════════════════════════════════════════════════ + +(defn with-mocks + "Resets recording atoms, installs `mocks` via `with-redefs`, then + calls `(test-fn inner-done)`. + + `mocks` is a map of `Var → mock-fn` (e.g. `{#'rp/cmd! mock-fn}`). + `inner-done` tears down the `with-redefs` (by returning) and calls + `outer-done` (the `cljs.test/async` `done` callback). + + Example: + + (t/deftest my-async-test + (t/async done + (mock/with-mocks + {#'rp/cmd! mock/rpc-cmd!-mock} + (fn [done'] + (->> (some-async-flow) + (rx/subs! + (fn [v] ...) + (fn [err] (done')) + (fn [] (done'))))))))" + [mocks test-fn outer-done] + (reset!) + (apply with-redefs (mapcat identity mocks) + (test-fn (fn inner-done [] + (outer-done))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 5f0c6c0659..da17eeed40 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -11,6 +11,7 @@ [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] [frontend-tests.data.workspace-shortcuts-test] [frontend-tests.data.workspace-texts-test] @@ -63,6 +64,7 @@ frontend-tests.data.uploads-test frontend-tests.data.viewer-test frontend-tests.data.workspace-colors-test + frontend-tests.data.workspace-mcp-test frontend-tests.data.workspace-media-test frontend-tests.data.workspace-shortcuts-test frontend-tests.data.workspace-texts-test diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 455719f29d..a3559f9c1d 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -4888,16 +4888,16 @@ msgid "onboarding.questions.team-size.just-me" msgstr "Just me" msgid "onboarding.questions.team-size.2-100" -msgstr "2-100" +msgstr "2 - 100" msgid "onboarding.questions.team-size.101-500" -msgstr "101-500" +msgstr "101 - 500" msgid "onboarding.questions.team-size.501-1000" -msgstr "501-1,000" +msgstr "501 - 1,000" msgid "onboarding.questions.team-size.1001-5000" -msgstr "1,001-5,000" +msgstr "1,001 - 5,000" msgid "onboarding.questions.team-size.more-than-5001" msgstr "5,001+" @@ -9684,7 +9684,16 @@ msgstr "Create board. Click and drag to define its size. (%s)" msgid "workspace.toolbar.image" msgstr "Image (%s)" -#: src/app/main/ui/workspace/top_toolbar.cljs:139, src/app/main/ui/workspace/top_toolbar.cljs:140 +msgid "workspace.toolbar.mcp" +msgstr "MCP" + +msgid "workspace.toolbar.mcp-connected" +msgstr "MCP connected" + +msgid "workspace.toolbar.mcp-connect-here" +msgstr "Connect here" + +#: src/app/main/ui/workspace/top_toolbar.cljs:143 msgid "workspace.toolbar.move" msgstr "Move (%s)" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index d48f64a9e9..35393cf0f7 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -4757,16 +4757,16 @@ msgid "onboarding.questions.team-size.just-me" msgstr "Sólo yo" msgid "onboarding.questions.team-size.2-100" -msgstr "2-100" +msgstr "2 - 100" msgid "onboarding.questions.team-size.101-500" -msgstr "101-500" +msgstr "101 - 500" msgid "onboarding.questions.team-size.501-1000" -msgstr "501-1000" +msgstr "501 - 1000" msgid "onboarding.questions.team-size.1001-5000" -msgstr "1001-5000" +msgstr "1001 - 5000" msgid "onboarding.questions.team-size.more-than-5001" msgstr "5001+" @@ -9357,7 +9357,16 @@ msgstr "Crear tablero. Click y arrastrar para definir el tamaño. (%s)" msgid "workspace.toolbar.image" msgstr "Imagen (%s)" -#: src/app/main/ui/workspace/top_toolbar.cljs:139, src/app/main/ui/workspace/top_toolbar.cljs:140 +msgid "workspace.toolbar.mcp" +msgstr "MCP" + +msgid "workspace.toolbar.mcp-connected" +msgstr "MCP conectado" + +msgid "workspace.toolbar.mcp-connect-here" +msgstr "Conectar aquí" + +#: src/app/main/ui/workspace/top_toolbar.cljs:143 msgid "workspace.toolbar.move" msgstr "Mover (%s)" diff --git a/mcp/package.json b/mcp/package.json index 0db939edb4..c0cd9353f3 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/mcp", - "version": "2.16.0-rc.9.11", + "version": "2.16.0", "description": "MCP server for Penpot integration", "license": "MPL-2.0", "bin": { diff --git a/package.json b/package.json index f9e05bb2cc..1aed4629d2 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", + "packageManager": "pnpm@11.6.0+sha512.9a36518224080c6fe5165afdcfe79bfa118c29be703f3f462b1e32efe1e98e47e8750b148e08286250aad4113cc7993ca413c4e2cd447752708c2ee5751bc95f", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ca93fb1e8d..381d72d613 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ allowBuilds: + esbuild: true opencode-ai: true