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

This commit is contained in:
Alejandro Alonso 2026-04-24 09:29:24 +02:00
commit 984d292ab2
58 changed files with 3547 additions and 263 deletions

View File

@ -101,6 +101,7 @@
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534) - Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962) - Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962)
- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961) - Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961)
- Fix color dropdown option update [Taiga #14035](https://tree.taiga.io/project/penpot/issue/14035)
## 2.15.0 (Unreleased) ## 2.15.0 (Unreleased)
@ -113,6 +114,14 @@
### :bug: Bugs fixed ### :bug: Bugs fixed
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041) - Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
## 2.14.4
### :bug: Bugs fixed
- Fix email validation [Taiga #14006](https://tree.taiga.io/project/penpot/issue/14006)
- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122)
- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927) - Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927)

View File

@ -36,10 +36,18 @@
:cause cause))))) :cause cause)))))
(defn contains? (defn contains?
"Check if email is in the blacklist." "Check if email is in the blacklist. Also matches subdomains: if
'somedomain.com' is blacklisted, 'xxx@foo.somedomain.com' will also
be rejected."
[{:keys [::email/blacklist]} email] [{:keys [::email/blacklist]} email]
(let [[_ domain] (str/split email "@" 2)] (let [[_ domain] (str/split email "@" 2)
(c/contains? blacklist (str/lower domain)))) parts (str/split (str/lower domain) #"\.")]
(loop [parts parts]
(if (empty? parts)
false
(if (c/contains? blacklist (str/join "." parts))
true
(recur (rest parts)))))))
(defn enabled? (defn enabled?
"Check if the blacklist is enabled" "Check if the blacklist is enabled"

View File

@ -0,0 +1,34 @@
;; 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 backend-tests.email-blacklist-test
(:require
[app.email :as-alias email]
[app.email.blacklist :as blacklist]
[clojure.test :as t]))
(def ^:private cfg
{::email/blacklist #{"somedomain.com" "spam.net"}})
(t/deftest test-exact-domain-match
(t/is (true? (blacklist/contains? cfg "user@somedomain.com")))
(t/is (true? (blacklist/contains? cfg "user@spam.net")))
(t/is (false? (blacklist/contains? cfg "user@legit.com"))))
(t/deftest test-subdomain-match
(t/is (true? (blacklist/contains? cfg "user@sub.somedomain.com")))
(t/is (true? (blacklist/contains? cfg "user@a.b.somedomain.com")))
;; A domain that merely contains the blacklisted string but is not a
;; subdomain must NOT be rejected.
(t/is (false? (blacklist/contains? cfg "user@notsomedomain.com"))))
(t/deftest test-case-insensitive
(t/is (true? (blacklist/contains? cfg "user@SOMEDOMAIN.COM")))
(t/is (true? (blacklist/contains? cfg "user@Sub.SomeDomain.Com"))))
(t/deftest test-non-blacklisted-domain
(t/is (false? (blacklist/contains? cfg "user@example.com")))
(t/is (false? (blacklist/contains? cfg "user@sub.legit.com"))))

View File

@ -113,12 +113,19 @@
(tgen/fmap keyword))))) (tgen/fmap keyword)))))
;; --- SPEC: email ;; --- SPEC: email
;;
;; Regex rules enforced:
;; local part - valid RFC chars, no leading/trailing dot, no consecutive dots
;; domain - labels can't start/end with hyphen, no empty labels
;; TLD - at least 2 alphabetic chars
(def email-re #"[a-zA-Z0-9_.+-\\\\]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+") (def email-re
#"^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,63}$")
(defn parse-email (defn parse-email
[s] [s]
(some->> s (re-seq email-re) first)) (when (and (string? s) (re-matches email-re s))
s))
(letfn [(conformer [v] (letfn [(conformer [v]
(or (parse-email v) ::s/invalid)) (or (parse-email v) ::s/invalid))
@ -126,11 +133,10 @@
(dm/str v))] (dm/str v))]
(s/def ::email (s/def ::email
(s/with-gen (s/conformer conformer unformer) (s/with-gen (s/conformer conformer unformer)
#(as-> (tgen/let [p1 (s/gen ::not-empty-string) #(tgen/let [local (tgen/string-alphanumeric 1 20)
p2 (s/gen ::not-empty-string) label (tgen/string-alphanumeric 2 10)
p3 (tgen/elements ["com" "net"])] tld (tgen/elements ["com" "net" "org" "io" "co" "dev"])]
(str p1 "@" p2 "." p3)) $ (str local "@" label "." tld)))))
(tgen/such-that (partial re-matches email-re) $ 50)))))
;; -- SPEC: uri ;; -- SPEC: uri

View File

@ -37,7 +37,9 @@
(defn attrs-to-styles (defn attrs-to-styles
[attrs] [attrs]
(reduce-kv (fn [res k v] (reduce-kv (fn [res k v]
(conj res (encode-style k v))) (if (some? v)
(conj res (encode-style k v))
res))
#{} #{}
attrs)) attrs))

View File

@ -95,7 +95,9 @@
:text-direction "ltr"}) :text-direction "ltr"})
(def default-text-attrs (def default-text-attrs
{:font-id "sourcesanspro" {:typography-ref-file nil
:typography-ref-id nil
:font-id "sourcesanspro"
:font-family "sourcesanspro" :font-family "sourcesanspro"
:font-variant-id "regular" :font-variant-id "regular"
:font-size "14" :font-size "14"

View File

@ -0,0 +1,151 @@
;; 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 common-tests.attrs-test
(:require
[app.common.attrs :as attrs]
[clojure.test :as t]))
(t/deftest get-attrs-multi-same-value
(t/testing "returns value when all objects have the same attribute value"
(let [objs [{:attr "red"}
{:attr "red"}
{:attr "red"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr "red"} result))))
(t/testing "returns nil when all objects have nil value"
(let [objs [{:attr nil}
{:attr nil}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr nil} result)))))
(t/deftest get-attrs-multi-different-values
(t/testing "returns :multiple when objects have different concrete values"
(let [objs [{:attr "red"}
{:attr "blue"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr :multiple} result)))))
(t/deftest get-attrs-multi-missing-key
(t/testing "returns value when one object has the attribute and another doesn't"
(let [objs [{:attr "red"}
{:other "value"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr "red"} result))))
(t/testing "returns value when one object has UUID and another is missing"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
objs [{:attr uuid}
{:other "value"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr uuid} result))))
(t/testing "returns :multiple when some objects have the key and some don't"
(let [objs [{:attr "red"}
{:other "value"}
{:attr "blue"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr :multiple} result))))
(t/testing "returns nil when one object has nil and another is missing"
(let [objs [{:attr nil}
{:other "value"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr nil} result)))))
(t/deftest get-attrs-multi-all-missing
(t/testing "all missing → attribute NOT included in result"
(let [objs [{:other "value"}
{:different "data"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {} result)
"Attribute should not be in result when all objects are missing")))
(t/testing "all missing with empty maps → attribute NOT included"
(let [objs [{} {}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {} result)
"Attribute should not be in result"))))
(t/deftest get-attrs-multi-multiple-attributes
(t/testing "handles multiple attributes with different merge results"
(let [objs [{:attr1 "red" :attr2 "blue"}
{:attr1 "red" :attr2 "green"}
{:attr1 "red"}] ; :attr2 missing
result (attrs/get-attrs-multi objs [:attr1 :attr2])]
(t/is (= {:attr1 "red" :attr2 :multiple} result))))
(t/testing "handles mixed scenarios: same, different, and missing"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
uuid2 #uuid "550e8400-e29b-41d4-a716-446655440001"
objs [{:id :a :ref uuid}
{:id :b :ref uuid2}
{:id :c}] ; :ref missing
result (attrs/get-attrs-multi objs [:id :ref])]
(t/is (= {:id :multiple :ref :multiple} result)))))
(t/deftest get-attrs-multi-typography-ref-id-scenario
(t/testing "the specific bug scenario: typography-ref-id with UUID vs missing"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
;; Shape 1 has typography-ref-id with a UUID
shape1 {:id :shape1 :typography-ref-id uuid}
;; Shape 2 does NOT have typography-ref-id at all
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {:typography-ref-id uuid} result))))
(t/testing "both shapes missing → attribute NOT included in result"
(let [shape1 {:id :shape1}
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {} result)
"Expected empty map when all shapes are missing the attribute"))))
(t/deftest get-attrs-multi-bug-missing-vs-present
(t/testing "BUG FIXED: one shape has :typography-ref-id, other does NOT → returns uuid"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
shape1 {:id :shape1 :typography-ref-id uuid}
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {:typography-ref-id uuid} result))))
(t/testing "both missing → empty map (attribute not in result)"
(let [shape1 {:id :shape1}
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {} result)
"Expected empty map when all shapes are missing the attribute")))
(t/testing "both equal values → return the value"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
shape1 {:id :shape1 :typography-ref-id uuid}
shape2 {:id :shape2 :typography-ref-id uuid}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {:typography-ref-id uuid} result))))
(t/testing "different values → return :multiple"
(let [uuid1 #uuid "550e8400-e29b-41d4-a716-446655440000"
uuid2 #uuid "550e8400-e29b-41d4-a716-446655440001"
shape1 {:id :shape1 :typography-ref-id uuid1}
shape2 {:id :shape2 :typography-ref-id uuid2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {:typography-ref-id :multiple} result)))))
(t/deftest get-attrs-multi-default-equal
(t/testing "numbers use close? for equality"
(let [objs [{:value 1.0}
{:value 1.0000001}]
result (attrs/get-attrs-multi objs [:value])]
(t/is (= {:value 1.0} result)
"Numbers within tolerance should be considered equal")))
(t/testing "different floating point positions beyond tolerance are :multiple"
(let [objs [{:x -26}
{:x -153}]
result (attrs/get-attrs-multi objs [:x])]
(t/is (= {:x :multiple} result)
"Different positions should be :multiple"))))

View File

@ -8,6 +8,7 @@
(:require (:require
#?(:clj [common-tests.fressian-test]) #?(:clj [common-tests.fressian-test])
[clojure.test :as t] [clojure.test :as t]
[common-tests.attrs-test]
[common-tests.buffer-test] [common-tests.buffer-test]
[common-tests.colors-test] [common-tests.colors-test]
[common-tests.data-test] [common-tests.data-test]
@ -54,6 +55,7 @@
[common-tests.path-names-test] [common-tests.path-names-test]
[common-tests.record-test] [common-tests.record-test]
[common-tests.schema-test] [common-tests.schema-test]
[common-tests.spec-test]
[common-tests.svg-path-test] [common-tests.svg-path-test]
[common-tests.svg-test] [common-tests.svg-test]
[common-tests.text-test] [common-tests.text-test]
@ -85,6 +87,7 @@
(defn -main (defn -main
[& args] [& args]
(t/run-tests (t/run-tests
'common-tests.attrs-test
'common-tests.buffer-test 'common-tests.buffer-test
'common-tests.colors-test 'common-tests.colors-test
'common-tests.data-test 'common-tests.data-test
@ -132,6 +135,7 @@
'common-tests.path-names-test 'common-tests.path-names-test
'common-tests.record-test 'common-tests.record-test
'common-tests.schema-test 'common-tests.schema-test
'common-tests.spec-test
'common-tests.svg-path-test 'common-tests.svg-path-test
'common-tests.svg-test 'common-tests.svg-test
'common-tests.text-test 'common-tests.text-test

View File

@ -0,0 +1,89 @@
;; 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 common-tests.spec-test
(:require
[app.common.spec :as spec]
[clojure.test :as t]))
(t/deftest valid-emails
(t/testing "accepts well-formed email addresses"
(doseq [email ["user@domain.com"
"user.name@domain.com"
"user+tag@domain.com"
"user-name@domain.com"
"user_name@domain.com"
"user123@domain.com"
"USER@DOMAIN.COM"
"u@domain.io"
"user@sub.domain.com"
"user@domain.co.uk"
"user@domain.dev"
"a@bc.co"]]
(t/is (some? (spec/parse-email email)) (str "should accept: " email)))))
(t/deftest rejects-invalid-local-part
(t/testing "rejects local part starting with a dot"
(t/is (nil? (spec/parse-email ".user@domain.com"))))
(t/testing "rejects local part with consecutive dots"
(t/is (nil? (spec/parse-email "user..name@domain.com"))))
(t/testing "rejects local part with spaces"
(t/is (nil? (spec/parse-email "us er@domain.com"))))
(t/testing "rejects local part with comma"
(t/is (nil? (spec/parse-email "user,name@domain.com")))
(t/is (nil? (spec/parse-email ",user@domain.com"))))
(t/testing "rejects empty local part"
(t/is (nil? (spec/parse-email "@domain.com")))))
(t/deftest rejects-invalid-domain
(t/testing "rejects domain starting with a dot"
(t/is (nil? (spec/parse-email "user@.domain.com"))))
(t/testing "rejects domain part with comma"
(t/is (nil? (spec/parse-email "user@domain,com")))
(t/is (nil? (spec/parse-email "user@,domain.com"))))
(t/testing "rejects domain with consecutive dots"
(t/is (nil? (spec/parse-email "user@sub..domain.com"))))
(t/testing "rejects label starting with hyphen"
(t/is (nil? (spec/parse-email "user@-domain.com"))))
(t/testing "rejects label ending with hyphen"
(t/is (nil? (spec/parse-email "user@domain-.com"))))
(t/testing "rejects TLD shorter than 2 chars"
(t/is (nil? (spec/parse-email "user@domain.c"))))
(t/testing "rejects domain without a dot"
(t/is (nil? (spec/parse-email "user@domain"))))
(t/testing "rejects domain with spaces"
(t/is (nil? (spec/parse-email "user@do main.com"))))
(t/testing "rejects domain ending with a dot"
(t/is (nil? (spec/parse-email "user@domain.")))))
(t/deftest rejects-invalid-structure
(t/testing "rejects nil"
(t/is (nil? (spec/parse-email nil))))
(t/testing "rejects empty string"
(t/is (nil? (spec/parse-email ""))))
(t/testing "rejects string without @"
(t/is (nil? (spec/parse-email "userdomain.com"))))
(t/testing "rejects string with multiple @"
(t/is (nil? (spec/parse-email "user@@domain.com")))
(t/is (nil? (spec/parse-email "us@er@domain.com"))))
(t/testing "rejects empty domain"
(t/is (nil? (spec/parse-email "user@")))))

View File

@ -105,7 +105,7 @@ services:
# - "traefik.http.routers.penpot-https.tls=true" # - "traefik.http.routers.penpot-https.tls=true"
environment: environment:
<< : [*penpot-flags, *penpot-http-body-size] << : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
penpot-backend: penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-latest}" image: "penpotapp/backend:${PENPOT_VERSION:-latest}"

View File

@ -19,6 +19,10 @@ update_flags() {
-e "s|^//var penpotFlags = .*;|var penpotFlags = \"$PENPOT_FLAGS\";|g" \ -e "s|^//var penpotFlags = .*;|var penpotFlags = \"$PENPOT_FLAGS\";|g" \
"$1")" > "$1" "$1")" > "$1"
fi fi
if [ -n "$PENPOT_PUBLIC_URI" ]; then
echo "var penpotPublicURI = \"$PENPOT_PUBLIC_URI\";" >> "$1";
fi
} }
update_oidc_name() { update_oidc_name() {
@ -39,8 +43,9 @@ update_oidc_name /var/www/app/js/config.js
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000} export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000}
export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp}
export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \ envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf < /tmp/nginx.conf.template > /etc/nginx/nginx.conf
PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)" PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)"

View File

@ -135,6 +135,23 @@ http {
proxy_http_version 1.1; proxy_http_version 1.1;
} }
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass $PENPOT_MCP_URI:4402;
proxy_http_version 1.1;
}
location /mcp/stream {
proxy_pass $PENPOT_MCP_URI:4401/mcp;
proxy_http_version 1.1;
}
location /mcp/sse {
proxy_pass $PENPOT_MCP_URI:4401/sse;
proxy_http_version 1.1;
}
location /readyz { location /readyz {
access_log off; access_log off;
proxy_pass $PENPOT_BACKEND_URI$request_uri; proxy_pass $PENPOT_BACKEND_URI$request_uri;

View File

@ -63,6 +63,7 @@
:wasm (when is-wasm "true") :wasm (when is-wasm "true")
:scale scale} :scale scale}
uri (-> (cf/get :public-uri) uri (-> (cf/get :public-uri)
(assoc :path "/render.html") (u/ensure-path-slash)
(u/join "render.html")
(assoc :query (u/map->query-string params)))] (assoc :query (u/map->query-string params)))]
(bw/exec! (prepare-options uri) (partial render uri))))) (bw/exec! (prepare-options uri) (partial render uri)))))

View File

@ -35,7 +35,7 @@
:object-id object-id :object-id object-id
:route "objects"}] :route "objects"}]
(-> base-uri (-> base-uri
(assoc :path "/render.html") (u/join "render.html")
(assoc :query (u/map->query-string params))))) (assoc :query (u/map->query-string params)))))
(sync-page-size! [dom] (sync-page-size! [dom]
@ -77,6 +77,7 @@
(on-object (assoc object :path path)) (on-object (assoc object :path path))
(p/recur (rest objects))))))] (p/recur (rest objects))))))]
(let [base-uri (cf/get :public-uri)] (let [base-uri (-> (cf/get :public-uri)
(u/ensure-path-slash))]
(bw/exec! (prepare-options base-uri) (bw/exec! (prepare-options base-uri)
(partial render base-uri))))) (partial render base-uri)))))

