Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-06-15 13:04:19 +02:00
commit d44c6250ea
41 changed files with 2000 additions and 513 deletions

View File

@ -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
<one paragraph: the problem or feature, user-facing impact>
## Why
<root cause or motivation, why this change was necessary>
## How
<high-level approach, key technical decisions>
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 "<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.

View File

@ -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

View File

@ -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

View File

@ -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)))))))

View File

@ -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; \

View File

@ -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

View File

@ -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; \

View File

@ -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/; \

View File

@ -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; \

View File

@ -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/

View File

@ -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;

View File

@ -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"

184
exporter/pnpm-lock.yaml generated
View File

@ -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:

View File

@ -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

View File

@ -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]

View File

@ -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",

441
frontend/pnpm-lock.yaml generated
View File

@ -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

View File

@ -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?]}]

View File

@ -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 [_ _ _]

View File

@ -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))))))

View File

@ -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)))))

View File

@ -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))

View File

@ -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)

View File

@ -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)}]))])])

View File

@ -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}])]]]

View File

@ -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

View File

@ -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)}]]])))

View File

@ -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;

View File

@ -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

View File

@ -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 {}))

View File

@ -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)

View File

@ -24,5 +24,5 @@
(defn ^:export plugins []
(st/emit! (features/enable-feature "plugins/runtime"))
(plugins/init-plugins-runtime!)
(plugins/init-plugins-runtime)
nil)

View File

@ -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]))))))

View File

@ -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)))))

View File

@ -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)))))

View File

@ -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

View File

@ -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)"

View File

@ -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)"

View File

@ -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": {

View File

@ -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"

View File

@ -1,2 +1,3 @@
allowBuilds:
esbuild: true
opencode-ai: true