View File

@ -350,7 +350,8 @@
:object-id (mapv :id objects) :object-id (mapv :id objects)
:route "objects"} :route "objects"}
uri (-> (cf/get :public-uri) uri (-> (cf/get :public-uri)
(assoc :path "/render.html") (u/ensure-path-slash)
(u/join "render.html")
(assoc :query (u/map->query-string params)))] (assoc :query (u/map->query-string params)))]
(bw/exec! (prepare-options uri) (bw/exec! (prepare-options uri)
(partial render uri))))) (partial render uri)))))

View File

@ -0,0 +1,822 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/objects-map",
"render-wasm/v1",
"text-editor-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u6bd7c17d-4f59-815e-8006-5c1f6882469a",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "svg_path_evenodd",
"~:revn": 18,
"~:modified-at": "~m1776843383797",
"~:vern": 0,
"~:id": "~u3e84615b-5628-818c-8007-e7563bb081fb",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node",
"0017-fix-layout-flex-dir"
]
},
"~:version": 67,
"~:project-id": "~u6bd7c17d-4f59-815e-8006-5c1f68846e43",
"~:created-at": "~m1776779037378",
"~:backend": "legacy-db",
"~:data": {
"~:pages": ["~u3e84615b-5628-818c-8007-e7563bb081fc"],
"~:pages-index": {
"~u3e84615b-5628-818c-8007-e7563bb081fc": {
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b7399e693\",\"~ud0a635f7-639e-80f3-8007-e84b73ad74f4\",\"~ud0a635f7-639e-80f3-8007-e84b73bdd7da\",\"~ud0a635f7-639e-80f3-8007-e84b73ce5ead\",\"~ud0a635f7-639e-80f3-8007-e84b73d4a494\"]]]",
"~ud0a635f7-639e-80f3-8007-e84b73ce5ead": "[\"~#shape\",[\"^ \",\"~:y\",-840.999998986721,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"04-venn-circles\",\"~:width\",99.99999761581421,\"~:type\",\"~:group\",\"~:svg-attrs\",[\"^ \",\"^5\",\"120\",\"~:height\",\"120\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-840.999998986721]],[\"^;\",[\"^ \",\"~:x\",188.9999984701475,\"~:y\",-840.999998986721]],[\"^;\",[\"^ \",\"~:x\",188.9999984701475,\"~:y\",-741.0000013709068]],[\"^;\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-741.0000013709068]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73ce5ead\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",89.00000085433328,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-840.999998986721,\"^5\",99.99999761581421,\"^9\",99.99999761581421,\"~:x1\",89.00000085433328,\"~:y1\",-840.999998986721,\"~:x2\",188.9999984701475,\"~:y2\",-741.0000013709068]],\"~:fills\",[],\"~:flip-x\",null,\"^9\",99.99999761581421,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b73ce9306\",\"~ud0a635f7-639e-80f3-8007-e84b73cf1ec2\"]]]",
"~ud0a635f7-639e-80f3-8007-e84b739a1146": "[\"~#shape\",[\"^ \",\"~:y\",-725.9999912977219,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"base-background\",\"~:width\",100,\"~:type\",\"~:rect\",\"~:svg-attrs\",[\"^ \",\"~:fill\",\"none\",\"~:id\",\"base-background\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-725.9999912977219]],[\"^<\",[\"^ \",\"~:x\",357.9999885559082,\"~:y\",-725.9999912977219]],[\"^<\",[\"^ \",\"~:x\",357.9999885559082,\"~:y\",-625.9999912977219]],[\"^<\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-625.9999912977219]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:hidden\",true,\"^:\",\"~ud0a635f7-639e-80f3-8007-e84b739a1146\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b7399e693\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",100,\"~:height\",100,\"~:x1\",0,\"~:y1\",0,\"~:x2\",100,\"~:y2\",100]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",257.9999885559082,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"^E\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-725.9999912977219,\"^5\",100,\"^F\",100,\"^G\",257.9999885559082,\"^H\",-725.9999912977219,\"^I\",357.9999885559082,\"^J\",-625.9999912977219]],\"~:fills\",[],\"~:flip-x\",null,\"^F\",100,\"~:flip-y\",null]]",
"~ud0a635f7-639e-80f3-8007-e84b73ce9306": "[\"~#shape\",[\"^ \",\"~:y\",-840.999998986721,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"base-background\",\"~:width\",99.99999761581421,\"~:type\",\"~:rect\",\"~:svg-attrs\",[\"^ \",\"~:fill\",\"none\",\"~:id\",\"base-background\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-840.999998986721]],[\"^<\",[\"^ \",\"~:x\",188.9999984701475,\"~:y\",-840.999998986721]],[\"^<\",[\"^ \",\"~:x\",188.9999984701475,\"~:y\",-741.0000013709068]],[\"^<\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-741.0000013709068]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:hidden\",true,\"^:\",\"~ud0a635f7-639e-80f3-8007-e84b73ce9306\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73ce5ead\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",120,\"~:height\",120,\"~:x1\",0,\"~:y1\",0,\"~:x2\",120,\"~:y2\",120]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",89.00000085433328,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"^E\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-840.999998986721,\"^5\",99.99999761581421,\"^F\",99.99999761581421,\"^G\",89.00000085433328,\"^H\",-840.999998986721,\"^I\",188.9999984701475,\"^J\",-741.0000013709068]],\"~:fills\",[],\"~:flip-x\",null,\"^F\",99.99999761581421,\"~:flip-y\",null]]",
"~ud0a635f7-639e-80f3-8007-e84b73be6505": "[\"~#shape\",[\"^ \",\"~:y\",-841,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"base-background\",\"~:width\",99.99998211860657,\"~:type\",\"~:rect\",\"~:svg-attrs\",[\"^ \",\"~:fill\",\"none\",\"~:id\",\"base-background\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-841]],[\"^<\",[\"^ \",\"~:x\",288.99998104572296,\"~:y\",-841]],[\"^<\",[\"^ \",\"~:x\",288.99998104572296,\"~:y\",-741]],[\"^<\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-741]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:hidden\",true,\"^:\",\"~ud0a635f7-639e-80f3-8007-e84b73be6505\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73bdd7da\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",100,\"~:height\",100,\"~:x1\",0,\"~:y1\",0,\"~:x2\",100,\"~:y2\",100]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",188.9999989271164,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"^E\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-841,\"^5\",99.99998211860657,\"^F\",100,\"^G\",188.9999989271164,\"^H\",-841,\"^I\",288.99998104572296,\"^J\",-741]],\"~:fills\",[],\"~:flip-x\",null,\"^F\",100,\"~:flip-y\",null]]",
"~ud0a635f7-639e-80f3-8007-e84b73ae0f65": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAgJpDAMBPxAIAAAAAAAAAAAAAAAAAAAAAAAAAAIDCQwDAT8QCAAAAAAAAAAAAAAAAAAAAAAAAAACAwkMAwDvEAgAAAAAAAAAAAAAAAAAAAAAAAAAAgJpDAMA7xAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAokMAAEzEAgAAAAAAAAAAAAAAAAAAAAAAAAAAALtDAABMxAIAAAAAAAAAAAAAAAAAAAAAAAAAAAC7QwCAP8QCAAAAAAAAAAAAAAAAAAAAAAAAAAAAokMAgD/EBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAICpQwBASMQCAAAAAAAAAAAAAAAAAAAAAAAAAACAs0MAQEjEAgAAAAAAAAAAAAAAAAAAAAAAAAAAgLNDAEBDxAIAAAAAAAAAAAAAAAAAAAAAAAAAAICpQwBAQ8QEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"],\"~:name\",\"svg-path\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:fillRule\",\"evenodd\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",309,\"~:y\",-831]],[\"^=\",[\"^ \",\"~:x\",389,\"~:y\",-831]],[\"^=\",[\"^ \",\"~:x\",389,\"~:y\",-751]],[\"^=\",[\"^ \",\"~:x\",309,\"~:y\",-751]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73ae0f65\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73ad74f4\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",10,\"~:y\",10,\"^7\",80,\"~:height\",80,\"~:x1\",10,\"~:y1\",10,\"~:x2\",90,\"~:y2\",90]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#1a6a3a\",\"~:stroke-opacity\",1,\"~:stroke-width\",3]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",309,\"~:y\",-831,\"^7\",80,\"^E\",80,\"^F\",309,\"^G\",-831,\"^H\",389,\"^I\",-751]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#4ae290\"]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
"~ud0a635f7-639e-80f3-8007-e84b73d53705": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAABACpDq2ozxAMAAAD2/BJDq2ozxFVVAEPDwC7EVVUAQwAAKcQDAAAAVVUAQz4/I8T2/BJDVpUexAEAKkNWlR7EAwAAAAoDQUNWlR7EqqpTQz4/I8SqqlNDAAApxAMAAACqqlNDw8AuxAoDQUOrajPEAQAqQ6tqM8QCAAAAAAAAAAAAAAAAAAAAAAAAAAEAKkOrajPEAQAAAAAAAAAAAAAAAAAAAAAAAAABACpDAEAvxAMAAAAmeiRDAEAvxAEAIEN3IS7EAQAgQwDALMQDAAAAAQAgQ4leK8QmeiRDAEAqxAEAKkMAQCrEAwAAANuFL0MAQCrEAQA0Q4leK8QBADRDAMAsxAMAAAABADRDdyEuxNuFL0MAQC/EAQAqQwBAL8QCAAAAAAAAAAAAAAAAAAAAAAAAAAEAKkMAQC/EAQAAAAAAAAAAAAAAAAAAAAAAAABWVRRDrKogxAMAAABWVRRDrGokxKuqHEOr6ibEAQAqQ6vqJsQDAAAAVVU3Q6vqJsSqqj9DrGokxKqqP0OsqiDEAwAAAKqqP0MBQCDE//89Q1bVH8RVVTxDVtUfxAIAAAAAAAAAAAAAAAAAAAAAAAAAq6oXQ1bVH8QDAAAAAQAWQ1bVH8RWVRRDAUAgxFZVFEOsqiDEAgAAAAAAAAAAAAAAAAAAAAAAAABWVRRDrKogxA==\"],\"~:name\",\"svg-path\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:fillRule\",\"evenodd\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",128.33333949247913,\"~:y\",-717.6666545867935]],[\"^=\",[\"^ \",\"~:x\",211.6666658719356,\"~:y\",-717.6666545867935]],[\"^=\",[\"^ \",\"~:x\",211.6666658719356,\"~:y\",-634.3333282073353]],[\"^=\",[\"^ \",\"~:x\",128.33333949247913,\"~:y\",-634.3333282073353]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73d53705\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73d4a494\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",5,\"~:y\",5,\"^7\",50,\"~:height\",50,\"~:x1\",5,\"~:y1\",5,\"~:x2\",55,\"~:y2\",55]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#6a4a1a\",\"~:stroke-opacity\",1,\"~:stroke-width\",2]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",128.33333949247913,\"~:y\",-717.6666545867935,\"^7\",83.33332637945648,\"^E\",83.33332637945819,\"^F\",128.33333949247913,\"^G\",-717.6666545867935,\"^H\",211.6666658719356,\"^I\",-634.3333282073353]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e2a04a\"]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
"~ud0a635f7-639e-80f3-8007-e84b73cf1ec2": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAAMtCAOBIxAMAAAAAAMtCnv9FxKKn3UKqqkPEqqr0QqqqQ8QDAAAA29YFQ6qqQ8SrKg9Dnv9FxKsqD0MA4EjEAwAAAKsqD0NhwEvE29YFQ1UVTsSqqvRCVRVOxAMAAACip91CVRVOxAAAy0JhwEvEAADLQgDgSMQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAABV1QZDAOBIxAMAAABV1QZDnv9FxCUpEEOqqkPEqqobQ6qqQ8QDAAAAMCwnQ6qqQ8QAgDBDnv9FxACAMEMA4EjEAwAAAACAMENhwEvEMCwnQ1UVTsSqqhtDVRVOxAMAAAAlKRBDVRVOxFXVBkNhwEvEVdUGQwDgSMQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAABWVexCVZVBxAMAAABWVexC9LQ+xPb8/kIAYDzEAAALQwBgPMQDAAAAhYEWQwBgPMRV1R9D9LQ+xFXVH0NVlUHEAwAAAFXVH0O2dUTEhYEWQ6rKRsQAAAtDqspGxAMAAAD2/P5CqspGxFZV7EK2dUTEVlXsQlWVQcQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"],\"~:name\",\"svg-path\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:fillRule\",\"evenodd\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",101.50000055631006,\"~:y\",-824.3333327174187]],[\"^=\",[\"^ \",\"~:x\",176.4999987681707,\"~:y\",-824.3333327174187]],[\"^=\",[\"^ \",\"~:x\",176.4999987681707,\"~:y\",-753.5000010728836]],[\"^=\",[\"^ \",\"~:x\",101.50000055631006,\"~:y\",-753.5000010728836]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73cf1ec2\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73ce5ead\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",15,\"~:y\",20,\"^7\",90,\"~:height\",85,\"~:x1\",15,\"~:y1\",20,\"~:x2\",105,\"~:y2\",105]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#3a1a6a\",\"~:stroke-opacity\",1,\"~:stroke-width\",3]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",101.50000055631006,\"~:y\",-824.3333327174187,\"^7\",74.99999821186066,\"^E\",70.83333164453506,\"^F\",101.50000055631006,\"^G\",-824.3333327174187,\"^H\",176.4999987681707,\"^I\",-753.5000010728836]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#9a4ae2\"]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
"~ud0a635f7-639e-80f3-8007-e84b73bea73d": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAD//25DAABRxAIAAAAAAAAAAAAAAAAAAAAAAAAAAABVQwDAPMQCAAAAAAAAAAAAAAAAAAAAAAAAAP//jEMAQEnEAgAAAAAAAAAAAAAAAAAAAAAAAAAAAERDAEBJxAIAAAAAAAAAAAAAAAAAAAAAAAAAAICEQwDAPMQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"],\"~:name\",\"svg-path\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:fillRule\",\"evenodd\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",195.99999767541885,\"~:y\",-836]],[\"^=\",[\"^ \",\"~:x\",281.9999822974205,\"~:y\",-836]],[\"^=\",[\"^ \",\"~:x\",281.9999822974205,\"~:y\",-755]],[\"^=\",[\"^ \",\"~:x\",195.99999767541885,\"~:y\",-755]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73bea73d\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73bdd7da\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",7,\"~:y\",5,\"^7\",86,\"~:height\",81,\"~:x1\",7,\"~:y1\",5,\"~:x2\",93,\"~:y2\",86]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#6a1a1a\",\"~:stroke-opacity\",1,\"~:stroke-width\",3]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",195.99999767541885,\"~:y\",-836,\"^7\",85.99998462200165,\"^E\",81,\"^F\",195.99999767541885,\"^G\",-836,\"^H\",281.9999822974205,\"^I\",-755]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e24a4a\"]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
"~ud0a635f7-639e-80f3-8007-e84b73d4e3fc": "[\"~#shape\",[\"^ \",\"~:y\",-725.9999872247394,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"base-background\",\"~:width\",99.99999165534774,\"~:type\",\"~:rect\",\"~:svg-attrs\",[\"^ \",\"~:fill\",\"none\",\"~:id\",\"base-background\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-725.9999872247394]],[\"^<\",[\"^ \",\"~:x\",219.99999850988124,\"~:y\",-725.9999872247394]],[\"^<\",[\"^ \",\"~:x\",219.99999850988124,\"~:y\",-625.9999955693894]],[\"^<\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-625.9999955693894]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:hidden\",true,\"^:\",\"~ud0a635f7-639e-80f3-8007-e84b73d4e3fc\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73d4a494\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",60,\"~:height\",60,\"~:x1\",0,\"~:y1\",0,\"~:x2\",60,\"~:y2\",60]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",120.0000068545335,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"^E\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-725.9999872247394,\"^5\",99.99999165534774,\"^F\",99.99999165534996,\"^G\",120.0000068545335,\"^H\",-725.9999872247394,\"^I\",219.99999850988124,\"^J\",-625.9999955693894]],\"~:fills\",[],\"~:flip-x\",null,\"^F\",99.99999165534996,\"~:flip-y\",null]]",
"~ud0a635f7-639e-80f3-8007-e84b73ada09b": "[\"~#shape\",[\"^ \",\"~:y\",-841,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"base-background\",\"~:width\",100,\"~:type\",\"~:rect\",\"~:svg-attrs\",[\"^ \",\"~:fill\",\"none\",\"~:id\",\"base-background\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",299,\"~:y\",-841]],[\"^<\",[\"^ \",\"~:x\",399,\"~:y\",-841]],[\"^<\",[\"^ \",\"~:x\",399,\"~:y\",-741]],[\"^<\",[\"^ \",\"~:x\",299,\"~:y\",-741]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:hidden\",true,\"^:\",\"~ud0a635f7-639e-80f3-8007-e84b73ada09b\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73ad74f4\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",100,\"~:height\",100,\"~:x1\",0,\"~:y1\",0,\"~:x2\",100,\"~:y2\",100]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",299,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"^E\",[\"^ \",\"~:x\",299,\"~:y\",-841,\"^5\",100,\"^F\",100,\"^G\",299,\"^H\",-841,\"^I\",399,\"^J\",-741]],\"~:fills\",[],\"~:flip-x\",null,\"^F\",100,\"~:flip-y\",null]]",
"~ud0a635f7-639e-80f3-8007-e84b73bdd7da": "[\"~#shape\",[\"^ \",\"~:y\",-841,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"03-pentagram\",\"~:width\",99.99998211860657,\"~:type\",\"~:group\",\"~:svg-attrs\",[\"^ \",\"^5\",\"100\",\"~:height\",\"100\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-841]],[\"^;\",[\"^ \",\"~:x\",288.99998104572296,\"~:y\",-841]],[\"^;\",[\"^ \",\"~:x\",288.99998104572296,\"~:y\",-741]],[\"^;\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-741]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73bdd7da\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",188.9999989271164,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-841,\"^5\",99.99998211860657,\"^9\",100,\"~:x1\",188.9999989271164,\"~:y1\",-841,\"~:x2\",288.99998104572296,\"~:y2\",-741]],\"~:fills\",[],\"~:flip-x\",null,\"^9\",100,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b73be6505\",\"~ud0a635f7-639e-80f3-8007-e84b73bea73d\"]]]",
"~ud0a635f7-639e-80f3-8007-e84b739aa576": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAAJpDAAAzxAMAAABN9I5DAAAzxAAAhkPZhS7EAACGQwAAKcQDAAAAAACGQyd6I8RN9I5DAAAfxAAAmkMAAB/EAwAAALMLpUMAAB/EAACuQyd6I8QAAK5DAAApxAMAAAAAAK5D2YUuxLMLpUMAADPEAACaQwAAM8QEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAJpDAAAuxAMAAAAnepRDAAAuxAAAkEPtwivEAACQQwAAKcQDAAAAAACQQxM9JsQnepRDAAAkxAAAmkMAACTEAwAAANmFn0MAACTEAACkQxM9JsQAAKRDAAApxAMAAAAAAKRD7cIrxNmFn0MAAC7EAACaQwAALsQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"],\"~:name\",\"svg-path\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:fillRule\",\"evenodd\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",267.9999885559082,\"~:y\",-715.9999912977219]],[\"^=\",[\"^ \",\"~:x\",347.9999885559082,\"~:y\",-715.9999912977219]],[\"^=\",[\"^ \",\"~:x\",347.9999885559082,\"~:y\",-635.9999912977219]],[\"^=\",[\"^ \",\"~:x\",267.9999885559082,\"~:y\",-635.9999912977219]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b739aa576\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b7399e693\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",10,\"~:y\",10,\"^7\",80,\"~:height\",80,\"~:x1\",10,\"~:y1\",10,\"~:x2\",90,\"~:y2\",90]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#1a3a6a\",\"~:stroke-opacity\",1,\"~:stroke-width\",3]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",267.9999885559082,\"~:y\",-715.9999912977219,\"^7\",80,\"^E\",80,\"^F\",267.9999885559082,\"^G\",-715.9999912977219,\"^H\",347.9999885559082,\"^I\",-635.9999912977219]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#4a90e2\"]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
"~ud0a635f7-639e-80f3-8007-e84b73ad74f4": "[\"~#shape\",[\"^ \",\"~:y\",-841,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"02-nested-squares\",\"~:width\",100,\"~:type\",\"~:group\",\"~:svg-attrs\",[\"^ \",\"^5\",\"100\",\"~:height\",\"100\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",299,\"~:y\",-841]],[\"^;\",[\"^ \",\"~:x\",399,\"~:y\",-841]],[\"^;\",[\"^ \",\"~:x\",399,\"~:y\",-741]],[\"^;\",[\"^ \",\"~:x\",299,\"~:y\",-741]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73ad74f4\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",299,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",299,\"~:y\",-841,\"^5\",100,\"^9\",100,\"~:x1\",299,\"~:y1\",-841,\"~:x2\",399,\"~:y2\",-741]],\"~:fills\",[],\"~:flip-x\",null,\"^9\",100,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b73ada09b\",\"~ud0a635f7-639e-80f3-8007-e84b73ae0f65\"]]]",
"~ud0a635f7-639e-80f3-8007-e84b73d4a494": "[\"~#shape\",[\"^ \",\"~:y\",-725.9999872247394,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"05-person-icon\",\"~:width\",99.99999165534774,\"~:type\",\"~:group\",\"~:svg-attrs\",[\"^ \",\"^5\",\"60\",\"~:height\",\"60\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-725.9999872247394]],[\"^;\",[\"^ \",\"~:x\",219.99999850988124,\"~:y\",-725.9999872247394]],[\"^;\",[\"^ \",\"~:x\",219.99999850988124,\"~:y\",-625.9999955693894]],[\"^;\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-625.9999955693894]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73d4a494\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",120.0000068545335,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-725.9999872247394,\"^5\",99.99999165534774,\"^9\",99.99999165534996,\"~:x1\",120.0000068545335,\"~:y1\",-725.9999872247394,\"~:x2\",219.99999850988124,\"~:y2\",-625.9999955693894]],\"~:fills\",[],\"~:flip-x\",null,\"^9\",99.99999165534996,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b73d4e3fc\",\"~ud0a635f7-639e-80f3-8007-e84b73d53705\"]]]",
"~ud0a635f7-639e-80f3-8007-e84b7399e693": "[\"~#shape\",[\"^ \",\"~:y\",-725.9999912977219,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"01-ring\",\"~:width\",100,\"~:type\",\"~:group\",\"~:svg-attrs\",[\"^ \",\"^5\",\"100\",\"~:height\",\"100\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-725.9999912977219]],[\"^;\",[\"^ \",\"~:x\",357.9999885559082,\"~:y\",-725.9999912977219]],[\"^;\",[\"^ \",\"~:x\",357.9999885559082,\"~:y\",-625.9999912977219]],[\"^;\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-625.9999912977219]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b7399e693\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",257.9999885559082,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-725.9999912977219,\"^5\",100,\"^9\",100,\"~:x1\",257.9999885559082,\"~:y1\",-725.9999912977219,\"~:x2\",357.9999885559082,\"~:y2\",-625.9999912977219]],\"~:fills\",[],\"~:flip-x\",null,\"^9\",100,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b739a1146\",\"~ud0a635f7-639e-80f3-8007-e84b739aa576\"]]]"
}
},
"~:id": "~u3e84615b-5628-818c-8007-e7563bb081fc",
"~:name": "Page 1"
}
},
"~:id": "~u3e84615b-5628-818c-8007-e7563bb081fb",
"~:options": { "~:components-v2": true, "~:base-font-size": "16px" },
"~:components": {
"~u7c8614ca-087a-80b1-8007-e75c161f105c": {
"~:path": "Icons / 16",
"~:deleted": true,
"~:main-instance-id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f",
"~:objects": {
"~u7c8614ca-087a-80b1-8007-e75c161ef12f": {
"~#shape": {
"~:y": -622.2015816167936,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:hide-in-viewer": true,
"~:name": "Icons / 16 / profile",
"~:width": 16,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 185.68057966317429,
"~:y": -622.2015816167936
}
},
{
"~#point": {
"~:x": 201.68057966317429,
"~:y": -622.2015816167936
}
},
{
"~#point": {
"~:x": 201.68057966317429,
"~:y": -606.2015816167934
}
},
{
"~#point": {
"~:x": 185.68057966317429,
"~:y": -606.2015816167934
}
}
],
"~:component-root": true,
"~:show-content": true,
"~:proportion-lock": true,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1",
"~:id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:applied-tokens": {
"~:width": "xx.alias.icon.size.s",
"~:height": "xx.alias.icon.size.s"
},
"~:component-id": "~u7c8614ca-087a-80b1-8007-e75c161f105c",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 185.68057966317429,
"~:main-instance": true,
"~:proportion": 0.999999999999981,
"~:selrect": {
"~#rect": {
"~:x": 185.68057966317429,
"~:y": -622.2015816167936,
"~:width": 16,
"~:height": 16.000000000000227,
"~:x1": 185.68057966317429,
"~:y1": -622.2015816167936,
"~:x2": 201.68057966317429,
"~:y2": -606.2015816167934
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": 16.000000000000227,
"~:component-file": "~u3e84615b-5628-818c-8007-e7563bb081fb",
"~:flip-y": null,
"~:shapes": [
"~u7c8614ca-087a-80b1-8007-e75c161ef130",
"~u7c8614ca-087a-80b1-8007-e75c161ef131"
]
}
},
"~u7c8614ca-087a-80b1-8007-e75c161ef130": {
"~#shape": {
"~:y": null,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:content": {
"~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAADmWERD5wwaxAMAAADmWERDpa4ZxD4nQ0M9YhnEOq5BQz1iGcQDAAAAMjVAQz1iGcSOAz9Dpa4ZxI4DP0PnDBrEAwAAAI4DP0MpaxrEMjVAQ5G3GsQ6rkFDkbcaxAMAAAA+J0NDkbcaxOZYREMpaxrE5lhEQ+cMGsQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAACOA0ND5wwaxAMAAACOA0NDxd0ZxL5qQkORtxnEOq5BQ5G3GcQDAAAAtvFAQ5G3GcTmWEBDxd0ZxOZYQEPnDBrEAwAAAOZYQEMHPBrEtvFAQz1iGsQ6rkFDPWIaxAMAAAC+akJDPWIaxI4DQ0MHPBrEjgNDQ+cMGsQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
},
"~:name": "svg-path",
"~:width": null,
"~:type": "~:path",
"~:svg-attrs": {
"~:fill-rule": "evenodd",
"~:clip-rule": "evenodd"
},
"~:points": [
{
"~#point": {
"~:x": 191.01391299650777,
"~:y": -618.8682482834602
}
},
{
"~#point": {
"~:x": 196.34724632984125,
"~:y": -618.8682482834602
}
},
{
"~#point": {
"~:x": 196.34724632984125,
"~:y": -613.5349149501269
}
},
{
"~#point": {
"~:x": 191.01391299650777,
"~:y": -613.5349149501269
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1",
"~:constraints-v": "~:scale",
"~:svg-transform": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
},
"~:constraints-h": "~:scale",
"~:id": "~u7c8614ca-087a-80b1-8007-e75c161ef130",
"~:parent-id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f",
"~:svg-viewbox": {
"~:y": 5,
"~:y1": 5,
"~:width": 8,
"~:x": 8,
"~:x1": 8,
"~:y2": 13,
"~:x2": 16,
"~:height": 8
},
"~:applied-tokens": {},
"~:svg-defs": {},
"~:frame-id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f",
"~:strokes": [
{
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:inner",
"~:stroke-width": 1,
"~:stroke-color": "#121270",
"~:stroke-opacity": 1
}
],
"~:x": null,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 191.01391299650777,
"~:y": -618.8682482834604,
"~:width": 5.333333333333485,
"~:height": 5.3333333333332575,
"~:x1": 191.01391299650777,
"~:y1": -618.8682482834604,
"~:x2": 196.34724632984125,
"~:y2": -613.5349149501271
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": null,
"~:flip-y": null
}
},
"~u7c8614ca-087a-80b1-8007-e75c161ef131": {
"~#shape": {
"~:y": null,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:content": {
"~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAAA6rkFDPWIbxAMAAABmoT1DPWIbxOZYOkMbkBrE5lg6Q+eMGcQDAAAA5lg6Q7OJGMRmoT1DkbcXxDquQUORtxfEAwAAAAq7RUORtxfEjgNJQ7OJGMSOA0lD54wZxAMAAACOA0lDG5AaxAq7RUM9YhvEOq5BQz1iG8QCAAAAAAAAAAAAAAAAAAAAAAAAADquQUM9YhvEAQAAAAAAAAAAAAAAAAAAAAAAAAA6rjtD54wZxAMAAAA6rjtDuTMZxNYnPEOj4RjE2vM8Q3WgGMQDAAAAggw+Q0/8GMQyxz9DkTcZxD65QUORNxnEAwAAAM6kQ0ORNxnEdlpFQ9f9GMSac0ZDA6QYxAMAAAD6OEdDneQYxDquR0NVNRnEOq5HQ+eMGcQDAAAAOq5HQ/tgGsSK/kRD5wwbxDquQUPnDBvEAwAAAOpdPkPnDBvEOq47Q/tgGsQ6rjtD54wZxAIAAAAAAAAAAAAAAAAAAAAAAAAAOq47Q+eMGcQBAAAAAAAAAAAAAAAAAAAAAAAAADquQUPnDBjEAwAAAEI/QEPnDBjEVu4+QxMtGMQm5j1DwWIYxAMAAAAuvj5D168YxCokQEM94hjEPrlBQz3iGMQDAAAAQklDQz3iGMRiq0RDFbEYxDaERUOdZRjEAwAAAP55REM5LhjEqiNDQ+cMGMQ6rkFD5wwYxAIAAAAAAAAAAAAAAAAAAAAAAAAAOq5BQ+cMGMQ="
},
"~:name": "svg-path",
"~:width": null,
"~:type": "~:path",
"~:svg-attrs": {
"~:fill-rule": "evenodd",
"~:clip-rule": "evenodd"
},
"~:points": [
{
"~#point": {
"~:x": 186.34724632984103,
"~:y": -621.5349149501271
}
},
{
"~#point": {
"~:x": 201.01391299650777,
"~:y": -621.5349149501271
}
},
{
"~#point": {
"~:x": 201.01391299650777,
"~:y": -606.8682482834602
}
},
{
"~#point": {
"~:x": 186.34724632984103,
"~:y": -606.8682482834602
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1",
"~:constraints-v": "~:scale",
"~:svg-transform": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
},
"~:constraints-h": "~:scale",
"~:id": "~u7c8614ca-087a-80b1-8007-e75c161ef131",
"~:parent-id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f",
"~:svg-viewbox": {
"~:y": 1,
"~:y1": 1,
"~:width": 22,
"~:x": 1,
"~:x1": 1,
"~:y2": 23,
"~:x2": 23,
"~:height": 22
},
"~:applied-tokens": {},
"~:svg-defs": {},
"~:frame-id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f",
"~:strokes": [
{
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:inner",
"~:stroke-width": 1,
"~:stroke-color": "#121270",
"~:stroke-opacity": 1
}
],
"~:x": null,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 186.3472463298408,
"~:y": -621.5349149501271,
"~:width": 14.666666666666742,
"~:height": 14.66666666666697,
"~:x1": 186.3472463298408,
"~:y1": -621.5349149501271,
"~:x2": 201.01391299650754,
"~:y2": -606.8682482834602
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": null,
"~:flip-y": null
}
}
},
"~:name": "profile",
"~:modified-at": "~m1776843284702",
"~:main-instance-page": "~u3e84615b-5628-818c-8007-e7563bb081fc",
"~:id": "~u7c8614ca-087a-80b1-8007-e75c161f105c"
},
"~u2094e2d4-1854-804d-8007-e761fd29d15c": {
"~:path": "Icons / 16",
"~:deleted": true,
"~:main-instance-id": "~u2094e2d4-1854-804d-8007-e761fd24f93e",
"~:objects": {
"~u2094e2d4-1854-804d-8007-e761fd24f93e": {
"~#shape": {
"~:y": -623.0000079548252,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:hide-in-viewer": true,
"~:name": "Icons / 16 / profile",
"~:width": 16,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 214.0000008841555,
"~:y": -623.0000079548252
}
},
{
"~#point": {
"~:x": 230.0000008841555,
"~:y": -623.0000079548252
}
},
{
"~#point": {
"~:x": 230.0000008841555,
"~:y": -607.0000079548249
}
},
{
"~#point": {
"~:x": 214.0000008841555,
"~:y": -607.0000079548249
}
}
],
"~:component-root": true,
"~:show-content": true,
"~:proportion-lock": true,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1",
"~:id": "~u2094e2d4-1854-804d-8007-e761fd24f93e",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:applied-tokens": {
"~:width": "xx.alias.icon.size.s",
"~:height": "xx.alias.icon.size.s"
},
"~:component-id": "~u2094e2d4-1854-804d-8007-e761fd29d15c",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 214.0000008841555,
"~:main-instance": true,
"~:proportion": 0.999999999999981,
"~:selrect": {
"~#rect": {
"~:x": 214.0000008841555,
"~:y": -623.0000079548252,
"~:width": 16,
"~:height": 16.000000000000227,
"~:x1": 214.0000008841555,
"~:y1": -623.0000079548252,
"~:x2": 230.0000008841555,
"~:y2": -607.0000079548249
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": 16.000000000000227,
"~:component-file": "~u3e84615b-5628-818c-8007-e7563bb081fb",
"~:flip-y": null,
"~:shapes": [
"~u2094e2d4-1854-804d-8007-e761fd24f93f",
"~u2094e2d4-1854-804d-8007-e761fd24f940"
]
}
},
"~u2094e2d4-1854-804d-8007-e761fd24f93f": {
"~#shape": {
"~:y": null,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:content": {
"~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAACrqmBDAEAaxAMAAACrqmBDvuEZxAN5X0NWlRnE//9dQ1aVGcQDAAAA94ZcQ1aVGcRTVVtDvuEZxFNVW0MAQBrEAwAAAFNVW0NCnhrE94ZcQ6rqGsT//11DquoaxAMAAAADeV9DquoaxKuqYENCnhrEq6pgQwBAGsQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAABTVV9DAEAaxAMAAABTVV9D3hAaxIO8XkOq6hnE//9dQ6rqGcQDAAAAe0NdQ6rqGcSrqlxD3hAaxKuqXEMAQBrEAwAAAKuqXEMgbxrEe0NdQ1aVGsT//11DVpUaxAMAAACDvF5DVpUaxFNVX0MgbxrEU1VfQwBAGsQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
},
"~:name": "svg-path",
"~:width": null,
"~:type": "~:path",
"~:svg-attrs": {
"~:fill-rule": "evenodd",
"~:clip-rule": "evenodd"
},
"~:points": [
{
"~#point": {
"~:x": 219.33333421748898,
"~:y": -619.6666746214917
}
},
{
"~#point": {
"~:x": 224.66666755082247,
"~:y": -619.6666746214917
}
},
{
"~#point": {
"~:x": 224.66666755082247,
"~:y": -614.3333412881584
}
},
{
"~#point": {
"~:x": 219.33333421748898,
"~:y": -614.3333412881584
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1",
"~:constraints-v": "~:scale",
"~:svg-transform": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
},
"~:constraints-h": "~:scale",
"~:id": "~u2094e2d4-1854-804d-8007-e761fd24f93f",
"~:parent-id": "~u2094e2d4-1854-804d-8007-e761fd24f93e",
"~:svg-viewbox": {
"~:y": 5,
"~:y1": 5,
"~:width": 8,
"~:x": 8,
"~:x1": 8,
"~:y2": 13,
"~:x2": 16,
"~:height": 8
},
"~:applied-tokens": {},
"~:svg-defs": {},
"~:frame-id": "~u2094e2d4-1854-804d-8007-e761fd24f93e",
"~:strokes": [
{
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:inner",
"~:stroke-width": 1,
"~:stroke-color": "#121270",
"~:stroke-opacity": 1
}
],
"~:x": null,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 219.33333421748898,
"~:y": -619.6666746214919,
"~:width": 5.333333333333485,
"~:height": 5.3333333333332575,
"~:x1": 219.33333421748898,
"~:y1": -619.6666746214919,
"~:x2": 224.66666755082247,
"~:y2": -614.3333412881586
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": null,
"~:flip-y": null
}
},
"~u2094e2d4-1854-804d-8007-e761fd24f940": {
"~#shape": {
"~:y": null,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:content": {
"~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAAD//11DVpUbxAMAAAAr81lDVpUbxKuqVkM0wxrEq6pWQwDAGcQDAAAAq6pWQ8y8GMQr81lDquoXxP//XUOq6hfEAwAAAM8MYkOq6hfEU1VlQ8y8GMRTVWVDAMAZxAMAAABTVWVDNMMaxM8MYkNWlRvE//9dQ1aVG8QCAAAAAAAAAAAAAAAAAAAAAAAAAP//XUNWlRvEAQAAAAAAAAAAAAAAAAAAAAAAAAD//1dDAMAZxAMAAAD//1dD0mYZxJt5WEO8FBnEn0VZQ47TGMQDAAAAR15aQ2gvGcT3GFxDqmoZxAMLXkOqahnEAwAAAJP2X0OqahnEO6xhQ/AwGcRfxWJDHNcYxAMAAAC/imNDthcZxP//Y0NuaBnE//9jQwDAGcQDAAAA//9jQxSUGsRPUGFDAEAbxP//XUMAQBvEAwAAAK+vWkMAQBvE//9XQxSUGsT//1dDAMAZxAIAAAAAAAAAAAAAAAAAAAAAAAAA//9XQwDAGcQBAAAAAAAAAAAAAAAAAAAAAAAAAP//XUMAQBjEAwAAAAeRXEMAQBjEG0BbQyxgGMTrN1pD2pUYxAMAAADzD1tD8OIYxO91XENWFRnEAwteQ1YVGcQDAAAAB5tfQ1YVGcQn/WBDLuQYxPvVYUO2mBjEAwAAAMPLYENSYRjEb3VfQwBAGMT//11DAEAYxAIAAAAAAAAAAAAAAAAAAAAAAAAA//9dQwBAGMQ="
},
"~:name": "svg-path",
"~:width": null,
"~:type": "~:path",
"~:svg-attrs": {
"~:fill-rule": "evenodd",
"~:clip-rule": "evenodd"
},
"~:points": [
{
"~#point": {
"~:x": 214.66666755082224,
"~:y": -622.3333412881586
}
},
{
"~#point": {
"~:x": 229.33333421748898,
"~:y": -622.3333412881586
}
},
{
"~#point": {
"~:x": 229.33333421748898,
"~:y": -607.6666746214917
}
},
{
"~#point": {
"~:x": 214.66666755082224,
"~:y": -607.6666746214917
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1",
"~:constraints-v": "~:scale",
"~:svg-transform": {
"~:a": 1,
"~:b": 0,
"~:c": 0,
"~:d": 1,
"~:e": 0,
"~:f": 0
},
"~:constraints-h": "~:scale",
"~:id": "~u2094e2d4-1854-804d-8007-e761fd24f940",
"~:parent-id": "~u2094e2d4-1854-804d-8007-e761fd24f93e",
"~:svg-viewbox": {
"~:y": 1,
"~:y1": 1,
"~:width": 22,
"~:x": 1,
"~:x1": 1,
"~:y2": 23,
"~:x2": 23,
"~:height": 22
},
"~:applied-tokens": {},
"~:svg-defs": {},
"~:frame-id": "~u2094e2d4-1854-804d-8007-e761fd24f93e",
"~:strokes": [
{
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:inner",
"~:stroke-width": 1,
"~:stroke-color": "#121270",
"~:stroke-opacity": 1
}
],
"~:x": null,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 214.666667550822,
"~:y": -622.3333412881586,
"~:width": 14.666666666666742,
"~:height": 14.66666666666697,
"~:x1": 214.666667550822,
"~:y1": -622.3333412881586,
"~:x2": 229.33333421748875,
"~:y2": -607.6666746214917
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": null,
"~:flip-y": null
}
}
},
"~:name": "profile",
"~:modified-at": "~m1776782297854",
"~:main-instance-page": "~u3e84615b-5628-818c-8007-e7563bb081fc",
"~:id": "~u2094e2d4-1854-804d-8007-e761fd29d15c"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -243,6 +243,22 @@ test("Renders a file with a closed path shape with multiple segments using strok
await expect(workspace.canvas).toHaveScreenshot(); await expect(workspace.canvas).toHaveScreenshot();
}); });
test("Renders svg paths with evenodd", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-paths-evenodd.json");
await workspace.goToWorkspace({
id: "3e84615b-5628-818c-8007-e7563bb081fb",
pageId: "u3e84615b-5628-818c-8007-e7563bb081fc",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders solid shadows after select all and zoom to selected", async ({ test("Renders solid shadows after select all and zoom to selected", async ({
page, page,
}) => { }) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,258 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
});
test("Multiselection - check multiple values in measures", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
// Select first shape (single selection first)
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(0).click();
// === CHECK SINGLE SELECTION - ALL MEASURE FIELDS ===
const measuresSection = workspacePage.rightSidebar.getByRole('region', { name: 'shape-measures-section' });
await expect(measuresSection).toBeVisible();
// Width
const widthInput = measuresSection.getByTitle('Width', { exact: true }).getByRole('textbox');
await expect(widthInput).toHaveValue("360");
// Height
const heightInput = measuresSection.getByTitle('Height', { exact: true }).getByRole('textbox');
await expect(heightInput).toHaveValue("53");
// X Position (using "X axis" title)
const xPosInput = measuresSection.getByTitle('X axis', { exact: true }).getByRole('textbox');
await expect(xPosInput).toHaveValue("1094");
// Y Position (using "Y axis" title)
const yPosInput = measuresSection.getByTitle('Y axis', { exact: true }).getByRole('textbox');
await expect(yPosInput).toHaveValue("856");
// === CHECK MULTI-SELECTION - MIXED VALUES ===
// Shift+click to add second layer to selection
await workspacePage.layers.getByTestId("layer-row").nth(1).click({ modifiers: ['Shift'] });
// All measure fields should show "Mixed" placeholder when values differ
await expect(widthInput).toHaveAttribute('placeholder', 'Mixed');
await expect(heightInput).toHaveAttribute('placeholder', 'Mixed');
await expect(xPosInput).toHaveAttribute('placeholder', 'Mixed');
await expect(yPosInput).toHaveAttribute('placeholder', 'Mixed');
});
test("Multiselection - check fill multiple values", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(0).click();
// Fill section
const fillSection = workspacePage.rightSidebar.getByRole('region', { name: "Fill section" });
await expect(fillSection).toBeVisible();
// Single selection - fill color should be visible (not "Mixed")
await expect(fillSection.getByText(/Mixed/i)).not.toBeVisible();
// Multi-selection with Shift+click
await workspacePage.layers.getByTestId("layer-row").nth(1).click({ modifiers: ['Shift'] });
// Should show "Mixed" for fills when shapes have different fill colors
await expect(fillSection.getByText('Mixed')).toBeVisible();
});
test("Multiselection - check stroke multiple values", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(0).click();
// Stroke section
const strokeSection = workspacePage.rightSidebar.getByRole('region', { name: "Stroke section" });
await expect(strokeSection).toBeVisible();
// Single selection - stroke should be visible (not "Mixed")
await expect(strokeSection.getByText(/Mixed/i)).not.toBeVisible();
// Multi-selection
await workspacePage.layers.getByTestId("layer-row").nth(1).click({ modifiers: ['Shift'] });
// Should show "Mixed" for strokes when shapes have different stroke colors
await expect(strokeSection.getByText('Mixed')).toBeVisible();
});
test("Multiselection - check rotation multiple values", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
// Measures section contains rotation
const measuresSection = workspacePage.rightSidebar.getByRole('region', { name: 'shape-measures-section' });
await expect(measuresSection).toBeVisible();
// Rotation field exists
const rotationInput = measuresSection.getByTitle('Rotation', { exact: true }).getByRole('textbox');
await expect(rotationInput).toBeVisible();
// Rotate that shape
await rotationInput.fill("45");
await page.keyboard.press('Enter');
await expect(rotationInput).toHaveValue("45"); // Rotation should be 45
// Multi-selection
await workspacePage.layers.getByTestId("layer-row").nth(0).click({ modifiers: ['Shift'] });
// Rotation should show "Mixed" placeholder
await expect(rotationInput).toHaveAttribute('placeholder', 'Mixed');
});
test("Multiselection of text and typographies", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/multiselection-typography.json",
);
await workspacePage.goToWorkspace({
fileId: "1062e0a0-8fe0-80ae-8007-e70b4993f5ef",
pageId: "1062e0a0-8fe0-80ae-8007-e70b4993f5f0",
});
const plainTextLayer = workspacePage.layers.getByTestId("layer-row").nth(5);
const plainTextLayerTwo = workspacePage.layers.getByTestId("layer-row").nth(2);
const typographyTextLayerOne = workspacePage.layers.getByTestId("layer-row").nth(7);
const typographyTextLayerTwo = workspacePage.layers.getByTestId("layer-row").nth(4);
const tokenTypographyTextLayerOne = workspacePage.layers.getByTestId("layer-row").nth(6);
const tokenTypographyTextLayerTwo = workspacePage.layers.getByTestId("layer-row").nth(3);
const rectangleLayer = workspacePage.layers.getByTestId("layer-row").nth(1);
const elipseLayer = workspacePage.layers.getByTestId("layer-row").nth(0);
const textSection = workspacePage.rightSidebar.getByRole('region', { name: "Text section" });
// Select rectangle and elipse together
await rectangleLayer.click();
await elipseLayer.click({ modifiers: ['Control'] });
await expect(textSection).not.toBeVisible();
// Select plain text layer
await plainTextLayer.click();
await expect(textSection).toBeVisible();
await expect(textSection.getByText("Multiple typographies")).not.toBeVisible();
// Select two plain text layer with different font family
await plainTextLayerTwo.click({ modifiers: ['Control'] });
await expect(textSection).toBeVisible();
await expect(textSection.getByTitle("Font family").getByText("--")).toBeVisible();
// Select typography text layer
await typographyTextLayerOne.click();
await expect(textSection).toBeVisible();
await expect(textSection.getByText("Typography one")).toBeVisible();
// Select two typography text layer with different typography
await typographyTextLayerTwo.click({ modifiers: ['Control'] });
await expect(textSection).toBeVisible();
await expect(textSection.getByText("Multiple typographies")).toBeVisible();
// Select token typography text layer
// TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY
await tokenTypographyTextLayerOne.click();
await expect(textSection).toBeVisible();
await expect(textSection.getByText('Metrophobic')).toBeVisible();
// Select two token typography text layer with different token typography
// TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY
await tokenTypographyTextLayerTwo.click({ modifiers: ['Control'] });
await expect(textSection).toBeVisible();
await expect(textSection.getByTitle("Font family").getByText("--")).toBeVisible();
//Select plain text layer and typography text layer together
await plainTextLayer.click();
await typographyTextLayerOne.click({ modifiers: ['Control'] });
await expect(textSection).toBeVisible();
await expect(textSection.getByText("Multiple typographies")).toBeVisible();
//Select plain text layer and typography text layer together on reverse order
await typographyTextLayerOne.click();
await plainTextLayer.click({ modifiers: ['Control'] });
await expect(textSection).toBeVisible();
await expect(textSection.getByText("Multiple typographies")).toBeVisible();
//Selen token typography text layer and typography text layer together
await tokenTypographyTextLayerOne.click();
await typographyTextLayerOne.click({ modifiers: ['Control'] });
await expect(textSection).toBeVisible();
await expect(textSection.getByText("Multiple typographies")).toBeVisible();
//Select token typography text layer and typography text layer together on reverse order
await typographyTextLayerOne.click();
await tokenTypographyTextLayerOne.click({ modifiers: ['Control'] });
await expect(textSection).toBeVisible();
await expect(textSection.getByText("Multiple typographies")).toBeVisible();
// Select rectangle and elipse together
await rectangleLayer.click();
await elipseLayer.click({ modifiers: ['Control'] });
await expect(textSection).not.toBeVisible();
});

View File

@ -735,7 +735,7 @@ test.describe("Tokens: Apply token", () => {
// Check if token pill is visible on right sidebar // Check if token pill is visible on right sidebar
const strokeSectionSidebar = rightSidebar.getByRole("region", { const strokeSectionSidebar = rightSidebar.getByRole("region", {
name: "stroke-section", name: "Stroke section",
}); });
await expect(strokeSectionSidebar).toBeVisible(); await expect(strokeSectionSidebar).toBeVisible();
const firstStrokeRow = strokeSectionSidebar.getByLabel("stroke-row-0"); const firstStrokeRow = strokeSectionSidebar.getByLabel("stroke-row-0");

View File

@ -915,7 +915,7 @@
position: absolute; position: absolute;
padding: $s-4; padding: $s-4;
border-radius: $br-8; border-radius: $br-8;
z-index: $z-index-10; z-index: $z-index-dropdown;
color: var(--title-foreground-color-hover); color: var(--title-foreground-color-hover);
background-color: var(--menu-background-color); background-color: var(--menu-background-color);
border: $s-2 solid var(--panel-border-color); border: $s-2 solid var(--panel-border-color);

View File

@ -11,5 +11,5 @@ $z-index-4: 4; // context menu
$z-index-5: 5; // modal $z-index-5: 5; // modal
$z-index-10: 10; $z-index-10: 10;
$z-index-20: 20; $z-index-20: 20;
$z-index-modal: 30; // When refactor finish we can reduce this number, $z-index-modal: 300;
$z-index-alert: 40; // When refactor finish we can reduce this number, $z-index-dropdown: 400;

View File

@ -31,7 +31,6 @@
globalThis.penpotVersion = "{{& version}}"; globalThis.penpotVersion = "{{& version}}";
globalThis.penpotVersionTag = "{{& version_tag}}"; globalThis.penpotVersionTag = "{{& version_tag}}";
globalThis.penpotBuildDate = "{{& build_date}}"; globalThis.penpotBuildDate = "{{& build_date}}";
globalThis.penpotWorkerURI = "{{& manifest.worker_main}}";
</script> </script>
{{# manifest}} {{# manifest}}

View File

@ -9,7 +9,6 @@
globalThis.penpotVersion = "{{& version}}"; globalThis.penpotVersion = "{{& version}}";
globalThis.penpotVersionTag = "{{& version_tag}}"; globalThis.penpotVersionTag = "{{& version_tag}}";
globalThis.penpotBuildDate = "{{& build_date}}"; globalThis.penpotBuildDate = "{{& build_date}}";
globalThis.penpotWorkerURI = "{{& manifest.worker_main}}";
</script> </script>
{{# manifest}} {{# manifest}}

View File

@ -14,7 +14,7 @@
</script> </script>
{{# manifest}} {{# manifest}}
<script src="{{& config}}"></script> <script src="{{& config_render}}"></script>
<script src="{{& polyfills}}"></script> <script src="{{& polyfills}}"></script>
<script type="importmap">{{& importmap }}</script> <script type="importmap">{{& importmap }}</script>
{{/manifest}} {{/manifest}}

View File

@ -207,9 +207,9 @@ async function generateManifest() {
rasterizer_main: "./js/rasterizer.js", rasterizer_main: "./js/rasterizer.js",
config: "./js/config.js?version=" + VERSION_TAG, config: "./js/config.js?version=" + VERSION_TAG,
config_render: "./js/config-render.js?version=" + VERSION_TAG,
polyfills: "./js/polyfills.js?version=" + VERSION_TAG, polyfills: "./js/polyfills.js?version=" + VERSION_TAG,
libs: "./js/libs.js?version=" + VERSION_TAG, libs: "./js/libs.js?version=" + VERSION_TAG,
worker_main: "./js/worker/main.js?version=" + VERSION_TAG,
default_translations: "./js/translation.en.js?version=" + VERSION_TAG, default_translations: "./js/translation.en.js?version=" + VERSION_TAG,
importmap: JSON.stringify({ importmap: JSON.stringify({

View File

@ -161,9 +161,9 @@
(def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI")) (def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI"))
(def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) (def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/"))
(def grid-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) (def grid-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/"))
(def plugins-list-uri (obj/get global "penpotPluginsListUri" "https://penpot.app/penpothub/plugins")) (def plugins-list-uri (obj/get global "penpotPluginsListURI" "https://penpot.app/penpothub/plugins"))
(def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" []))) (def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" [])))
(def templates-uri (obj/get global "penpotTemplatesUri" "https://penpot.github.io/penpot-files/")) (def templates-uri (obj/get global "penpotTemplatesURI" "https://penpot.github.io/penpot-files/"))
(def upload-chunk-size (obj/get global "penpotUploadChunkSize" (* 1024 1024 25))) ;; 25 MiB (def upload-chunk-size (obj/get global "penpotUploadChunkSize" (* 1024 1024 25))) ;; 25 MiB
;; We set the current parsed flags under common for make ;; We set the current parsed flags under common for make
@ -190,7 +190,10 @@
public-uri)) public-uri))
(def worker-uri (def worker-uri
(obj/get global "penpotWorkerURI" "/js/worker/main.js")) (-> public-uri
(u/join "js/worker/main.js")
(get :path)
(str "?version=" version-tag)))
(defn external-feature-flag (defn external-feature-flag
[flag value] [flag value]

View File

@ -651,3 +651,9 @@
(def progress (def progress
(l/derived :progress st/state)) (l/derived :progress st/state))
(def access-tokens
(l/derived :access-tokens st/state))
(def access-token-created
(l/derived :access-token-created st/state))

View File

@ -5,12 +5,13 @@
// Copyright (c) KALEIDOS INC // Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated; @use "refactor/common-refactor.scss" as deprecated;
@use "ds/z-index.scss" as *;
.context-menu { .context-menu {
position: relative; position: relative;
visibility: hidden; visibility: hidden;
opacity: deprecated.$op-0; opacity: deprecated.$op-0;
z-index: deprecated.$z-index-4; z-index: var(--z-index-dropdown);
&.is-open { &.is-open {
position: relative; position: relative;

View File

@ -6,11 +6,11 @@
@use "refactor/common-refactor.scss" as deprecated; @use "refactor/common-refactor.scss" as deprecated;
@use "common/refactor/common-dashboard"; @use "common/refactor/common-dashboard";
@use "../ds/typography.scss" as t; @use "ds/typography.scss" as t;
@use "../ds/_borders.scss" as *; @use "ds/spacing.scss" as *;
@use "../ds/spacing.scss" as *; @use "ds/z-index.scss" as *;
@use "../ds/_sizes.scss" as *; @use "ds/_borders.scss" as *;
@use "../ds/z-index.scss" as *; @use "ds/_sizes.scss" as *;
.dashboard-container { .dashboard-container {
flex: 1 0 0; flex: 1 0 0;
@ -52,7 +52,7 @@
padding: var(--sp-xxl) var(--sp-xxl) var(--sp-s) var(--sp-xxl); padding: var(--sp-xxl) var(--sp-xxl) var(--sp-s) var(--sp-xxl);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: $z-index-100; z-index: var(--z-index-panels);
} }
.nav-inside { .nav-inside {

View File

@ -6,6 +6,8 @@
@use "refactor/common-refactor.scss" as deprecated; @use "refactor/common-refactor.scss" as deprecated;
@use "common/refactor/common-dashboard"; @use "common/refactor/common-dashboard";
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
.dashboard-container { .dashboard-container {
flex: 1 0 0; flex: 1 0 0;
@ -13,6 +15,7 @@
overflow-y: auto; overflow-y: auto;
width: 100%; width: 100%;
border-top: deprecated.$s-1 solid var(--color-background-quaternary); border-top: deprecated.$s-1 solid var(--color-background-quaternary);
padding-block-end: var(--sp-xxxl);
&.dashboard-projects { &.dashboard-projects {
user-select: none; user-select: none;

View File

@ -15,7 +15,7 @@ $thumbnail-default-height: deprecated.$s-168; // Default width
font-size: deprecated.$fs-14; font-size: deprecated.$fs-14;
height: 100%; height: 100%;
overflow: hidden auto; overflow: hidden auto;
padding: 0 deprecated.$s-16; padding: 0 var(--sp-l) deprecated.$s-16;
} }
.grid-row { .grid-row {

View File

@ -19,16 +19,15 @@
margin-inline-end: var(--sp-l); margin-inline-end: var(--sp-l);
border-block-start: $b-1 solid var(--panel-border-color); border-block-start: $b-1 solid var(--panel-border-color);
overflow-y: auto; overflow-y: auto;
padding-block-end: var(--sp-xxxl);
} }
.dashboard-projects { .dashboard-projects {
user-select: none; user-select: none;
block-size: calc(100vh - px2rem(64)); block-size: calc(100vh - px2rem(80));
} }
.with-team-hero { .with-team-hero {
block-size: calc(100vh - px2rem(280)); block-size: calc(100vh - px2rem(360));
} }
.dashboard-shared { .dashboard-shared {

View File

@ -4,10 +4,11 @@
// //
// Copyright (c) KALEIDOS INC // Copyright (c) KALEIDOS INC
@use "ds/_borders.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as t; @use "ds/typography.scss" as t;
@use "ds/z-index.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
.dashboard-templates-section { .dashboard-templates-section {
background-color: var(--color-background-tertiary); background-color: var(--color-background-tertiary);
@ -25,6 +26,7 @@
transition: inset-block-end 300ms; transition: inset-block-end 300ms;
width: calc(100% - $sz-12); width: calc(100% - $sz-12);
pointer-events: none; pointer-events: none;
z-index: var(--z-index-set);
&.collapsed { &.collapsed {
inset-block-end: calc(-1 * px2rem(228)); inset-block-end: calc(-1 * px2rem(228));

View File

@ -34,15 +34,8 @@
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.forms :as fm] [app.util.forms :as fm]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[okulary.core :as l]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(def tokens-ref
(l/derived :access-tokens st/state))
(def token-created-ref
(l/derived :access-token-created st/state))
(def notification-timeout 7000) (def notification-timeout 7000)
(def ^:private schema:form-access-token (def ^:private schema:form-access-token
@ -78,7 +71,7 @@
(mf/defc token-created* (mf/defc token-created*
{::mf/private true} {::mf/private true}
[{:keys [title mcp-key?]}] [{:keys [title mcp-key?]}]
(let [token-created (mf/deref token-created-ref) (let [token-created (mf/deref refs/access-token-created)
on-copy-to-clipboard on-copy-to-clipboard
(mf/use-fn (mf/use-fn
@ -310,7 +303,7 @@
[] []
(let [created? (mf/use-state false) (let [created? (mf/use-state false)
tokens (mf/deref tokens-ref) tokens (mf/deref refs/access-tokens)
mcp-key (some #(when (= (:type %) "mcp") %) tokens) mcp-key (some #(when (= (:type %) "mcp") %) tokens)
mcp-key-id (:id mcp-key) mcp-key-id (:id mcp-key)
@ -413,7 +406,7 @@
(mf/defc mcp-server-section* (mf/defc mcp-server-section*
{::mf/private true} {::mf/private true}
[] []
(let [tokens (mf/deref tokens-ref) (let [tokens (mf/deref refs/access-tokens)
profile (mf/deref refs/profile) profile (mf/deref refs/profile)
mcp-key (some #(when (= (:type %) "mcp") %) tokens) mcp-key (some #(when (= (:type %) "mcp") %) tokens)
@ -422,6 +415,8 @@
expires-at (:expires-at mcp-key) expires-at (:expires-at mcp-key)
expired? (and (some? expires-at) (> (ct/now) expires-at)) expired? (and (some? expires-at) (> (ct/now) expires-at))
show-enabled? (and mcp-enabled? (false? expired?))
tooltip-id tooltip-id
(mf/use-id) (mf/use-id)
@ -511,14 +506,17 @@
(tr "integrations.mcp-server.status.expired.1")]]]) (tr "integrations.mcp-server.status.expired.1")]]])
[:div {:class (stl/css :mcp-server-switch)} [:div {:class (stl/css :mcp-server-switch)}
[:> switch* {:label (if mcp-enabled? [:> switch* {:label (if show-enabled?
(tr "integrations.mcp-server.status.enabled") (tr "integrations.mcp-server.status.enabled")
(tr "integrations.mcp-server.status.disabled")) (tr "integrations.mcp-server.status.disabled"))
:default-checked mcp-enabled? :default-checked show-enabled?
:on-change handle-mcp-change}] :on-change handle-mcp-change}]
(when (and (false? mcp-enabled?) (nil? mcp-key)) (when (and (false? mcp-enabled?) (nil? mcp-key))
[:div {:class (stl/css :mcp-server-switch-cover) [:div {:class (stl/css :mcp-server-switch-cover)
:on-click handle-generate-mcp-key}])]]] :on-click handle-generate-mcp-key}])
(when (true? expired?)
[:div {:class (stl/css :mcp-server-switch-cover)
:on-click handle-regenerate-mcp-key}])]]]
(when (some? mcp-key) (when (some? mcp-key)
[:div {:class (stl/css :mcp-server-key)} [:div {:class (stl/css :mcp-server-key)}
@ -567,7 +565,7 @@
(mf/defc access-tokens-section* (mf/defc access-tokens-section*
{::mf/private true} {::mf/private true}
[] []
(let [tokens (mf/deref tokens-ref) (let [tokens (mf/deref refs/access-tokens)
handle-click handle-click
(mf/use-fn (mf/use-fn

View File

@ -43,13 +43,9 @@
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[okulary.core :as l]
[potok.v2.core :as ptk] [potok.v2.core :as ptk]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(def tokens-ref
(l/derived :access-tokens st/state))
(mf/defc shortcuts* (mf/defc shortcuts*
{::mf/private true} {::mf/private true}
[{:keys [id]}] [{:keys [id]}]
@ -780,14 +776,22 @@
(mf/defc mcp-menu* (mf/defc mcp-menu*
{::mf/private true} {::mf/private true}
[{:keys [on-close]}] [{:keys [on-close]}]
(let [plugins? (features/active-feature? @st/state "plugins/runtime") (let [plugins? (features/active-feature? @st/state "plugins/runtime")
profile (mf/deref refs/profile) profile (mf/deref refs/profile)
mcp (mf/deref refs/mcp) mcp (mf/deref refs/mcp)
tokens (mf/deref refs/access-tokens)
expired? (some->> tokens
(some #(when (= (:type %) "mcp") %))
:expires-at
(> (ct/now)))
mcp-enabled? (true? (-> profile :props :mcp-enabled)) mcp-enabled? (true? (-> profile :props :mcp-enabled))
mcp-connected? (= "connected" (get mcp :connection-status)) mcp-connected? (= "connected" (get mcp :connection-status))
show-enabled? (and mcp-enabled? (false? expired?))
on-nav-to-integrations on-nav-to-integrations
(mf/use-fn (mf/use-fn
(fn [] (fn []
@ -825,7 +829,7 @@
:pos-6 plugins?) :pos-6 plugins?)
:on-close on-close} :on-close on-close}
(when mcp-enabled? (when show-enabled?
[:> dropdown-menu-item* {:id "mcp-menu-toggle-mcp-plugin" [:> dropdown-menu-item* {:id "mcp-menu-toggle-mcp-plugin"
:class (stl/css :base-menu-item :submenu-item) :class (stl/css :base-menu-item :submenu-item)
:on-click on-toggle-mcp-plugin :on-click on-toggle-mcp-plugin
@ -840,7 +844,7 @@
:on-click on-nav-to-integrations :on-click on-nav-to-integrations
:on-key-down on-nav-to-integrations-key-down} :on-key-down on-nav-to-integrations-key-down}
[:span {:class (stl/css :item-name)} [:span {:class (stl/css :item-name)}
(if mcp-enabled? (if show-enabled?
(tr "workspace.header.menu.mcp.server.status.enabled") (tr "workspace.header.menu.mcp.server.status.enabled")
(tr "workspace.header.menu.mcp.server.status.disabled"))]]])) (tr "workspace.header.menu.mcp.server.status.disabled"))]]]))
@ -1014,7 +1018,7 @@
:class (stl/css :item-arrow)}]]) :class (stl/css :item-arrow)}]])
(when (contains? cf/flags :mcp) (when (contains? cf/flags :mcp)
(let [tokens (mf/deref tokens-ref) (let [tokens (mf/deref refs/access-tokens)
expired? (some->> tokens expired? (some->> tokens
(some #(when (= (:type %) "mcp") %)) (some #(when (= (:type %) "mcp") %))
:expires-at :expires-at

View File

@ -115,7 +115,8 @@
result)) result))
result)))] result)))]
[:div {:class (stl/css :element-list) :data-testid "layer-item"} [:div {:class (stl/css :element-list)
:data-testid "layer-item"}
[:> hooks/sortable-container* {} [:> hooks/sortable-container* {}
(for [obj shapes] (for [obj shapes]
(if (cfh/frame-shape? obj) (if (cfh/frame-shape? obj)

View File

@ -69,12 +69,11 @@
;; Unique color attribute maps ;; Unique color attribute maps
all-colors (distinct (mapv :attrs data)) all-colors (distinct (mapv :attrs data))
;; Split into: library colors, token colors, and plain colors ;; Split into mutually exclusive groups:
library-colors (filterv :ref-id all-colors) ;; token-colors take priority; library-colors and plain colors exclude tokens
token-colors (filterv :token-name all-colors) token-colors (filterv :token-name all-colors)
colors (filterv #(and (nil? (:ref-id %)) library-colors (filterv (fn [c] (and (some? (:ref-id c)) (nil? (:token-name c)))) all-colors)
(not (:token-name %))) colors (filterv (fn [c] (and (nil? (:ref-id c)) (nil? (:token-name c)))) all-colors)]
all-colors)]
{:groups groups {:groups groups
:all-colors all-colors :all-colors all-colors
:colors colors :colors colors
@ -243,8 +242,7 @@
[:div {:class (stl/css :selected-color-group)} [:div {:class (stl/css :selected-color-group)}
(let [token-color-extract (cond->> token-colors (not @expand-token-color) (take 3))] (let [token-color-extract (cond->> token-colors (not @expand-token-color) (take 3))]
(for [[index token-color] (d/enumerate token-color-extract)] (for [[index token-color] (d/enumerate token-color-extract)]
(let [color {:color (:color token-color) (let [color (dissoc token-color :token-name :has-token-applied)]
:opacity (:opacity token-color)}]
[:> color-row* [:> color-row*
{:key index {:key index
:color color :color color

View File

@ -194,7 +194,8 @@
(dom/set-attribute! checkbox "indeterminate" true) (dom/set-attribute! checkbox "indeterminate" true)
(dom/remove-attribute! checkbox "indeterminate")))) (dom/remove-attribute! checkbox "indeterminate"))))
[:section {:class (stl/css :fill-section) :aria-label (tr "workspace.options.fill.section")} [:section {:class (stl/css :fill-section)
:aria-label (tr "workspace.options.fill.section")}
[:div {:class (stl/css :fill-title)} [:div {:class (stl/css :fill-title)}
[:> title-bar* {:collapsable has-fills? [:> title-bar* {:collapsable has-fills?
:collapsed (not open?) :collapsed (not open?)

View File

@ -186,7 +186,7 @@
:shape-ids ids}))))] :shape-ids ids}))))]
[:section {:class (stl/css :stroke-section) [:section {:class (stl/css :stroke-section)
:aria-label "stroke-section"} :aria-label "Stroke section"}
[:div {:class (stl/css :stroke-title)} [:div {:class (stl/css :stroke-title)}
[:> title-bar* {:collapsable has-strokes? [:> title-bar* {:collapsable has-strokes?
:collapsed (not open?) :collapsed (not open?)

View File

@ -30,7 +30,7 @@
[app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.hooks :as hooks] [app.main.ui.hooks :as hooks]
[app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]] [app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]]
[app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options typography-entry]] [app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry]]
[app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
@ -429,10 +429,12 @@
(when (not= "INPUT" (-> (dom/get-active) dom/get-tag-name)) (when (not= "INPUT" (-> (dom/get-active) dom/get-tag-name))
(dom/focus! (txu/get-text-editor-content))))))) (dom/focus! (txu/get-text-editor-content)))))))
common-props opts (mf/props
(mf/props {:values values {:ids ids
:on-change on-change :values values
:on-blur on-text-blur})] :on-change on-change
:show-recent true
:on-blur on-text-blur})]
(hooks/use-stream (hooks/use-stream
expand-stream expand-stream
@ -496,11 +498,7 @@
:icon i/detach}]] :icon i/detach}]]
:else :else
[:> text-options #js {:ids ids [:> text-options* opts])
:values values
:on-change on-change
:show-recent true
:on-blur on-text-blur}])
[:div {:class (stl/css :text-align-options)} [:div {:class (stl/css :text-align-options)}
[:> text-align-options* common-props] [:> text-align-options* common-props]

View File

@ -451,8 +451,7 @@
:value "lowercase" :value "lowercase"
:id "text-transform-lowercase"}]]])) :id "text-transform-lowercase"}]]]))
(mf/defc text-options (mf/defc text-options*
{::mf/wrap-props false}
[{:keys [ids editor values on-change on-blur show-recent]}] [{:keys [ids editor values on-change on-blur show-recent]}]
(let [full-size-selector? (and show-recent (= (mf/use-ctx ctx/sidebar) :right)) (let [full-size-selector? (and show-recent (= (mf/use-ctx ctx/sidebar) :right))
opts #js {:editor editor opts #js {:editor editor
@ -541,9 +540,9 @@
:on-click on-close :on-click on-close
:icon i/tick}]]] :icon i/tick}]]]
[:& text-options {:values typography [:> text-options* {:values typography
:on-change on-change :on-change on-change
:show-recent false}]] :show-recent false}]]
[:div {:class (stl/css :typography-info-wrapper)} [:div {:class (stl/css :typography-info-wrapper)}
[:div {:class (stl/css :typography-name-wrapper)} [:div {:class (stl/css :typography-name-wrapper)}

View File

@ -240,7 +240,16 @@
open-modal open-modal
(mf/use-fn (mf/use-fn
(mf/deps disable-gradient disable-opacity disable-image disable-picker on-change on-close on-open tokens index applied-token) (mf/deps disable-gradient
disable-opacity
disable-image
disable-picker
on-change
on-close
on-open
tokens
index
applied-token)
(fn [color pos tab] (fn [color pos tab]
(let [color (cond (let [color (cond
^boolean has-multiple-colors ^boolean has-multiple-colors
@ -345,6 +354,11 @@
(mf/with-effect [color prev-color disable-picker] (mf/with-effect [color prev-color disable-picker]
(when (and (not disable-picker) (not= prev-color color)) (when (and (not disable-picker) (not= prev-color color))
(modal/update-props! :colorpicker {:data (parse-color color)}))) (modal/update-props! :colorpicker {:data (parse-color color)})))
(mf/with-effect [applied-token disable-picker]
(when (not disable-picker)
(modal/update-props! :colorpicker {:applied-token applied-token})))
[:div {:class [class row-class]} [:div {:class [class row-class]}
;; Drag handler ;; Drag handler
(when (some? on-reorder) (when (some? on-reorder)
@ -436,4 +450,5 @@
[:> icon-button* {:variant "ghost" [:> icon-button* {:variant "ghost"
:aria-label (tr "settings.select-this-color") :aria-label (tr "settings.select-this-color")
:on-click handle-select :on-click handle-select
:tooltip-position "top-left"
:icon i/move}])])) :icon i/move}])]))

View File

@ -627,6 +627,7 @@
(if (empty? fills) (if (empty? fills)
(h/call wasm/internal-module "_clear_shape_fills") (h/call wasm/internal-module "_clear_shape_fills")
(let [fills (types.fills/coerce fills) (let [fills (types.fills/coerce fills)
image-ids (types.fills/get-image-ids fills)
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills)) offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
heap (mem/get-heap-u32)] heap (mem/get-heap-u32)]
@ -648,7 +649,7 @@
(when (zero? cached-image?) (when (zero? cached-image?)
(fetch-image shape-id id thumbnail?)))) (fetch-image shape-id id thumbnail?))))
(types.fills/get-image-ids fills))))) image-ids))))
(defn set-shape-strokes (defn set-shape-strokes
[shape-id strokes thumbnail?] [shape-id strokes thumbnail?]
@ -676,7 +677,8 @@
(some? gradient) (some? gradient)
(do (do
(types.fills.impl/write-gradient-fill offset dview opacity gradient) (types.fills.impl/write-gradient-fill offset dview opacity gradient)
(h/call wasm/internal-module "_add_shape_stroke_fill")) (h/call wasm/internal-module "_add_shape_stroke_fill")
nil)
(some? image) (some? image)
(let [image-id (get image :id) (let [image-id (get image :id)
@ -689,11 +691,13 @@
(h/call wasm/internal-module "_add_shape_stroke_fill") (h/call wasm/internal-module "_add_shape_stroke_fill")
(when (== cached-image? 0) (when (== cached-image? 0)
(fetch-image shape-id image-id thumbnail?))) (fetch-image shape-id image-id thumbnail?)))
(some? color) (some? color)
(do (do
(types.fills.impl/write-solid-fill offset dview opacity color) (types.fills.impl/write-solid-fill offset dview opacity color)
(h/call wasm/internal-module "_add_shape_stroke_fill")))))) (h/call wasm/internal-module "_add_shape_stroke_fill")
nil)))))
strokes)) strokes))
(defn set-shape-svg-attrs (defn set-shape-svg-attrs
@ -1303,16 +1307,17 @@
(when (or (seq pending-thumbnails) (seq pending-full)) (when (or (seq pending-thumbnails) (seq pending-full))
(->> (rx/concat (->> (rx/concat
(->> (rx/from (vals pending-thumbnails)) (->> (rx/from (vals pending-thumbnails))
(rx/merge-map (fn [callback] (callback))) (rx/merge-map
(fn [callback]
(if (fn? callback) (callback) (rx/empty))))
(rx/reduce conj [])) (rx/reduce conj []))
(->> (rx/from (vals pending-full)) (->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (callback))) (rx/mapcat
(fn [callback]
(if (fn? callback) (callback) (rx/empty))))
(rx/reduce conj []))) (rx/reduce conj [])))
(rx/subs! (rx/subs!
(fn [_] (fn [_]
;; Fonts are now loaded — recompute text
;; layouts so Skia uses the real metrics
;; instead of fallback-font estimates.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids) (when (seq text-ids)
(update-text-layouts text-ids))) (update-text-layouts text-ids)))

View File

@ -132,7 +132,9 @@
"Maps attrs to styles" "Maps attrs to styles"
[styles] [styles]
(let [mapped-styles (let [mapped-styles
(into {} (map attr->style styles))] (into {} (comp (filter (fn [[_ v]] (some? v)))
(map attr->style))
styles)]
(clj->js mapped-styles))) (clj->js mapped-styles)))
(defn style-needs-mapping? (defn style-needs-mapping?
@ -199,12 +201,14 @@
(let [style-name (get-style-name-as-css-variable k) (let [style-name (get-style-name-as-css-variable k)
[_ style-decode] (get mapping k) [_ style-decode] (get mapping k)
style-value (.getPropertyValue style-declaration style-name)] style-value (.getPropertyValue style-declaration style-name)]
(when (or (not removed-mixed) (not (contains? mixed-values style-value))) (if (or (not removed-mixed) (not (contains? mixed-values style-value)))
(assoc acc k (style-decode style-value)))) (assoc acc k (style-decode style-value))
acc))
(let [style-name (get-style-name k) (let [style-name (get-style-name k)
style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))] style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))]
(when (or (not removed-mixed) (not (contains? mixed-values style-value))) (if (or (not removed-mixed) (not (contains? mixed-values style-value)))
(assoc acc k style-value))))) {} txt/text-style-attrs)) (assoc acc k style-value)
acc)))) {} txt/text-style-attrs))
(defn get-styles-from-event (defn get-styles-from-event
"Returns a ClojureScript object compatible with text nodes" "Returns a ClojureScript object compatible with text nodes"

View File

@ -90,8 +90,8 @@
"Return a initialized webworker instance." "Return a initialized webworker instance."
[path on-error] [path on-error]
(let [instance (js/Worker. path) (let [instance (js/Worker. path)
bus (rx/subject) bus (rx/subject)
worker (Worker. instance (rx/to-observable bus)) worker (Worker. instance (rx/to-observable bus))
handle-message handle-message
(fn [event] (fn [event]

View File

@ -26,6 +26,7 @@ Penpot:
props?: { [key: string]: unknown }, props?: { [key: string]: unknown },
): symbol; ): symbol;
off(listenerId: symbol): void; off(listenerId: symbol): void;
version: string;
root: Shape | null; root: Shape | null;
currentFile: File | null; currentFile: File | null;
currentPage: Page | null; currentPage: Page | null;
@ -72,7 +73,7 @@ Penpot:
generateFontFaces(shapes: Shape[]): Promise<string>; generateFontFaces(shapes: Shape[]): Promise<string>;
openViewer(): void; openViewer(): void;
createPage(): Page; createPage(): Page;
openPage(page: Page, newWindow?: boolean): void; openPage(page: string | Page, newWindow?: boolean): void;
alignHorizontal( alignHorizontal(
shapes: Shape[], shapes: Shape[],
direction: "center" | "left" | "right", direction: "center" | "left" | "right",
@ -162,6 +163,12 @@ Penpot:
``` ```
penpot.closePlugin(); penpot.closePlugin();
``` ```
version: |-
```
readonly version: string
```
Returns the current penpot version.
root: |- root: |-
``` ```
readonly root: Shape | null readonly root: Shape | null
@ -725,19 +732,19 @@ Penpot:
Returns Page Returns Page
openPage: |- openPage: |-
``` ```
openPage(page: Page, newWindow?: boolean): void openPage(page: string | Page, newWindow?: boolean): void
``` ```
Changes the current open page to given page. Requires `content:read` permission. Changes the current open page to given page. Requires `content:read` permission.
Parameters Parameters
* page: Page * page: string | Page
the page to open the page to open (a Page object or a page UUID string)
* newWindow: boolean * newWindow: boolean
if true opens the page in a new window if true opens the page in a new window, defaults to false
Returns void Returns void
@ -4785,6 +4792,7 @@ Context:
``` ```
interface Context { interface Context {
version: string;
root: Shape | null; root: Shape | null;
currentFile: File | null; currentFile: File | null;
currentPage: Page | null; currentPage: Page | null;
@ -4837,7 +4845,7 @@ Context:
removeListener(listenerId: symbol): void; removeListener(listenerId: symbol): void;
openViewer(): void; openViewer(): void;
createPage(): Page; createPage(): Page;
openPage(page: Page, newWindow?: boolean): void; openPage(page: string | Page, newWindow?: boolean): void;
alignHorizontal( alignHorizontal(
shapes: Shape[], shapes: Shape[],
direction: "center" | "left" | "right", direction: "center" | "left" | "right",
@ -4854,6 +4862,12 @@ Context:
``` ```
members: members:
Properties: Properties:
version: |-
```
readonly version: string
```
Returns the current penpot version.
root: |- root: |-
``` ```
readonly root: Shape | null readonly root: Shape | null
@ -5392,19 +5406,19 @@ Context:
Returns Page Returns Page
openPage: |- openPage: |-
``` ```
openPage(page: Page, newWindow?: boolean): void openPage(page: string | Page, newWindow?: boolean): void
``` ```
Changes the current open page to given page. Requires `content:read` permission. Changes the current open page to given page. Requires `content:read` permission.
Parameters Parameters
* page: Page * page: string | Page
the page to open the page to open (a Page object or a page UUID string)
* newWindow: boolean * newWindow: boolean
if true opens the page in a new window if true opens the page in a new window, defaults to false
Returns void Returns void
@ -6845,7 +6859,7 @@ Export:
``` ```
interface Export { interface Export {
type: "svg" | "png" | "jpeg" | "pdf"; type: "svg" | "png" | "jpeg" | "webp" | "pdf";
scale?: number; scale?: number;
suffix?: string; suffix?: string;
skipChildren?: boolean; skipChildren?: boolean;
@ -6857,10 +6871,10 @@ Export:
Properties: Properties:
type: |- type: |-
``` ```
type: "svg" | "png" | "jpeg" | "pdf" type: "svg" | "png" | "jpeg" | "webp" | "pdf"
``` ```
Type of the file to export. Can be one of the following values: png, jpeg, svg, pdf Type of the file to export. Can be one of the following values: png, jpeg, webp, svg, pdf
scale: |- scale: |-
``` ```
scale?: number scale?: number
@ -7249,6 +7263,7 @@ Flags:
``` ```
interface Flags { interface Flags {
naturalChildOrdering: boolean; naturalChildOrdering: boolean;
throwValidationErrors: boolean;
} }
``` ```
@ -7264,6 +7279,14 @@ Flags:
Also, appendChild method will be append the children in the top-most position. Also, appendChild method will be append the children in the top-most position.
The insertchild method is changed acordingly to respect this ordering. The insertchild method is changed acordingly to respect this ordering.
Defaults to false Defaults to false
throwValidationErrors: |-
```
throwValidationErrors: boolean
```
If `true` the validation errors will throw an exception instead of displaying an
error in the debugger console.
Defaults to false
FlexLayout: FlexLayout:
overview: |- overview: |-
Interface FlexLayout Interface FlexLayout

View File

@ -16,9 +16,9 @@
"fmt": "./scripts/fmt" "fmt": "./scripts/fmt"
}, },
"devDependencies": { "devDependencies": {
"@github/copilot": "^1.0.21", "@github/copilot": "^1.0.35",
"@types/node": "^25.5.2", "@types/node": "^25.6.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"opencode-ai": "^1.14.19" "opencode-ai": "^1.14.22"
} }
} }

182
pnpm-lock.yaml generated
View File

@ -9,17 +9,17 @@ importers:
.: .:
devDependencies: devDependencies:
'@github/copilot': '@github/copilot':
specifier: ^1.0.21 specifier: ^1.0.35
version: 1.0.21 version: 1.0.35
'@types/node': '@types/node':
specifier: ^25.5.2 specifier: ^25.6.0
version: 25.5.2 version: 25.6.0
esbuild: esbuild:
specifier: ^0.28.0 specifier: ^0.28.0
version: 0.28.0 version: 0.28.0
opencode-ai: opencode-ai:
specifier: ^1.14.19 specifier: ^1.14.22
version: 1.14.19 version: 1.14.22
packages: packages:
@ -179,120 +179,120 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@github/copilot-darwin-arm64@1.0.21': '@github/copilot-darwin-arm64@1.0.35':
resolution: {integrity: sha512-aB+s9ldTwcyCOYmzjcQ4SknV6g81z92T8aUJEJZBwOXOTBeWKAJtk16ooAKangZgdwuLgO3or1JUjx1FJAm5nQ==} resolution: {integrity: sha512-NNZE0TOz0HOlv7eqlh6EcQbNkhtnIHReBLieW6pfDUUTKkgsqbUu1MOitF8m+LUQk3ml1T0MQ5MOfad1HSa/MQ==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
hasBin: true hasBin: true
'@github/copilot-darwin-x64@1.0.21': '@github/copilot-darwin-x64@1.0.35':
resolution: {integrity: sha512-aNad81DOGuGShmaiFNIxBUSZLwte0dXmDYkGfAF9WJIgY4qP4A8CPWFoNr8//gY+4CwaIf9V+f/OC6k2BdECbw==} resolution: {integrity: sha512-XCv/mfdv0rnrtrNVOluio/N/kyCge0uG2hghvtlgO/+z6EjvzFygkpXXS1gVxiXhWc3lX232cTXQU3zklC/8Ng==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
hasBin: true hasBin: true
'@github/copilot-linux-arm64@1.0.21': '@github/copilot-linux-arm64@1.0.35':
resolution: {integrity: sha512-FL0NsCnHax4czHVv1S8iBqPLGZDhZ28N3+6nT29xWGhmjBWTkIofxLThKUPcyyMsfPTTxIlrdwWa8qQc5z2Q+g==} resolution: {integrity: sha512-mbaadATfJPzmXq2SD1TWocIG/GobcYC6OvNFhCG8UXMsiXY5cevhszl5ujuayhPJBxS77Yj5uvIFjNQ1Kf5V8Q==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
hasBin: true hasBin: true
'@github/copilot-linux-x64@1.0.21': '@github/copilot-linux-x64@1.0.35':
resolution: {integrity: sha512-S7pWVI16hesZtxYbIyfw+MHZpc5ESoGKUVr5Y+lZJNaM2340gJGPQzQwSpvKIRMLHRKI2hXLwciAnYeMFxE/Tg==} resolution: {integrity: sha512-NrZ0VjztdBbJ5qAmuUtuKsWkimOaqzjDV+ZGUv1FxSxoys40kiiakQ5WbnMFDzaIFaf47zDi++6ixgQzq7Jk5A==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
hasBin: true hasBin: true
'@github/copilot-win32-arm64@1.0.21': '@github/copilot-win32-arm64@1.0.35':
resolution: {integrity: sha512-a9qc2Ku+XbyBkXCclbIvBbIVnECACTIWnPctmXWsQeSdeapGxgfHGux7y8hAFV5j6+nhCm6cnyEMS3rkZjAhdA==} resolution: {integrity: sha512-KQN7Q7+oPyglmvUEiMp6SYWjl30VSu91T0dUpNHbUs/xRM3qgnCymLPPUyBZGWHog/FueUAsRkhisMHWQVnO+g==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
hasBin: true hasBin: true
'@github/copilot-win32-x64@1.0.21': '@github/copilot-win32-x64@1.0.35':
resolution: {integrity: sha512-9klu+7NQ6tEyb8sibb0rsbimBivDrnNltZho10Bgbf1wh3o+erTjffXDjW9Zkyaw8lZA9Fz8bqhVkKntZq58Lg==} resolution: {integrity: sha512-J0XhXO2FmlFr8pGa970xEd4tr1rqFiZxoaPW5WvkJYZoZUHbBhFcGasp5/yEeJ71b3vI4PHm/mSZZebD3ALMKQ==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
hasBin: true hasBin: true
'@github/copilot@1.0.21': '@github/copilot@1.0.35':
resolution: {integrity: sha512-P+nORjNKAtl92jYCG6Qr1Rsw2JoyScgeQSkIR6O2WB37WS5JVdA4ax1WVualMbfuc9V58CPHX6fwyNpkI89FkQ==} resolution: {integrity: sha512-O1nUy8DXOTE+v86b/FTkyu09EMrDy+vj+2rhmUOcmsXGe0RE5ECyESsasUTUoHK/CSgAExFTziNxbubUoiMMfg==}
hasBin: true hasBin: true
'@types/node@25.5.2': '@types/node@25.6.0':
resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
esbuild@0.28.0: esbuild@0.28.0:
resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
opencode-ai@1.14.19: opencode-ai@1.14.22:
resolution: {integrity: sha512-67h56qYcJivd2U9VK8LJvyMBCc3ZE3HcJ/qL4YtaidSnjEumy4SxO+HHlDISsLase7TUQ0w1nULOAibdZxGzbQ==} resolution: {integrity: sha512-J+q1Ehlfg7SSXw2aIY8Mb47FHhPTN8IciKNt0/D+H/brO8RWLe67WjFzxhh/z9SSad9wPcCiLRGAc/iAn8W8wA==}
hasBin: true hasBin: true
opencode-darwin-arm64@1.14.19: opencode-darwin-arm64@1.14.22:
resolution: {integrity: sha512-gjJ97dTiBbCas3Y7K6KQMQm5/KNIBOM9+10KZHqNr2bQfn0N09O977ZkXoX6IVBBE2632Ahaa71d4pzLKN9ULw==} resolution: {integrity: sha512-h9FjzNoDRsuJD0EEg535P9ul5TyrWovwx591VmuG8fp9d4PoSrAN1O3Zi07GJjkrYyrB8g3c+x5whDqJCz+qog==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
opencode-darwin-x64-baseline@1.14.19: opencode-darwin-x64-baseline@1.14.22:
resolution: {integrity: sha512-UuwLpa511h7qQ+rTmOUmsHch/N4NBQT64dzg+iLWnR5yoR/81inENpbwxiS7hXpVdzCUGo/YnxI1u6SIBoMlTQ==} resolution: {integrity: sha512-GgfP0wSm9/I+j3shOxfeA++7yZpXS6Y1Vis258nEFoRS9Xfv3YlHom7c/8BR9rYqeUE/+rrijP7PrGWGl+IHBw==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
opencode-darwin-x64@1.14.19: opencode-darwin-x64@1.14.22:
resolution: {integrity: sha512-uksrjOtWI7Ob5JvjZBSsrKuy3JVF9d89oYZYfWS5m8ordNyv1Nob39MXJXizv85ozsXjSb0rqjpJurnJw8K+tQ==} resolution: {integrity: sha512-cyKRo22sxDwu4ITOlENwXaqVM9kMGndwSaAd95gz1Rmz5NYMShUO/8eckrD2MhS2wm+QvKw9XkRVWVHWQlZw3Q==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
opencode-linux-arm64-musl@1.14.19: opencode-linux-arm64-musl@1.14.22:
resolution: {integrity: sha512-R1BJuBGWHfBxfKvIA/Hb4nhYaJgCKl1B+mAGNydu+z0CLtGtwU8r+kQWF/G2N0y8Vx6Y6DRfJiv1X0eZEfD1BQ==} resolution: {integrity: sha512-DtSd5tbGk6R5+hGhqViSvbY8ICf+u4oVQhfvCAplQCb1UEwYVc0+oAF6PimFJ+o8i8L6x14O0rry0NaRzZ0CzA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
opencode-linux-arm64@1.14.19: opencode-linux-arm64@1.14.22:
resolution: {integrity: sha512-Bo+aZOppLF366mgGfK0CnIcAVy1EmsrBv93eot1CmPSN1oeud07GpGdn3Bjl5f6KuBx1io6JsvjQyHno+MH5AA==} resolution: {integrity: sha512-ohK4LkkGvzB4ptr0nqDOVi2JEJMLROfy1s2U2A4Qrh+1Y0QimgH2b5VgTm+BjA3bC2Hm8Yf/IfkitqlUnCp7YA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
opencode-linux-x64-baseline-musl@1.14.19: opencode-linux-x64-baseline-musl@1.14.22:
resolution: {integrity: sha512-+K8MuGoHugtUec4P/nKcTwZFUipHfW7oPpwlIoPiAQou3bNFTzzP6rslbzzNwjXlQRsUw9GAtuIPDOCL6CkgDg==} resolution: {integrity: sha512-oZffotEbGXbA38Y0Dmj7IVq0ATl3nKbP8j91Z0zR5kBEBykOqExJIyc9pZpModgfPf86k98XBsRHiVLK4u9ARw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
opencode-linux-x64-baseline@1.14.19: opencode-linux-x64-baseline@1.14.22:
resolution: {integrity: sha512-KuvITzg4iK0hdIjpNZepwu3bLZ/dUZDI6BwCoV4w//VEP1j3UfDyeS3vWghKcQLd2T1+yybuEMM/3RXcwm/lGQ==} resolution: {integrity: sha512-J67YAIWr3E03o9e6wNaPEqBo+9FcPKf5CzjIUSb8yNDyobWON1HHihcuu0hCJ6wF9J9awmlp2/4mO1HOoCo3QQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
opencode-linux-x64-musl@1.14.19: opencode-linux-x64-musl@1.14.22:
resolution: {integrity: sha512-0qe2+X76UJdrCdhdlJyfubMC4tveHAVxjSmPq7g9Zm95heBeJdcQDCLeyQk/lGgeXgsZzVPfLmyTWNtBvCZYFQ==} resolution: {integrity: sha512-r+QnqwR/OPmMm197Kb8VLD9mkZGFXz4m5QCZFxOAL34k8AhQZqn3d2mx2bfrMBVfoSiSVxa3jEjZEbNNFGlICQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
opencode-linux-x64@1.14.19: opencode-linux-x64@1.14.22:
resolution: {integrity: sha512-2GljfL7BeG4xALBJVRwaAGCM/dzYF5aQf6bfLTKsQIl6QpLUguYSF+fkStBHLeehyqbDP5MtiEEuXjC0+mecjA==} resolution: {integrity: sha512-MSUaO/Cvfb8DFRYETVrVeCnKtoIfgLflyB+O8xQOkVtjMKJ41M+1dFSMyZ3LQa2Vfp5tDskyMhj7eUxvT/owgQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
opencode-windows-arm64@1.14.19: opencode-windows-arm64@1.14.22:
resolution: {integrity: sha512-8/vRHe5tHexikfPceLmpjsQiEhuDTOSCSlEmP4s0Yq3UAkVaDAxpiWq7Bx4g8hjr1gzfXD9vbjV+WHq/BtMC/w==} resolution: {integrity: sha512-8grcxLSf9BD9Bt38MIxXfkI6aOFophVgM0US5r8nAUdVU78/8TS9Flnn6D39GM5RmxzqGWMl1u10vMFrBtMwPA==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
opencode-windows-x64-baseline@1.14.19: opencode-windows-x64-baseline@1.14.22:
resolution: {integrity: sha512-Z8imEJK/srE/r1fr7oNLvpLTeRJQyuL7vsbXvCt3T7j2Ew9BOZ7RuYa8EE0R6bNqQ+MLhBGPiAG7NWc++MgK8Q==} resolution: {integrity: sha512-R/o36LpmQmbv/tL2pkcmApn6030z/1oJIYmjDkW5a4K5MXmV7aq+jWrH5p6iYKp9fo9L8oCtOp/rELMBqDS3UA==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
opencode-windows-x64@1.14.19: opencode-windows-x64@1.14.22:
resolution: {integrity: sha512-/TqGN91WiUzx7IPMPwmpMIzRixi5TMjaBcC9FH1TgD7DCqKnP6pokvu+ak0C9xwA4wKorE9PoZzeRt/+c3rDCQ==} resolution: {integrity: sha512-jVbZ4VA5b5MF2QhWQOE1VYBKdBE0v/ZebFjwzs6Vieazfgr6OFnGSHVP5WJbU/r6zDssbTBzzpnFxo0IY1SQWw==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
undici-types@7.18.2: undici-types@7.19.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
snapshots: snapshots:
@ -374,36 +374,36 @@ snapshots:
'@esbuild/win32-x64@0.28.0': '@esbuild/win32-x64@0.28.0':
optional: true optional: true
'@github/copilot-darwin-arm64@1.0.21': '@github/copilot-darwin-arm64@1.0.35':
optional: true optional: true
'@github/copilot-darwin-x64@1.0.21': '@github/copilot-darwin-x64@1.0.35':
optional: true optional: true
'@github/copilot-linux-arm64@1.0.21': '@github/copilot-linux-arm64@1.0.35':
optional: true optional: true
'@github/copilot-linux-x64@1.0.21': '@github/copilot-linux-x64@1.0.35':
optional: true optional: true
'@github/copilot-win32-arm64@1.0.21': '@github/copilot-win32-arm64@1.0.35':
optional: true optional: true
'@github/copilot-win32-x64@1.0.21': '@github/copilot-win32-x64@1.0.35':
optional: true optional: true
'@github/copilot@1.0.21': '@github/copilot@1.0.35':
optionalDependencies: optionalDependencies:
'@github/copilot-darwin-arm64': 1.0.21 '@github/copilot-darwin-arm64': 1.0.35
'@github/copilot-darwin-x64': 1.0.21 '@github/copilot-darwin-x64': 1.0.35
'@github/copilot-linux-arm64': 1.0.21 '@github/copilot-linux-arm64': 1.0.35
'@github/copilot-linux-x64': 1.0.21 '@github/copilot-linux-x64': 1.0.35
'@github/copilot-win32-arm64': 1.0.21 '@github/copilot-win32-arm64': 1.0.35
'@github/copilot-win32-x64': 1.0.21 '@github/copilot-win32-x64': 1.0.35
'@types/node@25.5.2': '@types/node@25.6.0':
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.19.2
esbuild@0.28.0: esbuild@0.28.0:
optionalDependencies: optionalDependencies:
@ -434,55 +434,55 @@ snapshots:
'@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-ia32': 0.28.0
'@esbuild/win32-x64': 0.28.0 '@esbuild/win32-x64': 0.28.0
opencode-ai@1.14.19: opencode-ai@1.14.22:
optionalDependencies: optionalDependencies:
opencode-darwin-arm64: 1.14.19 opencode-darwin-arm64: 1.14.22
opencode-darwin-x64: 1.14.19 opencode-darwin-x64: 1.14.22
opencode-darwin-x64-baseline: 1.14.19 opencode-darwin-x64-baseline: 1.14.22
opencode-linux-arm64: 1.14.19 opencode-linux-arm64: 1.14.22
opencode-linux-arm64-musl: 1.14.19 opencode-linux-arm64-musl: 1.14.22
opencode-linux-x64: 1.14.19 opencode-linux-x64: 1.14.22
opencode-linux-x64-baseline: 1.14.19 opencode-linux-x64-baseline: 1.14.22
opencode-linux-x64-baseline-musl: 1.14.19 opencode-linux-x64-baseline-musl: 1.14.22
opencode-linux-x64-musl: 1.14.19 opencode-linux-x64-musl: 1.14.22
opencode-windows-arm64: 1.14.19 opencode-windows-arm64: 1.14.22
opencode-windows-x64: 1.14.19 opencode-windows-x64: 1.14.22
opencode-windows-x64-baseline: 1.14.19 opencode-windows-x64-baseline: 1.14.22
opencode-darwin-arm64@1.14.19: opencode-darwin-arm64@1.14.22:
optional: true optional: true
opencode-darwin-x64-baseline@1.14.19: opencode-darwin-x64-baseline@1.14.22:
optional: true optional: true
opencode-darwin-x64@1.14.19: opencode-darwin-x64@1.14.22:
optional: true optional: true
opencode-linux-arm64-musl@1.14.19: opencode-linux-arm64-musl@1.14.22:
optional: true optional: true
opencode-linux-arm64@1.14.19: opencode-linux-arm64@1.14.22:
optional: true optional: true
opencode-linux-x64-baseline-musl@1.14.19: opencode-linux-x64-baseline-musl@1.14.22:
optional: true optional: true
opencode-linux-x64-baseline@1.14.19: opencode-linux-x64-baseline@1.14.22:
optional: true optional: true
opencode-linux-x64-musl@1.14.19: opencode-linux-x64-musl@1.14.22:
optional: true optional: true
opencode-linux-x64@1.14.19: opencode-linux-x64@1.14.22:
optional: true optional: true
opencode-windows-arm64@1.14.19: opencode-windows-arm64@1.14.22:
optional: true optional: true
opencode-windows-x64-baseline@1.14.19: opencode-windows-x64-baseline@1.14.22:
optional: true optional: true
opencode-windows-x64@1.14.19: opencode-windows-x64@1.14.22:
optional: true optional: true
undici-types@7.18.2: {} undici-types@7.19.2: {}

View File

@ -720,26 +720,24 @@ impl RenderState {
self.surfaces.clear_cache(self.background_color); self.surfaces.clear_cache(self.background_color);
self.cache_cleared_this_render = true; self.cache_cleared_this_render = true;
} }
let tile_rect = self.get_current_aligned_tile_bounds()?;
// In fast mode the viewport is moving (pan/zoom) so Cache surface // In fast mode the viewport is moving (pan/zoom) so Cache surface
// positions would be wrong — only save to the tile HashMap. // positions would be wrong — only save to the tile HashMap.
let tile_rect = self.get_current_aligned_tile_bounds()?;
let current_tile = *self
.current_tile
.as_ref()
.ok_or(Error::CriticalError("Current tile not found".to_string()))?;
self.surfaces.cache_current_tile_texture( self.surfaces.cache_current_tile_texture(
&mut self.gpu_state, &mut self.gpu_state,
&self.tile_viewbox, &self.tile_viewbox,
&self &current_tile,
.current_tile
.ok_or(Error::CriticalError("Current tile not found".to_string()))?,
&tile_rect, &tile_rect,
fast_mode, fast_mode,
self.render_area, self.render_area,
); );
self.surfaces.draw_cached_tile_surface( self.surfaces
self.current_tile .draw_cached_tile_surface(current_tile, rect, self.background_color);
.ok_or(Error::CriticalError("Current tile not found".to_string()))?,
rect,
self.background_color,
);
Ok(()) Ok(())
} }
@ -1674,6 +1672,12 @@ impl RenderState {
self.cache_cleared_this_render = false; self.cache_cleared_this_render = false;
self.reset_canvas(); self.reset_canvas();
// Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom)
// to clamp atlas updates. This prevents zoom-out tiles from forcing atlas
// growth far beyond real content.
let doc_bounds = self.compute_document_bounds(base_object, tree);
self.surfaces.set_atlas_doc_bounds(doc_bounds);
// During an interactive shape transform (drag/resize/rotate) the // During an interactive shape transform (drag/resize/rotate) the
// Target is repainted tile-by-tile. If only a subset of the // Target is repainted tile-by-tile. If only a subset of the
// invalidated tiles finishes in this rAF the remaining area // invalidated tiles finishes in this rAF the remaining area
@ -1767,6 +1771,37 @@ impl RenderState {
Ok(()) Ok(())
} }
fn compute_document_bounds(
&mut self,
base_object: Option<&Uuid>,
tree: ShapesPoolRef,
) -> Option<skia::Rect> {
let ids: Vec<Uuid> = if let Some(id) = base_object {
vec![*id]
} else {
let root = tree.get(&Uuid::nil())?;
root.children_ids(false)
};
let mut acc: Option<skia::Rect> = None;
for id in ids.iter() {
let Some(shape) = tree.get(id) else {
continue;
};
let r = self.get_cached_extrect(shape, tree, 1.0);
if r.is_empty() {
continue;
}
acc = Some(if let Some(mut a) = acc {
a.join(r);
a
} else {
r
});
}
acc
}
pub fn process_animation_frame( pub fn process_animation_frame(
&mut self, &mut self,
base_object: Option<&Uuid>, base_object: Option<&Uuid>,
@ -1780,16 +1815,20 @@ impl RenderState {
} }
// In a pure viewport interaction (pan/zoom), render_from_cache // In a pure viewport interaction (pan/zoom), render_from_cache
// owns the Target surface — skip flush so we don't present // owns the Target surface — don't flush Target so we don't
// stale tile positions. The rAF still populates the Cache // present stale tile positions. We still drain the GPU command
// surface and tile HashMap so render_from_cache progressively // queue with a non-Target `flush_and_submit` so the backlog
// shows more complete content. // of tile-render commands executes incrementally instead of
// piling up for hundreds of milliseconds and blowing up the
// next `render_from_cache` call into a multi-frame hitch.
// //
// During interactive shape transforms (drag/resize/rotate) we // During interactive shape transforms (drag/resize/rotate) we
// still need to flush every rAF so the user sees the updated // still need to flush every rAF so the user sees the updated
// shape position — render_from_cache is not in the loop here. // shape position — render_from_cache is not in the loop here.
if !self.options.is_viewport_interaction() { if !self.options.is_viewport_interaction() {
self.flush_and_submit(); self.flush_and_submit();
} else {
self.gpu_state.context.flush_and_submit();
} }
if self.render_in_progress { if self.render_in_progress {
@ -2927,11 +2966,6 @@ impl RenderState {
s.canvas().draw_rect(aligned_rect, &paint); s.canvas().draw_rect(aligned_rect, &paint);
}); });
} }
// Clear atlas region to transparent so background shows through.
let _ = self
.surfaces
.clear_doc_rect_in_atlas(&mut self.gpu_state, self.render_area);
} }
} }
} }
@ -3156,7 +3190,8 @@ impl RenderState {
} }
pub fn remove_cached_tile(&mut self, tile: tiles::Tile) { pub fn remove_cached_tile(&mut self, tile: tiles::Tile) {
self.surfaces.remove_cached_tile_surface(tile); self.surfaces
.remove_cached_tile_surface(&mut self.gpu_state, tile);
} }
/// Rebuild the tile index (shape→tile mapping) for all top-level shapes. /// Rebuild the tile index (shape→tile mapping) for all top-level shapes.

View File

@ -62,7 +62,9 @@ fn draw_image_fill(
if let Some(path) = shape_type.path() { if let Some(path) = shape_type.path() {
if let Some(path_transform) = path_transform { if let Some(path_transform) = path_transform {
canvas.clip_path( canvas.clip_path(
&path.to_skia_path().make_transform(&path_transform), &path
.to_skia_path(shape.svg_attrs.as_ref())
.make_transform(&path_transform),
skia::ClipOp::Intersect, skia::ClipOp::Intersect,
antialias, antialias,
); );

View File

@ -1,7 +1,8 @@
use crate::math::{Matrix, Point, Rect}; use crate::math::{Matrix, Point, Rect};
use crate::shapes::{ use crate::shapes::{
merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, Type, merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs,
Type,
}; };
use skia_safe::{self as skia, ImageFilter, RRect}; use skia_safe::{self as skia, ImageFilter, RRect};
@ -210,6 +211,7 @@ fn draw_stroke_on_path(
path_transform: Option<&Matrix>, path_transform: Option<&Matrix>,
shadow: Option<&ImageFilter>, shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>, blur: Option<&ImageFilter>,
svg_attrs: Option<&SvgAttrs>,
antialias: bool, antialias: bool,
) { ) {
let is_open = path.is_open(); let is_open = path.is_open();
@ -229,7 +231,7 @@ fn draw_stroke_on_path(
if let Some(pt) = path_transform { if let Some(pt) = path_transform {
canvas.concat(pt); canvas.concat(pt);
} }
let skia_path = path.to_skia_path(); let skia_path = path.to_skia_path(svg_attrs);
match stroke.render_kind(is_open) { match stroke.render_kind(is_open) {
StrokeKind::Inner => { StrokeKind::Inner => {
@ -510,7 +512,7 @@ fn draw_image_stroke_in_container(
if let Some(p) = shape_type.path() { if let Some(p) = shape_type.path() {
canvas.save(); canvas.save();
let path = p.to_skia_path().make_transform( let path = p.to_skia_path(svg_attrs).make_transform(
&path_transform.ok_or(Error::CriticalError("No path transform".to_string()))?, &path_transform.ok_or(Error::CriticalError("No path transform".to_string()))?,
); );
let stroke_kind = stroke.render_kind(p.is_open()); let stroke_kind = stroke.render_kind(p.is_open());
@ -574,7 +576,7 @@ fn draw_image_stroke_in_container(
// Clear outer stroke for paths if necessary. When adding an outer stroke we need to empty the stroke added too in the inner area. // Clear outer stroke for paths if necessary. When adding an outer stroke we need to empty the stroke added too in the inner area.
if let Type::Path(p) = &shape.shape_type { if let Type::Path(p) = &shape.shape_type {
if stroke.render_kind(p.is_open()) == StrokeKind::Outer { if stroke.render_kind(p.is_open()) == StrokeKind::Outer {
let path = p.to_skia_path().make_transform( let path = p.to_skia_path(svg_attrs).make_transform(
&path_transform.ok_or(Error::CriticalError("No path transform".to_string()))?, &path_transform.ok_or(Error::CriticalError("No path transform".to_string()))?,
); );
let mut clear_paint = skia::Paint::default(); let mut clear_paint = skia::Paint::default();
@ -846,6 +848,7 @@ fn render_merged(
path_transform.as_ref(), path_transform.as_ref(),
None, None,
blur_filter.as_ref(), blur_filter.as_ref(),
svg_attrs,
antialias, antialias,
); );
} }
@ -1016,6 +1019,7 @@ fn render_single_internal(
path_transform.as_ref(), path_transform.as_ref(),
shadow, shadow,
blur.as_ref(), blur.as_ref(),
svg_attrs,
antialias, antialias,
); );
} }

View File

@ -78,10 +78,16 @@ pub struct Surfaces {
/// When the atlas would exceed `max_atlas_texture_size`, this value is /// When the atlas would exceed `max_atlas_texture_size`, this value is
/// reduced so the atlas stays within the fixed texture cap. /// reduced so the atlas stays within the fixed texture cap.
atlas_scale: f32, atlas_scale: f32,
/// Optional document-space bounds (1 unit == 1 doc px @ 100% zoom) used to
/// clamp atlas writes/clears so the atlas doesn't grow due to outlier tile rects.
atlas_doc_bounds: Option<skia::Rect>,
/// Max width/height in pixels for the atlas surface (typically browser /// Max width/height in pixels for the atlas surface (typically browser
/// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation. /// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation.
max_atlas_texture_size: i32, max_atlas_texture_size: i32,
sampling_options: skia::SamplingOptions, sampling_options: skia::SamplingOptions,
/// Tracks the last document-space rect written to the atlas per tile.
/// Used to clear old content without clearing the whole (potentially huge) tile rect.
atlas_tile_doc_rects: HashMap<Tile, skia::Rect>,
pub margins: skia::ISize, pub margins: skia::ISize,
// Tracks which surfaces have content (dirty flag bitmask) // Tracks which surfaces have content (dirty flag bitmask)
dirty_surfaces: u32, dirty_surfaces: u32,
@ -147,8 +153,10 @@ impl Surfaces {
atlas_origin: skia::Point::new(0.0, 0.0), atlas_origin: skia::Point::new(0.0, 0.0),
atlas_size: skia::ISize::new(0, 0), atlas_size: skia::ISize::new(0, 0),
atlas_scale: 1.0, atlas_scale: 1.0,
atlas_doc_bounds: None,
max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE, max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE,
sampling_options, sampling_options,
atlas_tile_doc_rects: HashMap::default(),
margins, margins,
dirty_surfaces: 0, dirty_surfaces: 0,
extra_tile_dims, extra_tile_dims,
@ -162,6 +170,28 @@ impl Surfaces {
self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE); self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE);
} }
/// Sets the document-space bounds used to clamp atlas updates.
/// Pass `None` to disable clamping.
pub fn set_atlas_doc_bounds(&mut self, bounds: Option<skia::Rect>) {
self.atlas_doc_bounds = bounds.filter(|b| !b.is_empty());
}
fn clamp_doc_rect_to_bounds(&self, doc_rect: skia::Rect) -> skia::Rect {
if doc_rect.is_empty() {
return doc_rect;
}
if let Some(bounds) = self.atlas_doc_bounds {
let mut r = doc_rect;
if r.intersect(bounds) {
r
} else {
skia::Rect::new_empty()
}
} else {
doc_rect
}
}
fn ensure_atlas_contains( fn ensure_atlas_contains(
&mut self, &mut self,
gpu_state: &mut GpuState, gpu_state: &mut GpuState,
@ -271,21 +301,51 @@ impl Surfaces {
&mut self, &mut self,
gpu_state: &mut GpuState, gpu_state: &mut GpuState,
tile_image: &skia::Image, tile_image: &skia::Image,
doc_rect: skia::Rect, tile_doc_rect: skia::Rect,
) -> Result<()> { ) -> Result<()> {
self.ensure_atlas_contains(gpu_state, doc_rect)?; if tile_doc_rect.is_empty() {
return Ok(());
}
// Clamp to document bounds (if any) and compute a matching source-rect in tile pixels.
let mut clipped_doc_rect = tile_doc_rect;
if let Some(bounds) = self.atlas_doc_bounds {
if !clipped_doc_rect.intersect(bounds) {
return Ok(());
}
}
if clipped_doc_rect.is_empty() {
return Ok(());
}
self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?;
// Destination is document-space rect mapped into atlas pixel coords. // Destination is document-space rect mapped into atlas pixel coords.
let dst = skia::Rect::from_xywh( let dst = skia::Rect::from_xywh(
(doc_rect.left - self.atlas_origin.x) * self.atlas_scale, (clipped_doc_rect.left - self.atlas_origin.x) * self.atlas_scale,
(doc_rect.top - self.atlas_origin.y) * self.atlas_scale, (clipped_doc_rect.top - self.atlas_origin.y) * self.atlas_scale,
doc_rect.width() * self.atlas_scale, clipped_doc_rect.width() * self.atlas_scale,
doc_rect.height() * self.atlas_scale, clipped_doc_rect.height() * self.atlas_scale,
); );
self.atlas // Compute source rect in tile_image pixel coordinates.
.canvas() let img_w = tile_image.width() as f32;
.draw_image_rect(tile_image, None, dst, &skia::Paint::default()); let img_h = tile_image.height() as f32;
let tw = tile_doc_rect.width().max(1.0);
let th = tile_doc_rect.height().max(1.0);
let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w;
let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h;
let sw = (clipped_doc_rect.width() / tw) * img_w;
let sh = (clipped_doc_rect.height() / th) * img_h;
let src = skia::Rect::from_xywh(sx, sy, sw, sh);
self.atlas.canvas().draw_image_rect(
tile_image,
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
dst,
&skia::Paint::default(),
);
Ok(()) Ok(())
} }
@ -294,6 +354,7 @@ impl Surfaces {
gpu_state: &mut GpuState, gpu_state: &mut GpuState,
doc_rect: skia::Rect, doc_rect: skia::Rect,
) -> Result<()> { ) -> Result<()> {
let doc_rect = self.clamp_doc_rect_to_bounds(doc_rect);
if doc_rect.is_empty() { if doc_rect.is_empty() {
return Ok(()); return Ok(());
} }
@ -316,6 +377,18 @@ impl Surfaces {
Ok(()) Ok(())
} }
/// Clears the last atlas region written by `tile` (if any).
///
/// This avoids clearing the entire logical tile rect which, at very low
/// zoom levels, can be enormous in document space and would unnecessarily
/// grow / rescale the atlas.
pub fn clear_tile_in_atlas(&mut self, gpu_state: &mut GpuState, tile: Tile) -> Result<()> {
if let Some(doc_rect) = self.atlas_tile_doc_rects.remove(&tile) {
self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?;
}
Ok(())
}
pub fn clear_tiles(&mut self) { pub fn clear_tiles(&mut self) {
self.tiles.clear(); self.tiles.clear();
} }
@ -817,6 +890,7 @@ impl Surfaces {
// Incrementally update persistent 1:1 atlas in document space. // Incrementally update persistent 1:1 atlas in document space.
// `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%).
let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect);
self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect);
self.tiles.add(tile_viewbox, tile, tile_image); self.tiles.add(tile_viewbox, tile, tile_image);
} }
} }
@ -825,11 +899,14 @@ impl Surfaces {
self.tiles.has(tile) self.tiles.has(tile)
} }
pub fn remove_cached_tile_surface(&mut self, tile: Tile) { pub fn remove_cached_tile_surface(&mut self, gpu_state: &mut GpuState, tile: Tile) {
// Mark tile as invalid // Mark tile as invalid
// Old content stays visible until new tile overwrites it atomically, // Old content stays visible until new tile overwrites it atomically,
// preventing flickering during tile re-renders. // preventing flickering during tile re-renders.
self.tiles.remove(tile); self.tiles.remove(tile);
// Also clear the corresponding region in the persistent atlas to avoid
// leaving stale pixels when shapes move/delete.
let _ = self.clear_tile_in_atlas(gpu_state, tile);
} }
pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) { pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) {
@ -914,6 +991,7 @@ impl Surfaces {
/// the cache canvas for scaled previews, use `invalidate_tile_cache` instead. /// the cache canvas for scaled previews, use `invalidate_tile_cache` instead.
pub fn remove_cached_tiles(&mut self, color: skia::Color) { pub fn remove_cached_tiles(&mut self, color: skia::Color) {
self.tiles.clear(); self.tiles.clear();
self.atlas_tile_doc_rects.clear();
self.cache.canvas().clear(color); self.cache.canvas().clear(color);
} }
@ -923,6 +1001,7 @@ impl Surfaces {
/// content while new tiles are being rendered. /// content while new tiles are being rendered.
pub fn invalidate_tile_cache(&mut self) { pub fn invalidate_tile_cache(&mut self) {
self.tiles.clear(); self.tiles.clear();
self.atlas_tile_doc_rects.clear();
} }
pub fn gc(&mut self) { pub fn gc(&mut self) {

View File

@ -1349,15 +1349,10 @@ impl Shape {
pub fn get_skia_path(&self) -> Option<skia::Path> { pub fn get_skia_path(&self) -> Option<skia::Path> {
if let Some(path) = self.shape_type.path() { if let Some(path) = self.shape_type.path() {
let mut skia_path = path.to_skia_path(); let mut skia_path = path.to_skia_path(self.svg_attrs.as_ref());
if let Some(path_transform) = self.to_path_transform() { if let Some(path_transform) = self.to_path_transform() {
skia_path = skia_path.make_transform(&path_transform); skia_path = skia_path.make_transform(&path_transform);
} }
if let Some(svg_attrs) = &self.svg_attrs {
if svg_attrs.fill_rule == FillRule::Evenodd {
skia_path.set_fill_type(skia::PathFillType::EvenOdd);
}
}
Some(skia_path) Some(skia_path)
} else { } else {
None None

View File

@ -1,6 +1,7 @@
use skia_safe::{self as skia, Matrix}; use skia_safe::{self as skia, Matrix};
use crate::math; use crate::math;
use crate::shapes::svg_attrs::{FillRule, SvgAttrs};
mod subpaths; mod subpaths;
@ -217,8 +218,14 @@ impl Path {
Path::new(segments) Path::new(segments)
} }
pub fn to_skia_path(&self) -> skia::Path { pub fn to_skia_path(&self, svg_attrs: Option<&SvgAttrs>) -> skia::Path {
self.skia_path.snapshot() let mut path = self.skia_path.snapshot();
if let Some(attrs) = svg_attrs {
if attrs.fill_rule == FillRule::Evenodd {
path.set_fill_type(skia::PathFillType::EvenOdd);
}
}
path
} }
pub fn contains(&self, p: skia::Point) -> bool { pub fn contains(&self, p: skia::Point) -> bool {

View File

@ -17,7 +17,7 @@ pub fn stroke_to_path(
selrect: &Rect, selrect: &Rect,
svg_attrs: Option<&SvgAttrs>, svg_attrs: Option<&SvgAttrs>,
) -> Option<Path> { ) -> Option<Path> {
let skia_shape_path = shape_path.to_skia_path(); let skia_shape_path = shape_path.to_skia_path(svg_attrs);
let transformed_shape_path = if let Some(pt) = path_transform { let transformed_shape_path = if let Some(pt) = path_transform {
skia_shape_path.make_transform(pt) skia_shape_path.make_transform(pt)

View File

@ -1,11 +1,34 @@
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::mem; use crate::mem;
use crate::shapes::Fill;
use crate::state::State;
use crate::uuid::Uuid; use crate::uuid::Uuid;
use crate::with_state_mut; use crate::with_state_mut;
use crate::STATE; use crate::STATE;
use crate::{shapes::ImageFill, utils::uuid_from_u32_quartet}; use crate::{shapes::ImageFill, utils::uuid_from_u32_quartet};
use macros::wasm_error; use macros::wasm_error;
fn touch_shapes_with_image(state: &mut State, image_id: Uuid) {
let ids: Vec<Uuid> = state
.shapes
.iter()
.filter(|shape| {
shape
.fills()
.any(|f| matches!(f, Fill::Image(i) if i.id() == image_id))
|| shape
.strokes
.iter()
.any(|s| matches!(&s.fill, Fill::Image(i) if i.id() == image_id))
})
.map(|shape| shape.id)
.collect();
for id in ids {
state.touch_shape(id);
}
}
const FLAG_KEEP_ASPECT_RATIO: u8 = 1 << 0; const FLAG_KEEP_ASPECT_RATIO: u8 = 1 << 0;
const IMAGE_IDS_SIZE: usize = 32; const IMAGE_IDS_SIZE: usize = 32;
const IMAGE_HEADER_SIZE: usize = 36; // 32 bytes for IDs + 4 bytes for is_thumbnail flag const IMAGE_HEADER_SIZE: usize = 36; // 32 bytes for IDs + 4 bytes for is_thumbnail flag
@ -90,7 +113,7 @@ pub extern "C" fn store_image() -> Result<()> {
{ {
eprintln!("{}", msg); eprintln!("{}", msg);
} }
state.touch_shape(ids.shape_id); touch_shapes_with_image(state, ids.image_id);
}); });
mem::free_bytes()?; mem::free_bytes()?;
@ -167,7 +190,7 @@ pub extern "C" fn store_image_from_texture() -> Result<()> {
// FIXME: Review if we should return a RecoverableError // FIXME: Review if we should return a RecoverableError
eprintln!("store_image_from_texture error: {}", msg); eprintln!("store_image_from_texture error: {}", msg);
} }
state.touch_shape(ids.shape_id); touch_shapes_with_image(state, ids.image_id);
}); });
mem::free_bytes()?; mem::free_bytes()?;