Compare commits

...

46 Commits

Author SHA1 Message Date
Francis Santiago
e44488c372 🐛 Fix enable IPv6 support in nginx frontend 2026-02-05 20:07:44 +01:00
Andrey Antukh
2ccb33ba89 📎 Add missing for-update for the migration 145 2026-02-05 18:12:11 +01:00
Andrey Antukh
ee88ee63a2 Add data migration for fix plugins data on profiles 2026-02-05 18:08:28 +01:00
Eva Marco
f961f9a123 🐛 Fix several bugs (#8267)
* ♻️ Remove rename warning

* 🐛 Fix opacity value
2026-02-05 11:34:14 +01:00
Eva Marco
dda3377596 🐛 Allow detach broken token from input (#8242)
* 🐛 Allow detach broken token from input

* 🐛 Fix multiselection on multiple token applied

* ♻️ Remove detach-token new fn
2026-02-05 11:28:47 +01:00
Andrey Antukh
17935443df Move all tokenscript related adaptations to a separared package 2026-02-05 09:45:55 +01:00
Florian Schroedl
150d57b1eb Add tokenscript MVP 2026-02-05 09:45:55 +01:00
Andrey Antukh
490619119e 🐛 Use correct listen address for mcp server 2026-02-04 18:57:53 +01:00
Belén Albeza
834b513562 🔧 Fix typo in workspace spec (#8272) 2026-02-04 17:05:49 +01:00
Andrey Antukh
1656fefdc9 Merge remote-tracking branch 'origin/staging-render' into develop 2026-02-04 16:23:46 +01:00
Andrey Antukh
7f318bb110 Merge remote-tracking branch 'origin/staging' into staging-render 2026-02-04 16:22:13 +01:00
Andrey Antukh
44c7d3fbd6 Backport .github workflows from develop 2026-02-04 16:21:19 +01:00
Andrey Antukh
3d50aa6cb2 ⬆️ Update imagemagick version 2026-02-04 16:21:19 +01:00
Andrey Antukh
06afd94a74 ⬆️ Update backend dependencies (mainly bugfixes) 2026-02-04 16:21:19 +01:00
Andrey Antukh
e7d9dca55e ⬆️ Update jdk and node on devenv and other images 2026-02-04 16:21:19 +01:00
Andrey Antukh
c14ccc18b8 Import mcp from develop 2026-02-04 16:21:19 +01:00
Andrey Antukh
ca4d00df69 🐛 Fix latest error report related migration 2026-02-04 15:36:07 +01:00
Andrey Antukh
9667477d6b 🐛 Add missing dep for rpc routes on backend 2026-02-04 15:26:02 +01:00
Alejandro Alonso
485005477e 🐛 Fix WasmWorkspacePage import 2026-02-04 14:02:38 +01:00
Alejandro Alonso
86ca260ea2 Merge remote-tracking branch 'origin/staging-render' into develop 2026-02-04 13:50:13 +01:00
Andrey Antukh
d80ba1856a Add several improvements to frontend error reporting
*  Add major improvement on error handling

*  Add the ability to store frontend reports

* 📎 Add PR feedback changes
2026-02-04 12:45:38 +01:00
Alejandro Alonso
ebb7d01bc9 🐛 Fix entering decimal values in dimension fields causes internal server error (#8263) 2026-02-04 12:44:19 +01:00
Andrey Antukh
a1bfb2781e 📎 Update mcp readme 2026-02-04 12:22:36 +01:00
Andrey Antukh
08e8787568 Add mcp types generator build script 2026-02-04 12:22:36 +01:00
Andrey Antukh
da55653844 Add integration tests for mcp 2026-02-04 12:22:36 +01:00
Andrey Antukh
11f2323057 Add mcp to default compose template 2026-02-04 12:22:36 +01:00
Andrey Antukh
ae0f5e2bb9 🐛 Fix subpath support on plugins 2026-02-04 12:22:36 +01:00
Andrey Antukh
1fff1f9506 Add mcp dockerfile 2026-02-04 12:22:36 +01:00
Andrey Antukh
61d7dd3167 Update devenv with mcp required dependencies 2026-02-04 12:22:36 +01:00
Dominik Jain
880b9b61c4 🎉 Integrate mcp repository
Original repository: https://github.com/penpot/penpot-mcp
Imported commit: fcfa67e908fc54e23a3a3543dee432472dc90c5d
2026-02-04 12:22:36 +01:00
Yamila Moreno
307dae9f61 💄 Remove access logs for /readyz 2026-02-04 10:54:07 +01:00
Xaviju
0f0ad4f161 🐛 Remove path from state when removing tokens (#8252)
* 🐛 Remove path from state when removing tokens

* ♻️ Improve path edition legibility

* ♻️ Fix path delete on change set
2026-02-04 10:15:46 +01:00
Alejandro Alonso
24c8fc484f 🐛 Fix Internal Error when adding a new text layer and trying to go back to Dashboard without saving 2026-02-04 10:01:10 +01:00
Alejandro Alonso
bc16b8ddc3 Merge pull request #8198 from penpot/ladybenko-13176-playwright-wasm
🔧 Migrate workspace tests to user the wasm viewport
2026-02-03 13:00:10 +01:00
Alejandro Alonso
b07c98faa5 Merge pull request #8259 from penpot/superalex-improve-shadow-rendering
🎉 Improving shadow rendering performance
2026-02-03 12:59:49 +01:00
Alejandro Alonso
25aff100cf 🎉 Add shadows playground for render wasm 2026-02-03 12:44:43 +01:00
Alejandro Alonso
5be887f10b 🎉 Improve plain shape calculation 2026-02-03 12:44:43 +01:00
Alejandro Alonso
f7403935c8 🎉 Improve shadows rendering performance 2026-02-03 12:33:05 +01:00
Andrey Antukh
7d09d930fe 📚 Update changelog 2026-02-03 11:13:46 +01:00
Belén Albeza
79be3ab7df 🔧 Fix text editor flaky tests 2026-02-03 10:39:38 +01:00
Belén Albeza
629649aca6 🔧 Fix config playwright syntax 2026-02-02 16:25:16 +01:00
Belén Albeza
cc326f23cf 🔧 Adjust timeout of websocket readiness (playwright) 2026-02-02 16:16:59 +01:00
Belén Albeza
2c4efc6b53 🔧 Fix onboarding test 2026-02-02 16:16:58 +01:00
Belén Albeza
4d5c874b91 🔧 Fix typography token test 2026-02-02 16:16:58 +01:00
Belén Albeza
e3b97638b4 🔧 Fix broken / flaky tests 2026-02-02 16:16:58 +01:00
Belén Albeza
daedc660b9 🔧 Migrate workspace tests to user the wasm viewport 2026-02-02 16:16:58 +01:00
167 changed files with 33768 additions and 773 deletions

45
.github/workflows/tests-mcp.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: "MCP CI"
on:
pull_request:
branches:
- develop
- staging
- main
types:
- opened
- synchronize
paths:
- 'mcp/**'
push:
branches:
- develop
- staging
- main
paths:
- 'mcp/**'
jobs:
test:
name: "Test"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup
working-directory: ./mcp
run: ./scripts/setup
- name: Check
working-directory: ./mcp
run: |
pnpm run fmt:check;
pnpm -r run build;
pnpm -r run types:check;

2
.nvmrc
View File

@@ -1 +1 @@
v22.21.1
v22.22.0

View File

@@ -34,11 +34,7 @@
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
## 2.13.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
## 2.13.0
### :heart: Community contributions (Thank you!)

View File

@@ -3,7 +3,7 @@
:deps
{penpot/common {:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.2"}
org.clojure/clojure {:mvn/version "1.12.4"}
org.clojure/tools.namespace {:mvn/version "1.5.0"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
@@ -28,8 +28,8 @@
com.google.guava/guava {:mvn/version "33.4.8-jre"}
funcool/yetti
{:git/tag "v11.8"
:git/sha "1d1b33f"
{:git/tag "v11.9"
:git/sha "5fad7a9"
:git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]}
@@ -39,7 +39,7 @@
metosin/reitit-core {:mvn/version "0.9.1"}
nrepl/nrepl {:mvn/version "1.4.0"}
org.postgresql/postgresql {:mvn/version "42.7.7"}
org.postgresql/postgresql {:mvn/version "42.7.9"}
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
@@ -49,7 +49,7 @@
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.6.1-359"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.2"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"}
org.jsoup/jsoup {:mvn/version "1.21.2"}
org.im4java/im4java
@@ -66,7 +66,7 @@
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.33.10"}}
software.amazon.awssdk/s3 {:mvn/version "2.41.21"}}
:paths ["src" "resources" "target/classes"]
:aliases

View File

@@ -5,7 +5,6 @@
<meta name="robots" content="noindex,nofollow">
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono">
<style>
{% include "app/templates/styles.css" %}
</style>

View File

@@ -5,23 +5,25 @@ penpot - error list
{% endblock %}
{% block content %}
<nav>
<div class="title">
<h1>Error reports (last 200)
<a href="/dbg">[GO BACK]</a>
</h1>
</div>
</nav>
<main class="horizontal-list">
<ul>
{% for item in items %}
<li>
<a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a>
<a class="hint" href="/dbg/error/{{item.id}}">
<span class="title">{{item.hint|abbreviate:150}}</span>
</a>
</li>
{% endfor %}
</ul>
</main>
<nav>
<div class="title">
<a href="/dbg"> [BACK]</a>
<h1>Error reports (last 300)</h1>
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
</div>
</nav>
<main class="horizontal-list">
<ul>
{% for item in items %}
<li>
<a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a>
<a class="hint" href="/dbg/error/{{item.id}}">
<span class="title">{{item.hint|abbreviate:150}}</span>
</a>
</li>
{% endfor %}
</ul>
</main>
{% endblock %}

View File

@@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
{% block content %}
<nav>
<div>[<a href="/dbg/error">⮜</a>]</div>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#props">props</a>]</div>
<div>[<a href="#context">context</a>]</div>

View File

@@ -0,0 +1,46 @@
{% extends "app/templates/base.tmpl" %}
{% block title %}
Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
{% endblock %}
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<!-- <div>[<a href="#props">props</a>]</div> -->
<div>[<a href="#context">context</a>]</div>
{% if report %}
<div>[<a href="#report">report</a>]</div>
{% endif %}
</nav>
<main>
<div class="table">
<div class="table-row multiline">
<div id="head" class="table-key">HEAD</div>
<div class="table-val">
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
<h2><span class="not-important">Report ID:</span> <br/> {{id}}</h2>
</div>
</div>
<div class="table-row multiline">
<div id="context" class="table-key">CONTEXT: </div>
<div class="table-val">
<pre>{{context}}</pre>
</div>
</div>
{% if report %}
<div class="table-row multiline">
<div id="report" class="table-key">REPORT:</div>
<div class="table-val">
<pre>{{report}}</pre>
</div>
</div>
{% endif %}
</div>
</main>
{% endblock %}

View File

@@ -1,5 +1,5 @@
* {
font-family: "JetBrains Mono", monospace;
font-family: monospace;
font-size: 12px;
}
@@ -36,6 +36,10 @@ small {
color: #888;
}
.strong {
font-weight: 900;
}
.not-important {
color: #888;
font-weight: 200;
@@ -57,14 +61,26 @@ nav {
nav > .title {
display: flex;
justify-content: center;
width: 100%;
}
nav > .title > a {
color: black;
text-decoration: none;
}
nav > .title > a.strong {
text-decoration: underline;
}
nav > .title > h1 {
padding: 0px;
margin: 0px;
font-size: 11px;
display: block;
}
nav > .title > * {
padding: 0px 6px;
}
nav > div {

View File

@@ -232,13 +232,22 @@
(-> (io/resource "app/templates/error-report.v3.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 3)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
(render-template-v4 [{:keys [content id created-at]}]
(-> (io/resource "app/templates/error-report.v4.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 4)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
(if-let [report (get-report request)]
(let [result (case (:version report)
1 (render-template-v1 report)
2 (render-template-v2 report)
3 (render-template-v3 report))]
3 (render-template-v3 report)
4 (render-template-v4 report))]
{::yres/status 200
::yres/body result
::yres/headers {"content-type" "text/html; charset=utf-8"
@@ -246,20 +255,22 @@
{::yres/status 404
::yres/body "not found"})))
(def sql:error-reports
(def ^:private sql:error-reports
"SELECT id, created_at,
content->>'~:hint' AS hint
FROM server_error_report
WHERE version = ?
ORDER BY created_at DESC
LIMIT 200")
LIMIT 300")
(defn error-list-handler
[{:keys [::db/pool]} _request]
(let [items (->> (db/exec! pool [sql:error-reports])
(map #(update % :created-at ct/format-inst :rfc1123)))]
(defn- error-list-handler
[{:keys [::db/pool]} {:keys [params]}]
(let [version (or (some-> (get params :version) parse-long) 3)
items (->> (db/exec! pool [sql:error-reports version])
(map #(update % :created-at ct/format-inst :rfc1123)))]
{::yres/status 200
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
(tmpl/render {:items items}))
(tmpl/render {:items items :version version}))
::yres/headers {"content-type" "text/html; charset=utf-8"
"x-robots-tag" "noindex"}}))

View File

@@ -32,7 +32,7 @@
(assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid))
(assoc :request/auth-data auth)
(assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")))))
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defmulti handle-error
(fn [cause _ _]

View File

@@ -113,6 +113,8 @@
;; COLLECTOR API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare ^:private prepare-context-from-request)
;; Defines a service that collects the audit/activity log using
;; internal database. Later this audit log can be transferred to
;; an external storage and data cleared.
@@ -126,6 +128,8 @@
[::props {:optional true} [:map-of :keyword :any]]
[::context {:optional true} [:map-of :keyword :any]]
[::tracked-at {:optional true} ::ct/inst]
[::created-at {:optional true} ::ct/inst]
[::source {:optional true} ::sm/text]
[::webhooks/event? {:optional true} ::sm/boolean]
[::webhooks/batch-timeout {:optional true} ::ct/duration]
[::webhooks/batch-key {:optional true}
@@ -134,36 +138,8 @@
(def ^:private check-event
(sm/check-fn schema:event))
(defn- prepare-context-from-request
[request]
(let [client-event-origin (get-client-event-origin request)
client-version (get-client-version request)
client-user-agent (get-client-user-agent request)
session-id (get-external-session-id request)
key-id (::http/auth-key-id request)
token-id (::actoken/id request)
token-type (::actoken/type request)]
(d/without-nils
{:external-session-id session-id
:initiator (or key-id "app")
:access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin
:client-user-agent client-user-agent
:client-version client-version
:version (:full cf/version)})))
(defn event-from-rpc-params
"Create a base event skeleton with pre-filled some important
data that can be extracted from RPC params object"
[params]
(let [context (some-> params meta ::http/request prepare-context-from-request)
event {::type "action"
::profile-id (or (::rpc/profile-id params) uuid/zero)
::ip-addr (::rpc/ip-addr params)}]
(cond-> event
(some? context)
(assoc ::context context))))
(def valid-event?
(sm/validator schema:event))
(defn prepare-event
[cfg mdata params result]
@@ -212,6 +188,38 @@
(::webhooks/event? resultm)
false)}))
(defn- prepare-context-from-request
"Prepare backend event context from request"
[request]
(let [client-event-origin (get-client-event-origin request)
client-version (get-client-version request)
client-user-agent (get-client-user-agent request)
session-id (get-external-session-id request)
key-id (::http/auth-key-id request)
token-id (::actoken/id request)
token-type (::actoken/type request)]
(d/without-nils
{:external-session-id session-id
:initiator (or key-id "app")
:access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin
:client-user-agent client-user-agent
:client-version client-version
:version (:full cf/version)})))
(defn event-from-rpc-params
"Create a base event skeleton with pre-filled some important
data that can be extracted from RPC params object"
[params]
(let [context (some-> params meta ::http/request prepare-context-from-request)
event {::type "action"
::profile-id (or (::rpc/profile-id params) uuid/zero)
::ip-addr (::rpc/ip-addr params)}]
(cond-> event
(some? context)
(assoc ::context context))))
(defn- event->params
[event]
(let [params {:id (uuid/next)
@@ -238,8 +246,10 @@
(defn- handle-event!
[cfg event]
(let [params (event->params event)
tnow (ct/now)]
(let [tnow (ct/now)
params (-> (event->params event)
(assoc :created-at tnow)
(update :tracked-at #(or % tnow)))]
(when (contains? cf/flags :audit-log-logger)
(l/log! ::l/logger "app.audit"
@@ -255,10 +265,7 @@
;; NOTE: this operation may cause primary key conflicts on inserts
;; because of the timestamp precission (two concurrent requests), in
;; this case we just retry the operation.
(let [params (-> params
(assoc :created-at tnow)
(update :tracked-at #(or % tnow)))]
(append-audit-entry cfg params)))
(append-audit-entry cfg params))
(when (and (or (contains? cf/flags :telemetry)
(cf/get :telemetry-enabled))
@@ -269,8 +276,6 @@
;;
;; NOTE: this is only executed when general audit log is disabled
(let [params (-> params
(assoc :created-at tnow)
(update :tracked-at #(or % tnow))
(assoc :props {})
(assoc :context {}))]
(append-audit-entry cfg params)))

View File

@@ -14,6 +14,7 @@
[app.common.schema :as sm]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as audit]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.exec :as px]
@@ -28,69 +29,108 @@
(defonce enabled (atom true))
(defn- persist-on-database!
[pool id report]
[pool id version report]
(when-not (db/read-only? pool)
(db/insert! pool :server-error-report
{:id id
:version 3
:version version
:content (db/tjson report)})))
(defn- concurrent-exception?
[cause]
(or (instance? java.util.concurrent.CompletionException cause)
(instance? java.util.concurrent.ExecutionException cause)))
(defn record->report
[{:keys [::l/context ::l/message ::l/props ::l/logger ::l/level ::l/cause] :as record}]
(assert (l/valid-record? record) "expectd valid log record")
(if (or (instance? java.util.concurrent.CompletionException cause)
(instance? java.util.concurrent.ExecutionException cause))
(-> record
(assoc ::trace (ex/format-throwable cause :data? true :explain? false :header? false :summary? false))
(assoc ::l/cause (ex-cause cause))
(record->report))
(let [data (if (concurrent-exception? cause)
(ex-data (ex-cause cause))
(ex-data cause))
(let [data (ex-data cause)
ctx (-> context
(assoc :tenant (cf/get :tenant))
(assoc :host (cf/get :host))
(assoc :public-uri (str (cf/get :public-uri)))
(assoc :logger/name logger)
(assoc :logger/level level)
(dissoc :request/params :value :params :data))]
ctx (-> context
(assoc :service/tenant (cf/get :tenant))
(assoc :service/host (cf/get :host))
(assoc :service/public-uri (str (cf/get :public-uri)))
(assoc :backend/version (:full cf/version))
(assoc :logger/name logger)
(assoc :logger/level level)
(dissoc :request/params :value :params :data))]
(merge
{:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)]
(str props-hint ": " message)
message))
@message)
:trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
(merge
{:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)]
(str props-hint ": " message)
message))
@message)
:trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
(when-let [params (or (:request/params context) (:params context))]
{:params (pp/pprint-str params :length 20 :level 20)})
(when-let [params (or (:request/params context) (:params context))]
{:params (pp/pprint-str params :length 20 :level 20)})
(when-let [value (:value context)]
{:value (pp/pprint-str value :length 30 :level 13)})
(when-let [value (:value context)]
{:value (pp/pprint-str value :length 30 :level 13)})
(when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))]
{:data (pp/pprint-str data :length 30 :level 13)})
(when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))]
{:data (pp/pprint-str data :length 30 :level 13)})
(when-let [explain (ex/explain data :length 30 :level 13)]
{:explain explain})))))
(when-let [explain (ex/explain data :length 30 :level 13)]
{:explain explain}))))
(defn error-record?
[{:keys [::l/level]}]
(= :error level))
(defn- handle-event
(defn- handle-log-record
"Convert the log record into a report object and persist it on the database"
[{:keys [::db/pool]} {:keys [::l/id] :as record}]
(try
(let [uri (cf/get :public-uri)
report (-> record record->report d/without-nils)]
(l/debug :hint "registering error on database" :id id
:uri (str uri "/dbg/error/" id))
(l/dbg :hint "registering error on database"
:id id
:src "logging"
:uri (str uri "/dbg/error/" id))
(persist-on-database! pool id 3 report))
(catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
(persist-on-database! pool id report))
(defn- event->report
[{:keys [::audit/context ::audit/props ::audit/ip-addr] :as record}]
(let [context
(reduce-kv (fn [context k v]
(let [k' (keyword "frontend" (name k))]
(-> context
(dissoc k)
(assoc k' v))))
context
context)
context
(-> context
(assoc :backend/tenant (cf/get :tenant))
(assoc :backend/host (cf/get :host))
(assoc :backend/public-uri (str (cf/get :public-uri)))
(assoc :backend/version (:full cf/version))
(assoc :frontend/ip-addr ip-addr))]
{:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (get props :hint)
:report (get props :report)}))
(defn- handle-audit-event
"Convert the log record into a report object and persist it on the database"
[{:keys [::db/pool]} {:keys [::audit/id] :as event}]
(try
(let [uri (cf/get :public-uri)
report (-> event event->report d/without-nils)]
(l/dbg :hint "registering error on database"
:id id
:src "audit-log"
:uri (str uri "/dbg/error/" id))
(persist-on-database! pool id 4 report))
(catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
@@ -100,26 +140,49 @@
(defmethod ig/init-key ::reporter
[_ cfg]
(let [input (sp/chan :buf (sp/sliding-buffer 64)
:xf (filter error-record?))]
(add-watch l/log-record ::reporter #(sp/put! input %4))
(let [input (sp/chan :buf (sp/sliding-buffer 256))
thread (px/thread
{:name "penpot/reporter/database"}
(l/info :hint "initializing database error persistence")
(try
(loop []
(when-let [item (sp/take! input)]
(cond
(::l/id item)
(handle-log-record cfg item)
(px/thread {:name "penpot/database-reporter"}
(l/info :hint "initializing database error persistence")
(try
(loop []
(when-let [record (sp/take! input)]
(handle-event cfg record)
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(sp/close! input)
(remove-watch l/log-record ::reporter)
(l/info :hint "reporter terminated"))))))
(::audit/id item)
(handle-audit-event cfg item)
:else
(l/warn :hint "received unexpected item" :item item))
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(l/info :hint "reporter terminated"))))]
(add-watch l/log-record ::reporter
(fn [_ _ _ record]
(when (= :error (::l/level record))
(sp/put! input record))))
{::input input
::thread thread}))
(defmethod ig/halt-key! ::reporter
[_ thread]
(some-> thread px/interrupt!))
[_ {:keys [::input ::thread]}]
(remove-watch l/log-record ::reporter)
(sp/close! input)
(px/interrupt! thread))
(defn emit
"Emit an event/report into the database reporter"
[cfg event]
(when-let [{:keys [::input]} (get cfg ::reporter)]
(sp/put! input event)))

View File

@@ -9,9 +9,10 @@
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.uri :as u]
[app.config :as cf]
[app.http.client :as http]
[app.loggers.database :as ldb]
[app.loggers.audit :as audit]
[app.util.json :as json]
[integrant.core :as ig]
[promesa.exec :as px]
@@ -20,24 +21,27 @@
(defonce enabled (atom true))
(defn- send-mattermost-notification!
[cfg {:keys [id public-uri] :as report}]
[cfg {:keys [id] :as report}]
(let [text (str "Exception: " public-uri "/dbg/error/" id " "
(let [url (u/join (cf/get :public-uri) "/dbg/error/" id)
text (str "Exception: " url " "
(when-let [pid (:profile-id report)]
(str "(pid: #uuid-" pid ")"))
"\n"
"- host: #" (:host report) "\n"
"- tenant: #" (:tenant report) "\n"
"- logger: #" (:logger report) "\n"
"- request-path: `" (:request-path report) "`\n"
"- origin: #" (:origin report) "\n"
"- href: `" (:href report) "`\n"
"- frontend-version: `" (:frontend-version report) "`\n"
"- backend-version: `" (:backend-version report) "`\n"
"\n"
"```\n"
"Trace:\n"
(:trace report)
"```")
(when-let [trace (:trace report)]
(str "```\n"
"Trace:\n"
trace
"```")))
resp (http/req! cfg
{:uri (cf/get :error-report-webhook)
@@ -50,28 +54,49 @@
(l/warn :hint "error on sending data"
:response (pr-str resp)))))
(defn record->report
(defn- record->report
[{:keys [::l/context ::l/id ::l/cause] :as record}]
(assert (l/valid-record? record) "expectd valid log record")
(let [public-uri (cf/get :public-uri)]
{:id id
:origin "logging"
:tenant (cf/get :tenant)
:host (cf/get :host)
:backend-version (:full cf/version)
:frontend-version (:frontend/version context)
:profile-id (:request/profile-id context)
:href (-> public-uri
(assoc :path (:request/path context))
(str))
:trace (ex/format-throwable cause :detail? false :header? false)}))
(defn- audit-event->report
[{:keys [::audit/context ::audit/props ::audit/id] :as event}]
{:id id
:origin "audit-log"
:tenant (cf/get :tenant)
:host (cf/get :host)
:public-uri (cf/get :public-uri)
:backend-version (or (:version/backend context) (:full cf/version))
:frontend-version (:version/frontend context)
:profile-id (:request/profile-id context)
:request-path (:request/path context)
:logger (::l/logger record)
:trace (ex/format-throwable cause :detail? false :header? false)})
:backend-version (:full cf/version)
:frontend-version (:version context)
:profile-id (:audit/profile-id event)
:href (get props :href)})
(defn handle-event
(defn- handle-log-record
[cfg record]
(when @enabled
(try
(let [report (record->report record)]
(send-mattermost-notification! cfg report))
(catch Throwable cause
(l/warn :hint "unhandled error" :cause cause)))))
(try
(let [report (record->report record)]
(send-mattermost-notification! cfg report))
(catch Throwable cause
(l/warn :hint "unhandled error" :cause cause))))
(defn- handle-audit-event
[cfg record]
(try
(let [report (audit-event->report record)]
(send-mattermost-notification! cfg report))
(catch Throwable cause
(l/warn :hint "unhandled error" :cause cause))))
(defmethod ig/assert-key ::reporter
[_ params]
@@ -80,27 +105,49 @@
(defmethod ig/init-key ::reporter
[_ cfg]
(when-let [uri (cf/get :error-report-webhook)]
(px/thread
{:name "penpot/mattermost-reporter"
:virtual true}
(l/info :hint "initializing error reporter" :uri uri)
(let [input (sp/chan :buf (sp/sliding-buffer 128)
:xf (filter ldb/error-record?))]
(add-watch l/log-record ::reporter #(sp/put! input %4))
(try
(loop []
(when-let [msg (sp/take! input)]
(handle-event cfg msg)
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(sp/close! input)
(remove-watch l/log-record ::reporter)
(l/info :hint "reporter terminated")))))))
(let [input (sp/chan :buf (sp/sliding-buffer 256))
thread (px/thread
{:name "penpot/reporter/mattermost"}
(l/info :hint "initializing error reporter" :uri uri)
(try
(loop []
(when-let [item (sp/take! input)]
(when @enabled
(cond
(::l/id item)
(handle-log-record cfg item)
(::audit/id item)
(handle-audit-event cfg item)
:else
(l/warn :hint "received unexpected item" :item item)))
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(l/info :hint "reporter terminated"))))]
(add-watch l/log-record ::reporter
(fn [_ _ _ record]
(when (= :error (::l/level record))
(sp/put! input record))))
{::input input
::thread thread})))
(defmethod ig/halt-key! ::reporter
[_ thread]
[_ {:keys [::input ::thread]}]
(remove-watch l/log-record ::reporter)
(some-> input sp/close!)
(some-> thread px/interrupt!))
(defn emit
"Emit an event/report into the mattermost reporter"
[cfg event]
(when-let [{:keys [::input]} (get cfg ::reporter)]
(sp/put! input event)))

View File

@@ -337,7 +337,13 @@
::setup/props (ig/ref ::setup/props)
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
::email/whitelist (ig/ref ::email/whitelist)
:app.loggers.database/reporter
(ig/ref :app.loggers.database/reporter)
:app.loggers.mattermost/reporter
(ig/ref :app.loggers.mattermost/reporter)}
:app.nitrate/client
{::http.client/client (ig/ref ::http.client/client)
@@ -363,6 +369,7 @@
;; FIXME: revisit if db/pool is necessary here
::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)
::setup/shared-keys (ig/ref ::setup/shared-keys)}
::wrk/registry

View File

@@ -10,6 +10,7 @@
[app.common.logging :as l]
[app.db :as db]
[app.migrations.clj.migration-0023 :as mg0023]
[app.migrations.clj.migration-0145 :as mg0145]
[app.util.migrations :as mg]
[integrant.core :as ig]))
@@ -456,7 +457,14 @@
:fn (mg/resource "app/migrations/sql/0142-add-sso-provider-table.sql")}
{:name "0143-http-session-v2-table"
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}])
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}
{:name "0144-mod-server-error-report-table"
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
{:name "0145-fix-plugins-uri-on-profile"
:fn mg0145/migrate}])
(defn apply-migrations!
[pool name migrations]

View File

@@ -0,0 +1,83 @@
;; 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 app.migrations.clj.migration-0145
"Migrate plugins references on profiles"
(:require
[app.common.data :as d]
[app.common.logging :as l]
[app.db :as db]
[cuerdas.core :as str]))
(def ^:private replacements
{"https://colors-to-tokens-plugin.pages.dev"
"https://colors-to-tokens.plugins.penpot.app"
"https://contrast-penpot-plugin.pages.dev"
"https://contrast.plugins.penpot.app"
"https://create-palette-penpot-plugin.pages.dev"
"https://create-palette.plugins.penpot.app"
"https://icons-penpot-plugin.pages.dev"
"https://icons.plugins.penpot.app"
"https://lorem-ipsum-penpot-plugin.pages.dev"
"https://lorem-ipsum.plugins.penpot.app"
"https://rename-layers-penpot-plugin.pages.dev"
"https://rename-layers.plugins.penpot.app"
"https://table-penpot-plugin.pages.dev"
"https://table.plugins.penpot.app"})
(defn- fix-url
[url]
(reduce-kv (fn [url prefix replacement]
(if (str/starts-with? url prefix)
(reduced (str replacement (subs url (count prefix))))
url))
url
replacements))
(defn- fix-manifest
[manifest]
(-> manifest
(d/update-when :url fix-url)
(d/update-when :host fix-url)))
(defn- fix-plugins-data
[props]
(d/update-in-when props [:plugins :data]
(fn [data]
(reduce-kv (fn [data id manifest]
(let [manifest' (fix-manifest manifest)]
(if (= manifest manifest')
data
(assoc data id manifest'))))
data
data))))
(def ^:private sql:get-profiles
"SELECT id, props FROM profile
WHERE props ?? '~:plugins'
ORDER BY created_at
FOR UPDATE")
(defn migrate
[conn]
(->> (db/plan conn [sql:get-profiles])
(run! (fn [{:keys [id props]}]
(when-let [props (some-> props db/decode-transit-pgobject)]
(let [props' (fix-plugins-data props)]
(when (not= props props')
(l/inf :hint "fixing plugins data on profile props" :profile-id (str id))
(db/update! conn :profile
{:props (db/tjson props')}
{:id id}
{::db/return-keys false}))))))))

View File

@@ -0,0 +1,11 @@
ALTER TABLE server_error_report DROP CONSTRAINT server_error_report_pkey;
DELETE FROM server_error_report a
USING server_error_report b
WHERE a.id = b.id
AND a.ctid < b.ctid;
ALTER TABLE server_error_report ADD PRIMARY KEY (id);
CREATE INDEX server_error_report__version__idx
ON server_error_report ( version );

View File

@@ -16,6 +16,8 @@
[app.db :as db]
[app.http :as-alias http]
[app.loggers.audit :as-alias audit]
[app.loggers.database :as loggers.db]
[app.loggers.mattermost :as loggers.mm]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.doc :as-alias doc]
@@ -36,52 +38,79 @@
:context])
(defn- event->row [event]
[(uuid/next)
(:name event)
(:source event)
(:type event)
(:timestamp event)
(:created-at event)
(:profile-id event)
(db/inet (:ip-addr event))
(db/tjson (:props event))
(db/tjson (d/without-nils (:context event)))])
[(::audit/id event)
(::audit/name event)
(::audit/source event)
(::audit/type event)
(::audit/tracked-at event)
(::audit/created-at event)
(::audit/profile-id event)
(db/inet (::audit/ip-addr event))
(db/tjson (::audit/props event))
(db/tjson (d/without-nils (::audit/context event)))])
(defn- adjust-timestamp
[{:keys [timestamp created-at] :as event}]
(let [margin (inst-ms (ct/diff timestamp created-at))]
[{:keys [::audit/tracked-at ::audit/created-at] :as event}]
(let [margin (inst-ms (ct/diff tracked-at created-at))]
(if (or (neg? margin)
(> margin 3600000))
;; If event is in future or lags more than 1 hour, we reasign
;; timestamp to the server creation date
;; tracked-at to the server creation date
(-> event
(assoc :timestamp created-at)
(update :context assoc :original-timestamp timestamp))
(assoc ::audit/tracked-at created-at)
(update ::audit/context assoc :original-tracked-at tracked-at))
event)))
(defn- handle-events
[{:keys [::db/pool]} {:keys [::rpc/profile-id events] :as params}]
(defn- exception-event?
[{:keys [::audit/type ::audit/name] :as ev}]
(and (= "action" type)
(or (= "unhandled-exception" name)
(= "exception-page" name))))
(def ^:private xf:map-event-row
(comp
(map adjust-timestamp)
(map event->row)))
(defn- get-events
[{:keys [::rpc/request-at ::rpc/profile-id events] :as params}]
(let [request (-> params meta ::http/request)
ip-addr (inet/parse-request request)
tnow (ct/now)
xform (comp
(map (fn [event]
(-> event
(assoc :created-at tnow)
(assoc :profile-id profile-id)
(assoc :ip-addr ip-addr)
(assoc :source "frontend"))))
(filter :profile-id)
(map adjust-timestamp)
(map event->row))
events (sequence xform events)]
(when (seq events)
(db/insert-many! pool :audit-log event-columns events))))
(def valid-event-types
xform (map (fn [event]
{::audit/id (uuid/next)
::audit/type (:type event)
::audit/name (:name event)
::audit/props (:props event)
::audit/context (:context event)
::audit/profile-id profile-id
::audit/ip-addr ip-addr
::audit/source "frontend"
::audit/tracked-at (:timestamp event)
::audit/created-at request-at}))]
(sequence xform events)))
(defn- handle-events
[{:keys [::db/pool] :as cfg} params]
(let [events (get-events params)]
;; Look for error reports and save them on internal reports table
(when-let [events (->> events
(sequence (filter exception-event?))
(not-empty))]
(run! (partial loggers.db/emit cfg) events)
(run! (partial loggers.mm/emit cfg) events))
;; Process and save events
(when (seq events)
(let [rows (sequence xf:map-event-row events)]
(db/insert-many! pool :audit-log event-columns rows)))))
(def ^:private valid-event-types
#{"action" "identify" "trigger"})
(def schema:event
(def ^:private schema:frontend-event
[:map {:title "Event"}
[:name
[:and {:gen/elements ["update-file", "get-profile"]}
@@ -93,12 +122,13 @@
[::sm/one-of {:format "string"} valid-event-types]]]
[:props
[:map-of :keyword ::sm/any]]
[:timestamp ::ct/inst]
[:context {:optional true}
[:map-of :keyword ::sm/any]]])
(def schema:push-audit-events
(def ^:private schema:push-audit-events
[:map {:title "push-audit-events"}
[:events [:vector schema:event]]])
[:events [:vector schema:frontend-event]]])
(sv/defmethod ::push-audit-events
{::climit/id :submit-audit-events/by-profile

View File

@@ -103,6 +103,14 @@
(assoc-in [::db/pool ::db/username] (:database-username config))
(assoc-in [::db/pool ::db/password] (:database-password config))
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
(update :app.rpc/methods
(fn [state]
(-> state
(assoc :app.setup/templates templates)
(assoc :app.loggers.mattermost/reporter nil)
(assoc :app.loggers.database/reporter nil))))
(dissoc :app.srepl/server
:app.http/server
:app.http/route

View File

@@ -1,5 +1,5 @@
{:deps
{org.clojure/clojure {:mvn/version "1.12.2"}
{org.clojure/clojure {:mvn/version "1.12.4"}
org.clojure/data.json {:mvn/version "2.5.1"}
org.clojure/tools.cli {:mvn/version "1.1.230"}
org.clojure/test.check {:mvn/version "1.1.1"}
@@ -9,15 +9,15 @@
org.apache.commons/commons-pool2 {:mvn/version "2.12.1"}
;; Logging
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.1"}
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.1"}
org.apache.logging.log4j/log4j-web {:mvn/version "2.25.1"}
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.1"}
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.1"}
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-web {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"}
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.3"}
org.slf4j/slf4j-api {:mvn/version "2.0.17"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.40"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"}
selmer/selmer {:mvn/version "1.12.69"}
selmer/selmer {:mvn/version "1.12.70"}
criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "0.3.13"}
@@ -27,7 +27,7 @@
com.cognitect/transit-clj {:mvn/version "1.0.333"}
com.cognitect/transit-cljs {:mvn/version "0.8.280"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
integrant/integrant {:mvn/version "1.0.0"}
integrant/integrant {:mvn/version "1.0.1"}
funcool/cuerdas {:mvn/version "2026.415"}
funcool/promesa

View File

@@ -239,22 +239,63 @@
(recur cause))))))]
(with-out-str
(print-all cause)))))
(print-all cause))))
:cljs
(defn format-throwable
[cause & {:as opts}]
(with-out-str
(when-let [exdata (ex-data cause)]
(when-let [hint (get exdata :hint)]
(when (str/index-of hint "\n")
(println "Hint:")
(println "--------------------")
(println hint)
(println)))
(when-let [explain (get exdata ::sm/explain)]
(println "Explain:")
(println "--------------------")
(println (sm/humanize-explain explain))
(println))
(when-let [explain (get exdata :explain)]
(println "Server Explain:")
(println "--------------------")
(println explain))
(println "Data:")
(println "--------------------")
(pp/pprint (dissoc exdata ::sm/explain :explain))
(println))
(when-let [trace (.-stack cause)]
(println "Trace:")
(println "--------------------")
(println (.-stack cause))))))
(defn first-line
[s]
(let [break-index (str/index-of s "\n")]
(if (pos? break-index)
(subs s 0 break-index)
s)))
(defn print-throwable
[cause & {:as opts}]
#?(:clj
(println (format-throwable cause opts))
:cljs
(let [prefix (get opts :prefix "exception")
title (str prefix ": " (ex-message cause))
exdata (ex-data cause)]
(let [prefix (get opts :prefix)
data (ex-data cause)
title (cond->> (or (some-> (:hint data) first-line)
(ex-message cause))
(string? prefix)
(str prefix ": "))]
(js/console.group title)
(when-let [explain (get exdata ::sm/explain)]
(println (sm/humanize-explain explain)))
(js/console.log "\nData:")
(pp/pprint (dissoc exdata ::sm/explain))
(js/console.log "\nTrace:")
(js/console.error (.-stack cause)))))
(try
(js/console.log (format-throwable cause))
(finally
(js/console.groupEnd))))))

View File

@@ -39,12 +39,12 @@
(into {})))
(defn remove-attributes-for-token
"Removes applied tokens with `token-id` for the given `attributes` set from `applied-tokens`."
[attributes token applied-tokens]
"Removes applied tokens with `token-name` for the given `attributes` set from `applied-tokens`."
[attributes token-name applied-tokens]
(let [attr? (set attributes)]
(->> (remove (fn [[k v]]
(and (attr? k)
(= v (or (token-identifier token) token))))
(= v token-name)))
applied-tokens)
(into {}))))

View File

@@ -124,6 +124,7 @@
:token-base-font-size
:token-color
:token-shadow
:token-tokenscript
:transit-readable-response
:user-feedback
;; TODO: remove this flag.

View File

@@ -31,7 +31,7 @@ RUN set -ex; \
FROM base AS setup-node
ENV NODE_VERSION=v22.21.1 \
ENV NODE_VERSION=v22.22.0 \
PATH=/opt/node/bin:$PATH
RUN set -eux; \
@@ -97,18 +97,19 @@ RUN set -eux; \
FROM base AS setup-jvm
ENV CLOJURE_VERSION=1.12.3.1577
# https://clojure.org/releases/tools
ENV CLOJURE_VERSION=1.12.4.1602
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
;; \
amd64|x86_64) \
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
@@ -181,7 +182,8 @@ FROM base AS setup-utils
ENV CLJKONDO_VERSION=2026.01.19 \
BABASHKA_VERSION=1.12.208 \
CLJFMT_VERSION=0.15.6
CLJFMT_VERSION=0.15.6 \
PIXI_VERSION=0.63.2
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
@@ -224,6 +226,26 @@ RUN set -ex; \
tar -xf /tmp/babashka.tar.gz; \
rm -rf /tmp/babashka.tar.gz;
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-aarch64-unknown-linux-musl.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-x86_64-unknown-linux-musl.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
cd /tmp; \
curl -LfsSo /tmp/pixi.tar.gz ${BINARY_URL}; \
cd /opt/utils/bin; \
tar -xf /tmp/pixi.tar.gz; \
rm -rf /tmp/pixi.tar.gz;
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
@@ -375,7 +397,7 @@ ENV LANG='C.UTF-8' \
RUSTUP_HOME="/opt/rustup" \
PATH="/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH"
COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick
COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick
COPY --from=setup-jvm /opt/jdk /opt/jdk
COPY --from=setup-jvm /opt/clojure /opt/clojure
COPY --from=setup-node /opt/node /opt/node

View File

@@ -46,6 +46,11 @@ services:
- 9090:9090
- 9091:9091
# MCP
- 4400:4400
- 4401:4401
- 4402:4402
environment:
- EXTERNAL_UID=${CURRENT_USER_ID}
# SMTP setup

View File

@@ -6,7 +6,8 @@ export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bi
export CARGO_HOME="/home/penpot/.cargo"
alias l='ls --color -GFlh'
alias rm='rm -r'
alias ll='ls --color -GFlh'
alias rm='rm -rf'
alias ls='ls --color -F'
alias lsd='ls -d *(/)'
alias lsf='ls -h *(.)'

View File

@@ -121,6 +121,28 @@ http {
proxy_http_version 1.1;
}
location /mcp {
alias /home/penpot/penpot/mcp/packages/plugin/dist;
proxy_http_version 1.1;
}
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass http://127.0.0.1:4402;
proxy_http_version 1.1;
}
location /mcp/stream {
proxy_pass http://127.0.0.1:4401/mcp;
proxy_http_version 1.1;
}
location /mcp/sse {
proxy_pass http://127.0.0.1:4401/sse;
proxy_http_version 1.1;
}
location /admin {
proxy_pass http://127.0.0.1:6063/admin;
}

View File

@@ -1,3 +1,4 @@
set -g default-command "${SHELL}"
set -g mouse off
set -g history-limit 50000
setw -g mode-keys emacs

View File

@@ -6,7 +6,7 @@ ENV LANG='C.UTF-8' \
DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC
ARG IMAGEMAGICK_VERSION=7.1.1-47
ARG IMAGEMAGICK_VERSION=7.1.2-13
RUN set -e; \
apt-get -qq update; \

View File

@@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \
LC_ALL='C.UTF-8' \
JAVA_HOME="/opt/jdk" \
DEBIAN_FRONTEND=noninteractive \
NODE_VERSION=v22.21.1 \
NODE_VERSION=v22.22.0 \
TZ=Etc/UTC
RUN set -ex; \
@@ -46,12 +46,12 @@ RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
;; \
amd64|x86_64) \
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \

View File

@@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
NODE_VERSION=v22.22.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:/opt/imagick/bin:$PATH

View File

@@ -0,0 +1,58 @@
FROM ubuntu:24.04
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
mkdir -p /etc/resolvconf/resolv.conf.d; \
echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \
apt-get -qq update; \
apt-get -qqy --no-install-recommends install \
curl \
tzdata \
locales \
ca-certificates \
; \
rm -rf /var/lib/apt/lists/*; \
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \
locale-gen; \
find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete;
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \
mkdir -p /opt/node; \
cd /opt/node; \
tar -xf /tmp/nodejs.tar.gz --strip-components=1; \
chown -R root /opt/node; \
rm -rf /tmp/nodejs.tar.gz; \
corepack enable; \
mkdir -p /opt/penpot; \
chown -R penpot:penpot /opt/penpot;
ARG BUNDLE_PATH="./bundle-mcp/"
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/mcp/
WORKDIR /opt/penpot/mcp
USER penpot:penpot
RUN ./setup
CMD ["node", "index.js", "--multi-user"]

View File

@@ -198,6 +198,13 @@ services:
## Valkey (or previously Redis) is used for the websockets notifications.
PENPOT_REDIS_URI: redis://penpot-valkey/0
penpot-mcp:
image: penpotapp/mcp:${PENPOT_VERSION:-latest}
restart: always
networks:
- penpot
penpot-postgres:
image: "postgres:15"
restart: always

View File

@@ -1 +1 @@
resolver $PENPOT_INTERNAL_RESOLVER ipv6=off valid=10s;
resolver $PENPOT_INTERNAL_RESOLVER valid=10s;

View File

@@ -73,6 +73,7 @@ http {
server {
listen 8080 default_server;
listen [::]:8080 default_server;
server_name _;
client_max_body_size $PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE;
@@ -130,6 +131,7 @@ http {
}
location /readyz {
access_log off;
proxy_pass $PENPOT_BACKEND_URI$request_uri;
}

View File

@@ -47,6 +47,7 @@
"devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap",
"@penpot/tokenscript": "workspace:./packages/tokenscript",
"@penpot/plugins-runtime": "1.4.2",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor",

View File

@@ -0,0 +1,3 @@
node_modules
*.sublime-workspace
/.yarn

View File

@@ -0,0 +1,2 @@
export * from "./schemas.js";
export * from "@tokens-studio/tokenscript-interpreter";

View File

@@ -0,0 +1,13 @@
{
"name": "@penpot/tokenscript",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"@tokens-studio/tokenscript-interpreter": "^0.23.1"
}
}

View File

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import { platform } from "os";
/**
* Read environment variables from file.
@@ -6,6 +7,10 @@ import { defineConfig, devices } from "@playwright/test";
*/
// require('dotenv').config();
const userAgent = platform === 'darwin' ?
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" :
undefined;
/**
* @see https://playwright.dev/docs/test-configuration
*/
@@ -43,12 +48,20 @@ export default defineConfig({
projects: [
{
name: "default",
use: { ...devices["Desktop Chrome"] },
testDir: "./playwright/ui/specs",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 }, // Add custom viewport size
video: 'retain-on-failure',
trace: 'retain-on-failure',
}
userAgent,
},
snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}.png",
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.001,
},
},
},
{
name: "ds",

View File

@@ -0,0 +1,7 @@
{
"~:file-id": "~u8d38942d-b01f-800e-8007-79ee6a9bac45",
"~:tag": "component",
"~:object-id": "8d38942d-b01f-800e-8007-79ee6a9bac45/8d38942d-b01f-800e-8007-79ee6a9bac46/6b68aedd-4c5b-80b9-8007-7b38c1d34ce4/component",
"~:media-id": "~ube2dc82e-615b-486b-a193-8768bdb51d7a",
"~:created-at": "~m1769523563389"
}

View File

@@ -253,7 +253,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async #waitForWebSocketReadiness() {
// TODO: find a better event to settle whether the app is ready to receive notifications via ws
await expect(this.pageName).toHaveText("Page 1");
await expect(this.pageName).toHaveText("Page 1", { timeout: 30000 })
}
async sendPresenceMessage(fixture) {
@@ -383,19 +383,46 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.keyboard.press("T");
await this.page.waitForTimeout(timeToWait);
await this.clickAndMove(x1, y1, x2, y2);
await this.page.waitForTimeout(timeToWait);
await expect(this.page.getByTestId("text-editor")).toBeVisible();
if (initialText) {
await this.page.keyboard.type(initialText);
}
}
/**
* Copies the selected element into the clipboard.
* Copies the selected element into the clipboard, or copy the
* content of the locator into the clipboard.
*
* @returns {Promise<void>}
*/
async copy() {
return this.page.keyboard.press("Control+C");
async copy(kind = "keyboard", locator = undefined) {
if (kind === "context-menu" && locator) {
await locator.click({ button: "right" });
await this.page.getByText("Copy", { exact: true }).click();
} else {
await this.page.keyboard.press("ControlOrMeta+C");
}
// wait for the clipboard to be updated
await this.page.waitForFunction(async () => {
const content = await navigator.clipboard.readText()
return content !== "";
}, { timeout: 1000 });
}
async cut(kind = "keyboard", locator = undefined) {
if (kind === "context-menu" && locator) {
await locator.click({ button: "right" });
await this.page.getByText("Cut", { exact: true }).click();
} else {
await this.page.keyboard.press("ControlOrMeta+X");
}
// wait for the clipboard to be updated
await this.page.waitForFunction(async () => {
const content = await navigator.clipboard.readText()
return content !== "";
}, { timeout: 1000 });
}
/**
@@ -407,9 +434,9 @@ export class WorkspacePage extends BaseWebSocketPage {
async paste(kind = "keyboard") {
if (kind === "context-menu") {
await this.viewport.click({ button: "right" });
return this.page.getByText("PasteCtrlV").click();
return this.page.getByText("Paste", { exact: true }).click();
}
return this.page.keyboard.press("Control+V");
return this.page.keyboard.press("ControlOrMeta+V");
}
async panOnViewportAt(x, y, width, height) {
@@ -448,11 +475,11 @@ export class WorkspacePage extends BaseWebSocketPage {
const layer = this.layers
.getByTestId("layer-row")
.filter({ hasText: name });
const button = layer.getByRole("button");
const button = layer.getByTestId("toggle-content");
await button.waitFor();
await expect(button).toBeVisible();
await button.click(clickOptions);
await this.page.waitForTimeout(500);
await button.waitFor({ ariaExpanded: true });
}
async expectSelectedLayer(name) {
@@ -495,13 +522,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async clickColorPalette(clickOptions = {}) {
await this.palette
.getByRole("button", { name: "Color Palette (Alt+P)" })
.click(clickOptions);
}
async clickColorPalette(clickOptions = {}) {
await this.palette
.getByRole("button", { name: "Color Palette (Alt+P)" })
.getByRole("button", { name: /Color Palette/ })
.click(clickOptions);
}

View File

@@ -15,6 +15,8 @@ test("User can complete the onboarding", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
const onboardingPage = new OnboardingPage(page);
await dashboardPage.mockConfigFlags(["enable-onboarding"]);
await dashboardPage.goToDashboard();
await expect(
page.getByRole("heading", { name: "Help us get to know you" }),

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage, WASM_FLAGS } from "../pages/WasmWorkspacePage";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);

View File

@@ -5,7 +5,7 @@ import { WorkspacePage } from "../pages/WorkspacePage";
const timeToWait = 100;
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE);
await Clipboard.enable(context, Clipboard.Permission.ALL);
await WorkspacePage.init(page);
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);

View File

@@ -774,7 +774,7 @@ test.describe("Tokens: Apply token", () => {
await workspace.layers
.getByTestId("layer-row")
.nth(1)
.getByRole("button", { name: "Toggle layer" })
.getByTestId("toggle-content")
.click();
await workspace.layers.getByTestId("layer-row").nth(2).click();
@@ -831,15 +831,102 @@ test.describe("Tokens: Apply token", () => {
});
await detachButton.click();
await expect(marginPillXL).not.toBeVisible();
const horizontalMarginInput = layoutItemSectionSidebar.getByText('Horizontal marginOpen token');
const horizontalMarginInput = layoutItemSectionSidebar.getByText(
"Horizontal marginOpen token",
);
await expect(horizontalMarginInput).toBeVisible();
const tokenDropdown = horizontalMarginInput.getByRole('button', { name: 'Open token list' });
const tokenDropdown = horizontalMarginInput.getByRole("button", {
name: "Open token list",
});
await tokenDropdown.click();
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
await expect(marginPillXL).toBeVisible();
});
});
test.describe("Tokens: Detach token", () => {
test("User applies border-radius token to a shape from sidebar", async ({
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
// Open tokens sections on left sidebar
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
// Unfold border radius tokens
await page.getByRole("button", { name: "Border Radius 3" }).click();
await expect(
tokensSidebar.getByRole("button", { name: "borderRadius" }),
).toBeVisible();
await tokensSidebar.getByRole("button", { name: "borderRadius" }).click();
await expect(
tokensSidebar.getByRole("button", { name: "borderRadius.sm" }),
).toBeVisible();
// Apply border radius token from token panels
await tokensSidebar
.getByRole("button", { name: "borderRadius.sm" })
.click();
// Check if border radius sections is visible on right sidebar
const borderRadiusSection = page.getByRole("region", {
name: "border-radius-section",
});
await expect(borderRadiusSection).toBeVisible();
// Check if token pill is visible on design tab on right sidebar
const brTokenPillSM = borderRadiusSection.getByRole("button", {
name: "borderRadius.sm",
});
await expect(brTokenPillSM).toBeVisible();
await brTokenPillSM.click();
// Rename token
await tokensSidebar
.getByRole("button", { name: "borderRadius.sm" })
.click({ button: "right" });
await expect(page.getByText("Edit token")).toBeVisible();
await page.getByText("Edit token").click();
const editModal = page.getByTestId("token-update-create-modal");
await expect(editModal).toBeVisible();
await expect(
editModal.getByRole("textbox", { name: "Name" }),
).toBeVisible();
await editModal
.getByRole("textbox", { name: "Name" })
.fill("BorderRadius.smBis");
const submitButton = editModal.getByRole("button", { name: "Save" });
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(page.getByText("Don't remap")).toBeVisible();
await page.getByText("Don't remap").click();
const brokenPill = borderRadiusSection.getByRole("button", {
name: "This token is not in any",
});
await expect(brokenPill).toBeVisible();
// Detach broken token
const detachButton = borderRadiusSection.getByRole("button", {
name: "Detach token",
});
await detachButton.click();
await expect(brokenPill).not.toBeVisible();
//De-select and select shape again to double check token is detached
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(0).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
await expect(brokenPill).not.toBeVisible();
});
});

View File

@@ -158,7 +158,7 @@ test.describe("Tokens - creation", () => {
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
@@ -189,7 +189,7 @@ test.describe("Tokens - creation", () => {
// 2. Invalid value → disabled + error message
await valueField.fill("1");
const invalidValueErrorNode =
tokensUpdateCreateModal.getByText(invalidValueError);
tokensUpdateCreateModal.getByText(invalidValueError);
await expect(invalidValueErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -197,7 +197,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -207,7 +207,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("{color.primary}");
const selfRefErrorNode =
tokensUpdateCreateModal.getByText(selfReferenceError);
tokensUpdateCreateModal.getByText(selfReferenceError);
await expect(selfRefErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -320,7 +320,7 @@ test.describe("Tokens - creation", () => {
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -356,7 +356,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -366,7 +366,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("{my-token}");
const selfRefErrorNode =
tokensUpdateCreateModal.getByText(selfReferenceError);
tokensUpdateCreateModal.getByText(selfReferenceError);
await expect(selfRefErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -459,13 +459,13 @@ test.describe("Tokens - creation", () => {
test("User creates font weight token", async ({ page }) => {
const invalidValueError =
"Invalid font weight value: use numeric values (100-950) or standard names (thin, light, regular, bold, etc.) optionally followed by 'Italic'";
"Invalid font weight value: use numeric values (100-950) or standard names (thin, light, regular, bold, etc.) optionally followed by 'Italic'";
const emptyNameError = "Name should be at least 1 character";
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -501,7 +501,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("red");
const invalidValueErrorNode =
tokensUpdateCreateModal.getByText(invalidValueError);
tokensUpdateCreateModal.getByText(invalidValueError);
await expect(invalidValueErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -510,7 +510,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -520,7 +520,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("{my-token}");
const selfRefErrorNode =
tokensUpdateCreateModal.getByText(selfReferenceError);
tokensUpdateCreateModal.getByText(selfReferenceError);
await expect(selfRefErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -595,13 +595,13 @@ test.describe("Tokens - creation", () => {
test("User creates text case token", async ({ page }) => {
const invalidValueError =
"Invalid token value: only none, Uppercase, Lowercase or Capitalize are accepted";
"Invalid token value: only none, Uppercase, Lowercase or Capitalize are accepted";
const emptyNameError = "Name should be at least 1 character";
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -637,7 +637,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("red");
const invalidValueErrorNode =
tokensUpdateCreateModal.getByText(invalidValueError);
tokensUpdateCreateModal.getByText(invalidValueError);
await expect(invalidValueErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -646,7 +646,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -656,7 +656,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("{my-token}");
const selfRefErrorNode =
tokensUpdateCreateModal.getByText(selfReferenceError);
tokensUpdateCreateModal.getByText(selfReferenceError);
await expect(selfRefErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -711,13 +711,13 @@ test.describe("Tokens - creation", () => {
test("User creates text decoration token", async ({ page }) => {
const invalidValueError =
"Invalid token value: only none, underline and strike-through are accepted";
"Invalid token value: only none, underline and strike-through are accepted";
const emptyNameError = "Name should be at least 1 character";
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -755,7 +755,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("red");
const invalidValueErrorNode =
tokensUpdateCreateModal.getByText(invalidValueError);
tokensUpdateCreateModal.getByText(invalidValueError);
await expect(invalidValueErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -764,7 +764,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -774,7 +774,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("{my-token}");
const selfRefErrorNode =
tokensUpdateCreateModal.getByText(selfReferenceError);
tokensUpdateCreateModal.getByText(selfReferenceError);
await expect(selfRefErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -831,7 +831,7 @@ test.describe("Tokens - creation", () => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -900,7 +900,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -977,9 +977,9 @@ test.describe("Tokens - creation", () => {
await nameField.fill("my-token-2");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
tokensUpdateCreateModal.getByTestId("reference-opt");
const compositeToggle =
tokensUpdateCreateModal.getByTestId("composite-opt");
tokensUpdateCreateModal.getByTestId("composite-opt");
await referenceToggle.click();
const referenceInput = tokensUpdateCreateModal.getByPlaceholder(
@@ -1012,7 +1012,7 @@ test.describe("Tokens - creation", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await setupTypographyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1038,7 +1038,7 @@ test.describe("Tokens - creation", () => {
// Switch to reference tab, should not be submittable either
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
await expect(submitButton).toBeDisabled();
});
@@ -1047,7 +1047,7 @@ test.describe("Tokens - creation", () => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page, {flags: ["enable-token-shadow"]});
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1116,7 +1116,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -1198,9 +1198,9 @@ test.describe("Tokens - creation", () => {
await nameField.fill("my-token-2");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
tokensUpdateCreateModal.getByTestId("reference-opt");
const compositeToggle =
tokensUpdateCreateModal.getByTestId("composite-opt");
tokensUpdateCreateModal.getByTestId("composite-opt");
await referenceToggle.click();
const referenceInput = tokensUpdateCreateModal.getByPlaceholder(
@@ -1232,7 +1232,7 @@ test.describe("Tokens - creation", () => {
test("User creates typography token", async ({ page }) => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1287,7 +1287,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -1443,9 +1443,9 @@ test.describe("Tokens - creation", () => {
await nameField.fill("my-token-2");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
tokensUpdateCreateModal.getByTestId("reference-opt");
const compositeToggle =
tokensUpdateCreateModal.getByTestId("composite-opt");
tokensUpdateCreateModal.getByTestId("composite-opt");
await referenceToggle.click();
@@ -1479,7 +1479,7 @@ test.describe("Tokens - creation", () => {
test("User adds typography token with reference", async ({ page }) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await setupTypographyTokensFile(page);
const newTokenTitle = "NewReference";
@@ -1508,7 +1508,7 @@ test.describe("Tokens - creation", () => {
});
const resolvedValue =
await tokensUpdateCreateModal.getByText("Resolved value:");
await tokensUpdateCreateModal.getByText("Resolved value:");
await expect(resolvedValue).toBeVisible();
await expect(resolvedValue).toContainText("Font Family: 42dot Sans");
await expect(resolvedValue).toContainText("Font Size: 100");
@@ -1531,7 +1531,7 @@ test.describe("Tokens - creation", () => {
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
@@ -1591,7 +1591,7 @@ test.describe("Tokens - creation", () => {
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1613,15 +1613,11 @@ test.describe("Tokens - creation", () => {
});
});
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
await tokensSidebar.getByRole("button", { name: "Add Token: Color" }).click();
// Create grouped color token with mouse
@@ -1647,9 +1643,7 @@ test("User creates grouped color token", async ({ page }) => {
await expect(tokensSidebar.getByLabel("primary")).toBeEnabled();
});
test("User cant create regular token with value missing", async ({
page,
}) => {
test("User cant create regular token with value missing", async ({ page }) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1677,7 +1671,7 @@ test("User cant create regular token with value missing", async ({
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1703,7 +1697,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await setupTypographyTokensFile(page);
await tokensSidebar
.getByRole("button")
@@ -1724,8 +1718,8 @@ test.describe("Tokens tab - edition", () => {
// Fill font-family to verify to verify that input value doesn't get split into list of characters
const fontFamilyField = tokensUpdateCreateModal
.getByLabel("Font family")
.first();
.getByLabel("Font family")
.first();
await fontFamilyField.fill("OneWord");
// Invalidate incorrect values for font size
@@ -1746,11 +1740,11 @@ test.describe("Tokens tab - edition", () => {
const fontWeightField = tokensUpdateCreateModal.getByLabel(/Font Weight/i);
const letterSpacingField =
tokensUpdateCreateModal.getByLabel(/Letter Spacing/i);
tokensUpdateCreateModal.getByLabel(/Letter Spacing/i);
const lineHeightField = tokensUpdateCreateModal.getByLabel(/Line Height/i);
const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i);
const textDecorationField =
tokensUpdateCreateModal.getByLabel(/Text Decoration/i);
tokensUpdateCreateModal.getByLabel(/Text Decoration/i);
// Capture all values before switching tabs
const originalValues = {
@@ -1765,14 +1759,14 @@ test.describe("Tokens tab - edition", () => {
// Switch to reference tab and back to composite tab
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
// Empty reference tab should be disabled
await expect(saveButton).toBeDisabled();
const compositeTabButton =
tokensUpdateCreateModal.getByTestId("composite-opt");
tokensUpdateCreateModal.getByTestId("composite-opt");
await compositeTabButton.click();
// Filled composite tab should be enabled
@@ -1799,7 +1793,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1835,7 +1829,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1890,7 +1884,7 @@ test.describe("Tokens tab - edition", () => {
test.describe("Tokens tab - delete", () => {
test("User delete color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1910,7 +1904,7 @@ test.describe("Tokens tab - delete", () => {
});
test("User removes node and all child tokens", async ({ page }) => {
const { tokensSidebar, workspacePage } = await setupTokensFile(page);
const { tokensSidebar } = await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1919,7 +1913,7 @@ test.describe("Tokens tab - delete", () => {
// Verify that the node and child token are visible before deletion
const colorNode = tokensSidebar.getByRole("button", {
name: "blue",
name: "colors",
exact: true,
});
const colorNodeToken = tokensSidebar.getByRole("button", {
@@ -1943,5 +1937,13 @@ test.describe("Tokens tab - delete", () => {
await expect(colorNode).not.toBeVisible();
// Verify that child token is also removed
await expect(colorNodeToken).not.toBeVisible();
// Save the type button to verify that expands/folds
const tokenTypeButton = await tokensSidebar.getByRole("button", {
name: "Color",
exact: true,
});
await expect(tokenTypeButton).toHaveAttribute("aria-expanded", "false");
});
});

View File

@@ -1,12 +1,19 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
import { WorkspacePage } from "../pages/WorkspacePage";
import { BaseWebSocketPage } from "../pages/BaseWebSocketPage";
import { Clipboard } from "../../helpers/Clipboard";
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ALL);
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-variants.json");
});
test.afterEach(async ({ context }) => {
context.clearPermissions();
});
const setupVariantsFile = async (workspacePage) => {
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
@@ -34,9 +41,9 @@ const setupVariantsFileWithVariant = async (workspacePage) => {
await setupVariantsFile(workspacePage);
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(500);
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(500);
// We wait until layer-row starts looking like it an component
@@ -156,7 +163,7 @@ test("User duplicates a variant container", async ({ page }) => {
await variant.container.click();
//Duplicate the variant container
await workspacePage.page.keyboard.press("Control+d");
await workspacePage.page.keyboard.press("ControlOrMeta+d");
const variant_original = await findVariant(workspacePage, 1); // On duplicate, the new item is the first
const variant_duplicate = await findVariant(workspacePage, 0);
@@ -169,25 +176,27 @@ test("User duplicates a variant container", async ({ page }) => {
await validateVariant(variant_duplicate);
});
test("User copy paste a variant container", async ({ page }) => {
test("User copy paste a variant container", async ({ page, context }) => {
const workspacePage = new WorkspacePage(page);
// Access to the read/write clipboard necesary for this functionality
await setupVariantsFileWithVariant(workspacePage);
await workspacePage.mockRPC(
/create-file-object-thumbnail.*/,
"workspace/create-file-object-thumbnail.json",
);
const variant = findVariantNoWait(workspacePage, 0);
// await variant.container.waitFor();
// Select the variant container
await variant.container.click();
await workspacePage.page.waitForTimeout(1000);
// Copy the variant container
await workspacePage.page.keyboard.press("Control+c");
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.copy("keyboard");
// Paste the variant container
await workspacePage.clickAt(400, 400);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variants = workspacePage.layers.getByText("Rectangle");
await expect(variants).toHaveCount(2);
const variantDuplicate = findVariantNoWait(workspacePage, 0);
const variantOriginal = findVariantNoWait(workspacePage, 1);
@@ -212,18 +221,17 @@ test("User cut paste a variant container", async ({ page }) => {
await variant.container.click();
//Cut the variant container
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
await workspacePage.cut("keyboard");
//Paste the variant container
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
await workspacePage.page.waitForTimeout(500);
const variantPasted = await findVariant(workspacePage, 0);
// Expand the layers
await variantPasted.container.locator("button").first().click();
await workspacePage.clickToggableLayer("Rectangle");
// The variants are valid
await validateVariant(variantPasted);
@@ -239,27 +247,34 @@ test("User cut paste a variant container into a board, and undo twice", async ({
//Create a board
await workspacePage.boardButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 100, 100);
// NOTE: this board should not intersect the existing variants, otherwise
// this test is flaky
await workspacePage.clickWithDragViewportAt(200, 200, 100, 100);
await workspacePage.clickAt(495, 495);
const board = await workspacePage.rootShape.locator("Board");
// Select the variant container
await variant.container.click();
// await variant.container.click();
await workspacePage.clickLeafLayer("Rectangle");
//Cut the variant container
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
await workspacePage.cut("keyboard");
await expect(variant.container).not.toBeVisible();
//Select the board
await workspacePage.clickLeafLayer("Board");
//Paste the variant container inside the board
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
await expect(variant.container).toBeVisible();
//Undo twice
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.waitForTimeout(500);
await workspacePage.page.keyboard.press("ControlOrMeta+z");
await expect(variant.container).not.toBeVisible();
await workspacePage.page.keyboard.press("ControlOrMeta+z");
await expect(variant.container).toBeVisible();
const variantAfterUndo = await findVariant(workspacePage, 0);
@@ -276,12 +291,12 @@ test("User copy paste a variant", async ({ page }) => {
// Select the variant1
await variant.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+c");
// Copy the variant
await workspacePage.copy("keyboard");
//Paste the variant
// Paste the variant
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const copy = await workspacePage.layers
.getByTestId("layer-row")
@@ -302,11 +317,11 @@ test("User cut paste a variant outside the container", async ({ page }) => {
await variant.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the variant
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const component = await workspacePage.layers
.getByTestId("layer-row")
@@ -324,15 +339,11 @@ test("User drag and drop a variant outside the container", async ({ page }) => {
const variant = await findVariant(workspacePage, 0);
// Drag and drop the variant
await workspacePage.clickWithDragViewportAt(350, 400, 0, 200);
// FIXME: to make this test more resilient, we should get the bounding box of the Value 1 variant
// and use it to calculate the target position
await workspacePage.clickWithDragViewportAt(600, 500, 0, 300);
const component = await workspacePage.layers
.getByTestId("layer-row")
.filter({ has: workspacePage.page.getByText("Rectangle / Value 1") })
.filter({ has: workspacePage.page.getByTestId("icon-component") });
//The component exists and is visible
await expect(component).toBeVisible();
await expect(workspacePage.layers.getByText("Rectangle / Value 1")).toBeVisible();
});
test("User cut paste a component inside a variant", async ({ page }) => {
@@ -345,14 +356,14 @@ test("User cut paste a component inside a variant", async ({ page }) => {
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
//Cut the component
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the component inside the variant
await variant.container.click();
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -376,7 +387,7 @@ test("User cut paste a component with path inside a variant", async ({
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
@@ -387,11 +398,11 @@ test("User cut paste a component with path inside a variant", async ({
await workspacePage.page.keyboard.press("Enter");
//Cut the component
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the component inside the variant
await variant.container.click();
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -415,7 +426,7 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
@@ -426,7 +437,7 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.page.keyboard.press("Enter");
//Drag and drop the component the component
await workspacePage.clickWithDragViewportAt(510, 510, 0, -200);
await workspacePage.clickWithDragViewportAt(510, 510, 200, 0);
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -446,8 +457,8 @@ test("User cut paste a variant into another container", async ({ page }) => {
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
const variantOrigin = await findVariantNoWait(workspacePage, 1);
@@ -457,11 +468,11 @@ test("User cut paste a variant into another container", async ({ page }) => {
await variantOrigin.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the variant
await workspacePage.layers.getByText("Ellipse").first().click();
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variant3 = workspacePage.layers
.getByTestId("layer-row")

View File

@@ -1,13 +1,13 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { presenceFixture, joinFixture2, joinFixture3 } from "../../data/workspace/ws-notifications";
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
await WasmWorkspacePage.init(page);
});
test("User loads worskpace with empty file", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -16,7 +16,7 @@ test("User loads worskpace with empty file", async ({ page }) => {
});
test("User opens a file with a bad page id", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace({
@@ -29,7 +29,7 @@ test("User opens a file with a bad page id", async ({ page }) => {
test("User receives presence notifications updates in the workspace", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -43,7 +43,7 @@ test("User receives presence notifications updates in the workspace", async ({
test("BUG 13058 - Presence list shows up to 3 user avatars", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -63,7 +63,7 @@ test("BUG 13058 - Presence list shows up to 3 user avatars", async ({
});
test("User draws a rect", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"update-file?id=*",
@@ -74,13 +74,12 @@ test("User draws a rect", async ({ page }) => {
await workspacePage.rectShapeButton.click();
await workspacePage.clickWithDragViewportAt(128, 128, 200, 100);
const shape = await workspacePage.rootShape.locator("rect");
await expect(shape).toHaveAttribute("width", "200");
await expect(shape).toHaveAttribute("height", "100");
await workspacePage.hideUI();
await expect(workspacePage.canvas).toHaveScreenshot();
});
test("User makes a group", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
/get\-file\?/,
@@ -96,14 +95,14 @@ test("User makes a group", async ({ page }) => {
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.page.keyboard.press("Control+g");
await workspacePage.page.keyboard.press("ControlOrMeta+g");
await workspacePage.expectSelectedLayer("Group");
});
test("Bug 7654 - Toolbar keeps toggling on and off on spacebar press", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -113,10 +112,10 @@ test("Bug 7654 - Toolbar keeps toggling on and off on spacebar press", async ({
await workspacePage.expectHiddenToolbarOptions();
});
test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", async ({
test("Bug 7525 - User moves a scrollbar and no selection rectangle appears", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
/get\-file\?/,
@@ -132,8 +131,8 @@ test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", asyn
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
// Move created rect to a corner, in orther to get scrollbars
await workspacePage.panOnViewportAt(128, 128, 300, 300);
// Move created rect to a corner, in order to get scrollbars
await workspacePage.panOnViewportAt(128, 128, 600, 600);
// Check scrollbars appear
const horizontalScrollbar = workspacePage.horizontalScrollbar;
@@ -152,7 +151,7 @@ test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", asyn
test("User adds a library and its automatically selected in the color palette", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"link-file-to-library",
@@ -197,7 +196,7 @@ test("User adds a library and its automatically selected in the color palette",
test("Bug 10179 - Drag & drop doesn't add colors to the Recent Colors palette", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
await workspacePage.moveButton.click();
@@ -240,7 +239,7 @@ test("Bug 10179 - Drag & drop doesn't add colors to the Recent Colors palette",
test("Bug 7489 - Workspace-palette items stay hidden when opening with keyboard-shortcut", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -257,7 +256,7 @@ test("Bug 7489 - Workspace-palette items stay hidden when opening with keyboard-
test("Bug 8784 - Use keyboard arrow to move inside a text input does not change tabs", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
await workspacePage.pageName.click();
@@ -267,7 +266,7 @@ test("Bug 8784 - Use keyboard arrow to move inside a text input does not change
});
test("Bug 9066 - Problem with grid layout", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9066.json");
@@ -295,7 +294,7 @@ test("Bug 9066 - Problem with grid layout", async ({ page }) => {
});
test("User have toolbar", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -304,7 +303,7 @@ test("User have toolbar", async ({ page }) => {
});
test("User have edition menu entries", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -320,7 +319,7 @@ test("User have edition menu entries", async ({ page }) => {
});
test("Copy/paste properties", async ({ page, context }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
@@ -386,23 +385,23 @@ test("Copy/paste properties", async ({ page, context }) => {
});
test("[Taiga #9929] Paste text in workspace", async ({ page, context }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(() => navigator.clipboard.writeText("Lorem ipsum dolor"));
await workspacePage.viewport.click({ button: "right" });
await page.getByText("PasteCtrlV").click();
await page.getByText(/^Paste/i).click();
await workspacePage.viewport
.getByRole("textbox")
.getByText("Lorem ipsum dolor");
});
test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
page,
context,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9930.json");
await workspacePage.goToWorkspace({
@@ -410,16 +409,18 @@ test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
pageId: "fb9798e7-a547-80ae-8005-9ffda4a13e2c",
});
const zoom = await page.getByTitle("Zoom");
const zoom = page.getByTitle("Zoom");
await zoom.click();
const zoomIn = await page.getByRole("button", { name: "Zoom in" });
const zoomIn = page.getByRole("button", { name: "Zoom in" });
await zoomIn.click();
await zoomIn.click();
await zoomIn.click();
// Zoom fit all
await page.keyboard.press("Shift+1");
// Select all shapes to display selrect
await workspacePage.page.keyboard.press("ControlOrMeta+a");
const ids = [
"shape-165d1e5a-5873-8010-8005-9ffdbeaeec59",
@@ -441,7 +442,7 @@ test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
const viewportBoundingBox = await workspacePage.viewport.boundingBox();
for (const id of ids) {
const shape = await page.locator(`.ws-shape-wrapper > g#${id}`);
const shape = page.locator(`.viewport-selrect`);
const shapeBoundingBox = await shape.boundingBox();
expect(contains(viewportBoundingBox, shapeBoundingBox)).toBeTruthy();
}
@@ -450,7 +451,7 @@ test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
test("Bug 9877, user navigation to dashboard from header goes to blank page", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -467,7 +468,7 @@ test("Bug 9877, user navigation to dashboard from header goes to blank page", as
test("Bug 8371 - Flatten option is not visible in context menu", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockGetFile("workspace/get-file-8371.json");
await workspacePage.goToWorkspace({

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

123
frontend/pnpm-lock.yaml generated
View File

@@ -28,6 +28,9 @@ importers:
'@penpot/text-editor':
specifier: workspace:./text-editor
version: link:text-editor
'@penpot/tokenscript':
specifier: workspace:./packages/tokenscript
version: link:packages/tokenscript
'@playwright/test':
specifier: 1.58.0
version: 1.58.0
@@ -248,6 +251,12 @@ importers:
packages/mousetrap: {}
packages/tokenscript:
dependencies:
'@tokens-studio/tokenscript-interpreter':
specifier: ^0.23.1
version: 0.23.1
text-editor:
devDependencies:
'@playwright/test':
@@ -299,6 +308,12 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@ark/schema@0.56.0':
resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==}
'@ark/util@0.56.0':
resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==}
'@asamuzakjp/css-color@4.1.1':
resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==}
@@ -924,36 +939,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -1035,24 +1056,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@resvg/resvg-js-linux-x64-musl@2.6.2':
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
@@ -1149,121 +1174,145 @@ packages:
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-gnueabihf@4.56.0':
resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm-musleabihf@4.56.0':
resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.54.0':
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-gnu@4.56.0':
resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.54.0':
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-musl@4.56.0':
resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.54.0':
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-gnu@4.56.0':
resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.56.0':
resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.56.0':
resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.56.0':
resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.56.0':
resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.54.0':
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-musl@4.56.0':
resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.54.0':
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.56.0':
resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.54.0':
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.56.0':
resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.54.0':
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-x64-musl@4.56.0':
resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.56.0':
resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==}
@@ -1436,6 +1485,11 @@ packages:
peerDependencies:
style-dictionary: '>=4.3.0 < 6'
'@tokens-studio/tokenscript-interpreter@0.23.1':
resolution: {integrity: sha512-aIcJprCkHIyckl0Knn78Sn7ef3U3IXLjNv9MOePdNR0Mz3Z4PleerldtfLmr1DdXUXiroVSyJROyJrO3TfB2Gg==}
engines: {node: '>=16.0.0'}
hasBin: true
'@tokens-studio/types@0.5.2':
resolution: {integrity: sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==}
@@ -1674,6 +1728,12 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
arkregex@0.0.5:
resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==}
arktype@2.1.29:
resolution: {integrity: sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ==}
array-buffer-byte-length@1.0.2:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'}
@@ -1782,6 +1842,9 @@ packages:
buffer-builder@0.2.0:
resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==}
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -1952,6 +2015,10 @@ packages:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
commander@14.0.2:
resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==}
engines: {node: '>=20'}
commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
@@ -3360,6 +3427,9 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3637,6 +3707,10 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
readline-sync@1.4.10:
resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==}
engines: {node: '>= 0.8.0'}
recast@0.23.11:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
@@ -3784,48 +3858,56 @@ packages:
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
libc: glibc
sass-embedded-linux-arm@1.97.1:
resolution: {integrity: sha512-48VxaTUApLyx1NXFdZhKqI/7FYLmz8Ju3Ki2V/p+mhn5raHgAiYeFgn8O1WGxTOh+hBb9y3FdSR5a8MNTbmKMQ==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
libc: glibc
sass-embedded-linux-musl-arm64@1.97.1:
resolution: {integrity: sha512-kD35WSD9o0279Ptwid3Jnbovo1FYnuG2mayYk9z4ZI4mweXEK6vTu+tlvCE/MdF/zFKSj11qaxaH+uzXe2cO5A==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
libc: musl
sass-embedded-linux-musl-arm@1.97.1:
resolution: {integrity: sha512-FUFs466t3PVViVOKY/60JgLLtl61Pf7OW+g5BeEfuqVcSvYUECVHeiYHtX1fT78PEVa0h9tHpM6XpWti+7WYFA==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
libc: musl
sass-embedded-linux-musl-riscv64@1.97.1:
resolution: {integrity: sha512-ZgpYps5YHuhA2+KiLkPukRbS5298QObgUhPll/gm5i0LOZleKCwrFELpVPcbhsSBuxqji2uaag5OL+n3JRBVVg==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
libc: musl
sass-embedded-linux-musl-x64@1.97.1:
resolution: {integrity: sha512-wcAigOyyvZ6o1zVypWV7QLZqpOEVnlBqJr9MbpnRIm74qFTSbAEmShoh8yMXBymzuVSmEbThxAwW01/TLf62tA==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
libc: musl
sass-embedded-linux-riscv64@1.97.1:
resolution: {integrity: sha512-9j1qE1ZrLMuGb+LUmBzw93Z4TNfqlRkkxjPVZy6u5vIggeSfvGbte7eRoYBNWX6SFew/yBCL90KXIirWFSGrlQ==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
libc: glibc
sass-embedded-linux-x64@1.97.1:
resolution: {integrity: sha512-7nrLFYMH/UgvEgXR5JxQJ6y9N4IJmnFnYoDxN0nw0jUp+CQWQL4EJ4RqAKTGelneueRbccvt2sEyPK+X0KJ9Jg==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
libc: glibc
sass-embedded-unknown-all@1.97.1:
resolution: {integrity: sha512-oPSeKc7vS2dx3ZJHiUhHKcyqNq0GWzAiR8zMVpPd/kVMl5ZfVyw+5HTCxxWDBGkX02lNpou27JkeBPCaneYGAQ==}
@@ -4156,6 +4238,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
@@ -4677,6 +4760,10 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yauzl@3.2.0:
resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==}
engines: {node: '>=12'}
yocto-queue@1.2.2:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
@@ -4695,6 +4782,12 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@ark/schema@0.56.0':
dependencies:
'@ark/util': 0.56.0
'@ark/util@0.56.0': {}
'@asamuzakjp/css-color@4.1.1':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -5608,6 +5701,13 @@ snapshots:
is-mergeable-object: 1.1.1
style-dictionary: 5.0.0-rc.1
'@tokens-studio/tokenscript-interpreter@0.23.1':
dependencies:
arktype: 2.1.29
commander: 14.0.2
readline-sync: 1.4.10
yauzl: 3.2.0
'@tokens-studio/types@0.5.2': {}
'@trysound/sax@0.2.0': {}
@@ -5901,6 +6001,16 @@ snapshots:
aria-query@5.3.2: {}
arkregex@0.0.5:
dependencies:
'@ark/util': 0.56.0
arktype@2.1.29:
dependencies:
'@ark/schema': 0.56.0
'@ark/util': 0.56.0
arkregex: 0.0.5
array-buffer-byte-length@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -6040,6 +6150,8 @@ snapshots:
buffer-builder@0.2.0: {}
buffer-crc32@0.2.13: {}
buffer-from@1.1.2: {}
buffer@5.7.1:
@@ -6212,6 +6324,8 @@ snapshots:
commander@12.1.0: {}
commander@14.0.2: {}
commander@7.2.0: {}
component-emitter@2.0.0: {}
@@ -7735,6 +7849,8 @@ snapshots:
pathval@2.0.1: {}
pend@1.2.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -8029,6 +8145,8 @@ snapshots:
readdirp@4.1.2: {}
readline-sync@1.4.10: {}
recast@0.23.11:
dependencies:
ast-types: 0.16.1
@@ -9195,6 +9313,11 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yauzl@3.2.0:
dependencies:
buffer-crc32: 0.2.13
pend: 1.2.0
yocto-queue@1.2.2: {}
zod@3.25.76: {}

View File

@@ -6,4 +6,5 @@ shamefullyHoist: true
packages:
- "packages/draft-js"
- "packages/mousetrap"
- "packages/tokenscript"
- "text-editor"

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>WASM + WebGL2 Canvas</title>
<style>
body {
margin: 0;
background: #111;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
}
canvas {
width: 100%;
height: 100%;
position: absolute;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, addShapeSolidStrokeFill
} from './js/lib.js';
const canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const params = new URLSearchParams(document.location.search);
const shapes = params.get("shapes") || 1000;
initWasmModule().then(Module => {
init(Module);
assignCanvas(canvas);
Module._set_canvas_background(hexToU32ARGB("#FABADA", 1));
Module._init_shapes_pool(shapes + 1);
setupInteraction(canvas);
const children = [];
for (let i = 0; i < shapes; i++) {
const uuid = crypto.randomUUID();
children.push(uuid);
useShape(uuid);
Module._set_parent(0, 0, 0, 0);
Module._set_shape_type(3);
const x1 = getRandomInt(0, canvas.width);
const y1 = getRandomInt(0, canvas.height);
const width = getRandomInt(20, 100);
const height = getRandomInt(20, 100);
Module._set_shape_selrect(x1, y1, x1 + width, y1 + height);
const color = getRandomColor();
const argb = hexToU32ARGB(color, getRandomFloat(0.1, 1.0));
addShapeSolidFill(argb)
Module._add_shape_center_stroke(10, 0, 0, 0);
const argb2 = hexToU32ARGB(color, getRandomFloat(0.1, 1.0));
addShapeSolidStrokeFill(argb2);
// Add shadows
// Shadow 1: drop-shadow, #dedede opacity 0.33, blur 4, spread -2, offsetX 0, offsetY 2
Module._add_shape_shadow(hexToU32ARGB("#dedede", 0.33), 4, -2, 0, 2, 0, false);
// Shadow 2: drop-shadow, #dedede opacity 1, blur 12, spread -8, offsetX 0, offsetY 12
Module._add_shape_shadow(hexToU32ARGB("#dedede", 1), 12, -8, 0, 12, 0, false);
// Shadow 3: inner-shadow, #002046 opacity 0.12, blur 12, spread -8, offsetX 0, offsetY -4
Module._add_shape_shadow(hexToU32ARGB("#002046", 0.12), 12, -8, 0, -4, 1, false);
}
useShape("00000000-0000-0000-0000-000000000000");
setShapeChildren(children);
performance.mark('render:begin');
Module._set_view(1, 0, 0);
Module._render(Date.now());
performance.mark('render:end');
const { duration } = performance.measure('render', 'render:begin', 'render:end');
// alert(`render time: ${duration.toFixed(2)}ms`);
});
</script>
</body>
</html>

View File

@@ -14,7 +14,9 @@
[app.common.time :as ct]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.config :as cf]
[app.main.data.tinycolor :as tinycolor]
[app.main.data.tokenscript :as ts]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.warnings :as wtw]
[beicon.v2.core :as rx]
@@ -586,22 +588,25 @@
;; FIXME: this with effect with trigger all the time because
;; `config` will be always a different instance
(mf/with-effect [tokens config]
(let [cached (get @cache-atom tokens)]
(cond
(nil? tokens) nil
;; The tokens are already processing somewhere
(rx/observable? cached) (rx/sub! cached #(reset! tokens-state %))
;; Get the cached entry
(some? cached) (reset! tokens-state cached)
;; No cached entry, start processing
:else (let [resolved-tokens-s (if interactive?
(resolve-tokens-interactive tokens)
(resolve-tokens tokens))]
(swap! cache-atom assoc tokens resolved-tokens-s)
(rx/sub! resolved-tokens-s (fn [resolved-tokens]
(swap! cache-atom assoc tokens resolved-tokens)
(reset! tokens-state resolved-tokens)))))))
@tokens-state))
(when-not (contains? cf/flags :tokenscript)
(let [cached (get @cache-atom tokens)]
(cond
(nil? tokens) nil
;; The tokens are already processing somewhere
(rx/observable? cached) (rx/sub! cached #(reset! tokens-state %))
;; Get the cached entry
(some? cached) (reset! tokens-state cached)
;; No cached entry, start processing
:else (let [resolved-tokens-s (if interactive?
(resolve-tokens-interactive tokens)
(resolve-tokens tokens))]
(swap! cache-atom assoc tokens resolved-tokens-s)
(rx/sub! resolved-tokens-s (fn [resolved-tokens]
(swap! cache-atom assoc tokens resolved-tokens)
(reset! tokens-state resolved-tokens))))))))
(if (contains? cf/flags :tokenscript)
(ts/resolve-tokens tokens)
@tokens-state)))
(defn use-resolved-tokens*
"This hook will return the unresolved tokens as state until they are
@@ -612,16 +617,19 @@
[tokens & {:keys [interactive?]}]
(let [state* (mf/use-state tokens)]
(mf/with-effect [tokens interactive?]
(if (seq tokens)
(let [tpoint (ct/tpoint-ms)
tokens-s (if interactive?
(resolve-tokens-interactive tokens)
(resolve-tokens tokens))]
(when-not (contains? cf/flags :tokenscript)
(if (seq tokens)
(let [tpoint (ct/tpoint-ms)
tokens-s (if interactive?
(resolve-tokens-interactive tokens)
(resolve-tokens tokens))]
(-> tokens-s
(rx/sub! (fn [resolved-tokens]
(let [elapsed (tpoint)]
(l/dbg :hint "use-resolved-tokens*" :elapsed elapsed)
(reset! state* resolved-tokens))))))
(reset! state* tokens)))
@state*))
(-> tokens-s
(rx/sub! (fn [resolved-tokens]
(let [elapsed (tpoint)]
(l/dbg :hint "use-resolved-tokens*" :elapsed elapsed)
(reset! state* resolved-tokens))))))
(reset! state* tokens))))
(if (contains? cf/flags :tokenscript)
(ts/resolve-tokens tokens)
@state*)))

View File

@@ -0,0 +1,164 @@
(ns app.main.data.tokenscript
(:require
["@penpot/tokenscript" :refer [BaseSymbolType
ColorSymbol
ListSymbol NumberSymbol
NumberWithUnitSymbol
ProcessorError
processTokens
TokenSymbol
makeConfig]]
[app.common.logging :as l]
[app.common.time :as ct]
[app.main.data.workspace.tokens.errors :as wte]))
(l/set-level! :debug)
;; Config ----------------------------------------------------------------------
(def config (makeConfig))
;; Class predicates ------------------------------------------------------------
;; Predicates to get information about the tokenscript interpreter symbol type
;; Or to determine the error
(defn tokenscript-symbol? [v]
(instance? BaseSymbolType v))
(defn structured-token? [v]
(instance? TokenSymbol v))
(defn structured-record-token? [^js v]
(and (structured-token? v) (instance? js/Map (.-value v))))
(defn structured-array-token? [^js v]
(and (structured-token? v) (instance? js/Array (.-value v))))
(defn number-with-unit-symbol? [v]
(instance? NumberWithUnitSymbol v))
(defn number-symbol? [v]
(instance? NumberSymbol v))
(defn list-symbol? [v]
(instance? ListSymbol v))
(defn color-symbol? [v]
(instance? ColorSymbol v))
(defn processor-error? [err]
(instance? ProcessorError err))
;; Conversion Tools ------------------------------------------------------------
;; Helpers to convert tokenscript symbols to penpot accepted formats
(defn color-symbol->hex-string [^js v]
(when (color-symbol? v)
(.toString (.to v "hex"))))
(defn color-alpha [^js v]
(if (.isHex v)
1
(or (.getAttribute v "alpha") 1)))
(defn color-symbol->penpot-color [^js v]
{:color (color-symbol->hex-string v)
:opacity (color-alpha v)})
(defn rem-number-with-unit? [v]
(and (number-with-unit-symbol? v)
(= (.-unit v) "rem")))
(defn rem->px [^js v]
(* (.-value v) 16))
(declare tokenscript-symbols->penpot-unit)
(defn structured-token->penpot-map
"Converts structured token (record or array) to penpot map format.
Structured tokens are non-primitive token types like `typography` or `box-shadow`."
[^js token-symbol]
(if (instance? js/Array (.-value token-symbol))
(mapv structured-token->penpot-map (.-value token-symbol))
(let [entries (es6-iterator-seq (.entries (.-value token-symbol)))]
(into {} (map (fn [[k v :as V]]
[(keyword k) (tokenscript-symbols->penpot-unit v)])
entries)))))
(defn tokenscript-symbols->penpot-unit [^js v]
(cond
(structured-token? v) (structured-token->penpot-map v)
(list-symbol? v) (tokenscript-symbols->penpot-unit (.nth 1 v))
(color-symbol? v) (.-value (.to v "hex"))
(rem-number-with-unit? v) (rem->px v)
:else (.-value v)))
;; Processors ------------------------------------------------------------------
;; The processor resolves tokens
;; resolved/error tokens get put back into a clojure structure directly during build time
;; For updating tokens we use the `TokenResolver` crud methods from the processing result
;; The `TokenResolver` has detailed information for each tokens dependency graph
(defn create-token-builder
"Collects resolved tokens during build time into a clojure structure.
Returns Tokenscript Symbols in `:resolved-value` key."
[tokens]
(let [output (volatile! tokens)
;; When a token is resolved (No parsing / reference errors) we assing `:resolved-value` for the original token
on-resolve
(fn [^js/String token-name ^js/Symbol resolved-symbol]
(vswap! output assoc-in [token-name :resolved-value] resolved-symbol))
;; When a token contains any errors we assing `:errors` for the original token
on-error
(fn [^js/String token-name ^js/Error _error ^js/String _original-value]
(let [value (get tokens token-name)
default-error [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]]
(vswap! output assoc-in [token-name :errors] default-error)))
;; Extract the atom value
get-result
(fn [] @output)]
#js {:onResolve on-resolve
:onError on-error
:getResult get-result}))
(defn clj->token->tokenscript-token
"Convert penpot token into a format that tokenscript can handle."
[{:keys [type value]}]
#js {"$type" (name type)
"$value" (clj->js value)})
(defn clj-tokens->tokenscript-tokens
"Convert penpot map of tokens into tokenscript map structure.
tokenscript accepts a map of [token-name {\"$type\": string, \"$value\": any}]"
[tokens]
(let [token-map (js/Map.)]
(doseq [[k token] tokens]
(.set token-map k (clj->token->tokenscript-token token)))
token-map))
(defn process-tokens
"Builds tokens using `tokenscript`."
[tokens]
(let [input (clj-tokens->tokenscript-tokens tokens)
result (processTokens input #js {:config config
:builder (create-token-builder tokens)})]
result))
(defn update-token
[tokens token]
(let [result (process-tokens tokens)
resolver (.-resolver result)]
(.updateToken resolver #js {:tokenPath (:name token)
:tokenData (clj->token->tokenscript-token token)})))
;; Main ------------------------------------------------------------------------
(defn resolve-tokens [tokens]
(let [tpoint (ct/tpoint-ms)
result (process-tokens tokens)
elapsed (tpoint)]
(l/dbg :hint "tokenscript/resolve-tokens" :elapsed elapsed)
(.-output result)))

View File

@@ -1410,6 +1410,7 @@
(dm/export dwt/start-move-selected)
(dm/export dwt/move-selected)
(dm/export dwt/update-position)
(dm/export dwt/update-positions)
(dm/export dwt/flip-horizontal-selected)
(dm/export dwt/flip-vertical-selected)
(dm/export dwly/set-opacity)

View File

@@ -18,11 +18,13 @@
[app.common.types.tokens-lib :as ctob]
[app.common.types.typography :as cty]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.notifications :as ntf]
[app.main.data.style-dictionary :as sd]
[app.main.data.tinycolor :as tinycolor]
[app.main.data.tokenscript :as ts]
[app.main.data.workspace :as udw]
[app.main.data.workspace.colors :as wdc]
[app.main.data.workspace.shape-layout :as dwsl]
@@ -627,7 +629,9 @@
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (sd/resolve-tokens tokens)
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
@@ -645,6 +649,9 @@
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cft/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cft/attributes-map attributes token)
type (:type token)]
(rx/concat
@@ -699,17 +706,18 @@
"Removes `attributes` that match `token` for `shape-ids`.
Doesn't update shape attributes."
[{:keys [attributes token shape-ids] :as _props}]
[{:keys [attributes token-name shape-ids] :as _props}]
(ptk/reify ::unapply-token
ptk/WatchEvent
(watch [_ _ _]
(rx/of
(let [remove-token #(when % (cft/remove-attributes-for-token attributes token %))]
(let [remove-token #(when % (cft/remove-attributes-for-token attributes token-name %))]
(dwsh/update-shapes
shape-ids
(fn [shape]
(update shape :applied-tokens remove-token))))))))
(defn toggle-token
[{:keys [token attrs shape-ids expand-with-children]}]
(ptk/reify ::on-toggle-token
@@ -739,7 +747,7 @@
(if unapply-tokens?
(rx/of
(unapply-token {:attributes (or attrs all-attributes attributes)
:token token
:token-name (:name token)
:shape-ids shape-ids}))
(rx/of
(cond

View File

@@ -7,7 +7,9 @@
(ns app.main.data.workspace.tokens.color
(:require
[app.common.files.tokens :as cft]
[app.main.data.tinycolor :as tinycolor]))
[app.config :as cf]
[app.main.data.tinycolor :as tinycolor]
[app.main.data.tokenscript :as ts]))
(defn color-bullet-color [token-color-value]
(when-let [tc (tinycolor/valid-color token-color-value)]
@@ -17,5 +19,8 @@
(tinycolor/->hex-string tc))))
(defn resolved-token-bullet-color [{:keys [resolved-value] :as token}]
(when (and resolved-value (cft/color-token? token))
(color-bullet-color resolved-value)))
(if (contains? cf/flags :tokenscript)
(when (and resolved-value (ts/color-symbol? resolved-value))
(ts/color-symbol->penpot-color resolved-value))
(when (and resolved-value (cft/color-token? token))
(color-bullet-color resolved-value))))

View File

@@ -1,5 +1,6 @@
(ns app.main.data.workspace.tokens.format
(:require
[app.main.data.tokenscript :as ts]
[cuerdas.core :as str]))
(def category-dictionary
@@ -23,14 +24,52 @@
:color "Color"
:inset "Inner Shadow"})
(declare format-token-value)
(defn- format-map-entries
"Formats a sequence of [k v] entries into a formatted string."
[entries]
(->> entries
(map (fn [[k v]]
(str "- " (category-dictionary (keyword k)) ": " (format-token-value v))))
(str/join "\n")
(str "\n")))
(defn- format-structured-token
"Formats tokenscript Token"
[token-symbol]
(->> (.-value token-symbol)
(.entries)
(es6-iterator-seq)
(format-map-entries)))
(defn format-tokenscript-symbol
[^js tokenscript-symbol]
(cond
(ts/rem-number-with-unit? tokenscript-symbol)
(str (ts/rem->px tokenscript-symbol) "px")
(ts/color-symbol? tokenscript-symbol)
(ts/color-symbol->hex-string tokenscript-symbol)
(ts/structured-record-token? tokenscript-symbol)
(format-structured-token tokenscript-symbol)
(ts/structured-array-token? tokenscript-symbol)
(str/join "\n" (map format-tokenscript-symbol (.-value tokenscript-symbol)))
:else
(.toString tokenscript-symbol)))
(defn format-token-value
"Converts token value of any shape to a string."
[token-value]
(cond
(ts/tokenscript-symbol? token-value)
(format-tokenscript-symbol token-value)
(map? token-value)
(->> (map (fn [[k v]] (str "- " (category-dictionary k) ": " (format-token-value v))) token-value)
(str/join "\n")
(str "\n"))
(format-map-entries token-value)
(and (sequential? token-value) (every? map? token-value))
(str/join "\n" (map format-token-value token-value))

View File

@@ -62,6 +62,52 @@
(watch [_ _ _]
(rx/of (dwsh/update-shapes [id] #(merge % attrs)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Toggle tree nodes
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- remove-paths-recursively
[path paths]
(->> paths
(remove #(str/starts-with? % (str path)))
vec))
(defn add-path
[path paths]
(let [split-path (cpn/split-path path :separator ".")
partial-paths (->> split-path
(reduce
(fn [acc segment]
(let [new-acc (if (empty? acc)
segment
(str (last acc) "." segment))]
(conj acc new-acc)))
[]))]
(->> paths
(into partial-paths)
distinct
vec)))
(defn clear-tokens-paths
[]
(ptk/reify ::clear-tokens-paths
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-tokens :unfolded-token-paths] []))))
(defn toggle-token-path
[path]
(ptk/reify ::toggle-token-path
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-tokens :unfolded-token-paths]
(fn [paths]
(let [paths (or paths [])]
(if (some #(= % path) paths)
(remove-paths-recursively path paths)
(add-path path paths))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKENS Actions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -245,8 +291,9 @@
(pcb/with-library-data data)
(clt/generate-toggle-token-set tlib name))]
(rx/of (dch/commit-changes changes)
(dwtp/propagate-workspace-tokens))))))
(rx/of
(dch/commit-changes changes)
(dwtp/propagate-workspace-tokens))))))
(defn toggle-token-set-group
[group-path]
@@ -257,6 +304,7 @@
changes (-> (pcb/empty-changes)
(pcb/with-library-data data)
(clt/generate-toggle-token-set-group (get-tokens-lib state) group-path))]
(rx/of
(dch/commit-changes changes)
(dwtp/propagate-workspace-tokens))))))
@@ -486,35 +534,7 @@
;; TOKEN UI OPS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn clean-tokens-paths
[]
(ptk/reify ::clean-tokens-paths
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-tokens :unfolded-token-paths] []))))
(defn toggle-token-path
[path]
(ptk/reify ::toggle-token-path
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-tokens :unfolded-token-paths]
(fn [paths]
(let [paths (or paths [])]
(if (some #(= % path) paths)
(vec (remove #(or (= % path)
(str/starts-with? % (str path ".")))
paths))
(let [split-path (cpn/split-path path :separator ".")
partial-paths (reduce
(fn [acc segment]
(let [new-acc (if (empty? acc)
segment
(str (last acc) "." segment))]
(conj acc new-acc)))
[]
split-path)]
(into paths partial-paths)))))))))
(defn assign-token-context-menu
[{:keys [position] :as params}]

View File

@@ -1050,6 +1050,20 @@
:ignore-touched (:ignore-touched options)
:ignore-snap-pixel true}))))))))
(defn update-positions
"Move multiple shapes to a new position."
([ids position] (update-positions ids position nil))
([ids position options]
(assert (every? uuid? ids)
"expected valid coll of uuids")
(assert (map? position) "expected a valid map for `position`")
(ptk/reify ::update-positions
ptk/WatchEvent
(watch [_ _ _]
(->> ids
(map (fn [id] (update-position id position options)))
(rx/from))))))
(defn position-shapes
[shapes]
(ptk/reify ::position-shapes

View File

@@ -9,15 +9,16 @@
(:require
[app.common.exceptions :as ex]
[app.common.pprint :as pp]
[app.common.schema :as sm]
[app.config :as cf]
[app.main.data.auth :as da]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.workspace :as-alias dw]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.worker]
[app.util.globals :as glob]
[app.util.globals :as g]
[app.util.i18n :refer [tr]]
[app.util.timers :as ts]
[cuerdas.core :as str]
@@ -32,44 +33,6 @@
;; Will contain last uncaught exception
(def last-exception nil)
(defn- print-data!
[data]
(-> data
(dissoc ::sm/explain)
(dissoc :explain)
(dissoc ::trace)
(dissoc ::instance)
(pp/pprint {:width 70})))
(defn- print-explain!
[data]
(when-let [{:keys [errors] :as explain} (::sm/explain data)]
(let [errors (mapv #(update % :schema sm/form) errors)]
(pp/pprint errors {:width 100 :level 15 :length 20})))
(when-let [explain (:explain data)]
(js/console.log explain)))
(defn- print-trace!
[data]
(some-> data ::trace js/console.log))
(defn- print-group!
[message f]
(try
(js/console.group message)
(f)
(catch :default _ nil)
(finally
(js/console.groupEnd message))))
(defn print-cause!
[message cause]
(print-group! message (fn []
(print-data! cause)
(print-explain! cause)
(print-trace! cause))))
(defn exception->error-data
[cause]
(let [data (ex-data cause)]
@@ -78,18 +41,6 @@
(assoc ::instance cause)
(assoc ::trace (.-stack cause)))))
(defn print-error!
[cause]
(cond
(map? cause)
(print-cause! (:hint cause "Unexpected Error") cause)
(ex/error? cause)
(print-cause! (ex-message cause) (ex-data cause))
:else
(print-cause! (ex-message cause) (exception->error-data cause))))
(defn on-error
"A general purpose error handler."
[error]
@@ -104,13 +55,66 @@
;; Set the main potok error handler
(reset! st/on-error on-error)
(defn generate-report
[cause]
(try
(let [team-id (:current-team-id @st/state)
file-id (:current-file-id @st/state)
profile-id (:profile-id @st/state)
data (ex-data cause)]
(with-out-str
(println "Context:")
(println "--------------------")
(println "Hint: " (or (:hint data) (ex-message cause) "--"))
(println "Prof ID: " (str (or profile-id "--")))
(println "Team ID: " (str (or team-id "--")))
(when-let [file-id (or (:file-id data) file-id)]
(println "File ID: " (str file-id)))
(println "Version: " (:full cf/version))
(println "URI: " (str cf/public-uri))
(println "HREF: " (rt/get-current-href))
(println)
(println
(ex/format-throwable cause))
(println)
(println "Last events:")
(println "--------------------")
(pp/pprint @st/last-events {:length 200})
(println)))
(catch :default cause
(.error js/console "error on generating report" cause)
nil)))
(defn- show-not-blocking-error
"Show a non user blocking error notification"
[cause]
(let [data (ex-data cause)
hint (or (some-> (:hint data) ex/first-line)
(ex-message cause))]
(st/emit!
(ev/event {::ev/name "unhandled-exception"
:hint hint
:href (rt/get-current-href)
:type (get data :type :unknown)
:report (generate-report cause)})
(ntf/show {:content (tr "errors.unexpected-exception" hint)
:type :toast
:level :error
:timeout 3000}))))
(defmethod ptk/handle-error :default
[error]
(st/async-emit! (rt/assign-exception error))
(print-group! "Unhandled Error"
(fn []
(print-trace! error)
(print-data! error))))
(if (and (string? (:hint error))
(str/starts-with? (:hint error) "Assert failed:"))
(ptk/handle-error (assoc error :type :assertion))
(when-let [cause (::instance error)]
(ex/print-throwable cause :prefix "Unexpected Error")
(show-not-blocking-error cause))))
;; We receive a explicit authentication error; If the uri is for
;; workspace, dashboard, viewer or settings, then assign the exception
@@ -142,10 +146,10 @@
(defmethod ptk/handle-error :validation
[{:keys [code] :as error}]
(print-group! "Validation Error"
(fn []
(print-data! error)
(print-explain! error)))
(when-let [instance (get error ::instance)]
(ex/print-throwable instance :prefix "Validation Error"))
(cond
(= code :invalid-paste-data)
(let [message (tr "errors.paste-data-validation")]
@@ -193,23 +197,14 @@
:else
(st/async-emit! (rt/assign-exception error))))
;; This is a pure frontend error that can be caused by an active
;; assertion (assertion that is preserved on production builds). From
;; the user perspective this should be treated as internal error.
(defmethod ptk/handle-error :assertion
[error]
(ts/schedule
#(st/emit! (ntf/show {:content (tr "errors.internal-assertion-error")
:type :toast
:level :error
:timeout 3000})))
(print-group! "Internal Assertion Error"
(fn []
(print-trace! error)
(print-data! error)
(print-explain! error))))
(when-let [cause (::instance error)]
(show-not-blocking-error cause)
(ex/print-throwable cause :prefix "Assertion Error")))
;; ;; All the errors that happens on worker are handled here.
(defmethod ptk/handle-error :worker-error
@@ -221,9 +216,8 @@
:level :error
:timeout 3000})))
(print-group! "Internal Worker Error"
(fn []
(print-data! error))))
(some-> (::instance error)
(ex/print-throwable :prefix "Web Worker Error")))
;; Error on parsing an SVG
(defmethod ptk/handle-error :svg-parser
@@ -252,11 +246,9 @@
(defmethod ptk/handle-error ::exceptional-state
[error]
(when-let [cause (::instance error)]
(js/console.log (.-stack cause)))
(ts/schedule
#(st/emit! (rt/assign-exception error))))
(when-let [instance (get error ::instance)]
(ex/print-throwable instance :prefix "Exceptional State"))
(ts/schedule #(st/emit! (rt/assign-exception error))))
(defn- redirect-to-dashboard
[]
@@ -264,7 +256,7 @@
project-id (:current-project-id @st/state)]
(if (and project-id team-id)
(st/emit! (rt/nav :dashboard-files {:team-id team-id :project-id project-id}))
(set! (.-href glob/location) ""))))
(set! (.-href g/location) ""))))
(defmethod ptk/handle-error :restriction
[{:keys [code] :as error}]
@@ -312,9 +304,10 @@
:text (tr "errors.deprecated.contact.text")
:after (tr "errors.deprecated.contact.after")
:on-click #(st/emit! (rt/nav :settings-feedback))}}))
:else
(print-cause! "Restriction Error" error)))
(when-let [cause (::instance error)]
(ex/print-throwable cause :prefix "Restriction Error")
(show-not-blocking-error cause))))
;; This happens when the backed server fails to process the
;; request. This can be caused by an internal assertion or any other
@@ -322,24 +315,9 @@
(defmethod ptk/handle-error :server-error
[error]
(st/async-emit! (rt/assign-exception error))
(print-group! "Server Error"
(fn []
(print-data! (dissoc error :data))
(when-let [werror (:data error)]
(cond
(= :assertion (:type werror))
(print-group! "Assertion Error"
(fn []
(print-data! werror)
(print-explain! werror)))
:else
(print-group! "Unexpected"
(fn []
(print-data! werror)
(print-explain! werror))))))))
(when-let [instance (get error ::instance)]
(ex/print-throwable instance :prefix "Server Error"))
(st/async-emit! (rt/assign-exception error)))
(defonce uncaught-error-handler
(letfn [(is-ignorable-exception? [cause]
@@ -354,28 +332,19 @@
(when-let [cause (unchecked-get event "error")]
(set! last-exception cause)
(when-not (is-ignorable-exception? cause)
(ex/print-throwable cause :prefix "uncaught exception")
(st/async-emit!
(ntf/show {:content (tr "errors.unexpected-exception" (ex-message cause))
:type :toast
:level :error
:timeout 3000})))))
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/schedule #(show-not-blocking-error cause)))))
(on-unhandled-rejection [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "reason")]
(set! last-exception cause)
(ex/print-throwable cause :prefix "uncaught rejection")
(st/async-emit!
(ntf/show {:content (tr "errors.unexpected-exception" (ex-message cause))
:type :toast
:level :error
:timeout 3000}))))]
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/schedule #(show-not-blocking-error cause))))]
(.addEventListener glob/window "error" on-unhandled-error)
(.addEventListener glob/window "unhandledrejection" on-unhandled-rejection)
(.addEventListener g/window "error" on-unhandled-error)
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection)
(fn []
(.removeEventListener glob/window "error" on-unhandled-error)
(.removeEventListener glob/window "unhandledrejection" on-unhandled-rejection))))
(.removeEventListener g/window "error" on-unhandled-error)
(.removeEventListener g/window "unhandledrejection" on-unhandled-rejection))))

View File

@@ -9,12 +9,12 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as log]
[app.main.data.dashboard :as dd]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.errors :as errors]
[app.main.store :as st]
[app.main.ui.components.file-uploader :refer [file-uploader*]]
[app.main.ui.ds.product.loader :refer [loader*]]
@@ -360,7 +360,7 @@
on-error
(fn [cause]
(reset! status* :error)
(errors/print-error! cause)
(ex/print-throwable cause)
(rx/of (modal/hide)
(ntf/error (tr "dashboard.libraries-and-templates.import-error"))))

View File

@@ -564,18 +564,17 @@
(mf/use-fn
(mf/deps on-detach tokens disabled token-applied)
(fn [event]
(let [token (get-token-op tokens token-applied)]
(when-not disabled
(dom/prevent-default event)
(dom/stop-propagation event)
(reset! token-applied* nil)
(reset! selected-id* nil)
(reset! focused-id* nil)
(when on-detach
(on-detach token))
(ts/schedule-on-idle
(fn []
(dom/focus! (mf/ref-val ref))))))))
(when-not disabled
(dom/prevent-default event)
(dom/stop-propagation event)
(reset! token-applied* nil)
(reset! selected-id* nil)
(reset! focused-id* nil)
(when on-detach
(on-detach token-applied))
(ts/schedule-on-idle
(fn []
(dom/focus! (mf/ref-val ref)))))))
on-token-key-down
(mf/use-fn

View File

@@ -21,7 +21,7 @@
(def ^:private schema:properties-row
[:map
[:term :string]
[:detail :string]
[:detail {:optional true} [:maybe :string]]
[:property {:optional true} :string] ;; CSS valid property
[:token {:optional true} :any] ;; resolved token object
[:copiable {:optional true} :boolean]])

View File

@@ -36,7 +36,6 @@
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
;; FIXME: this is a workaround until we export this class on beicon library
@@ -447,25 +446,24 @@
(rx/of default)
(rx/throw cause)))))))
(mf/defc exception-section*
{::mf/private true}
[{:keys [data route] :as props}]
(let [type (get data :type)
report (mf/with-memo [data]
(generate-report data))
(some-> data ::errors/instance errors/generate-report))
props (mf/spread-props props {:report report})]
(mf/with-effect [data route report]
(let [params (:query-params route)
params (u/map->query-string params)]
(st/emit! (ptk/data-event ::ev/event
{::ev/name "exception-page"
:type (get data :type :unknown)
:hint (get data :hint)
:path (get route :path)
:report report
:params params}))))
(st/emit! (ev/event {::ev/name "exception-page"
:type (get data :type :unknown)
:href (rt/get-current-href)
:hint (get data :hint)
:path (get route :path)
:report report
:params params}))))
(case type
:not-found

View File

@@ -78,13 +78,15 @@
(fn []
(close-modals)
;; FIXME: move set-mode to uri?
(st/emit! (dw/set-options-mode :design)
(st/emit! :interrupt
(dw/set-options-mode :design)
(dcm/go-to-dashboard-recent))))
nav-to-project
(mf/use-fn
(mf/deps project-id)
#(st/emit! (dcm/go-to-dashboard-files ::rt/new-window true :project-id project-id)))]
#(st/emit! :interrupt
(dcm/go-to-dashboard-files ::rt/new-window true :project-id project-id)))]
(mf/with-effect [editing?]
(when ^boolean editing?

View File

@@ -401,7 +401,8 @@
(dm/fmt "scale(%)" maybe-zoom))}))]
[:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
:transform (dm/str transform)}
:transform (dm/str transform)
:data-testid "text-editor"}
[:defs
[:clipPath {:id clip-id}
[:rect {:x x :y y :width width :height height}]]]

View File

@@ -9,10 +9,15 @@
(:require
[app.common.data.macros :as dm]
[app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [right-sidebar-default-width right-sidebar-default-max-width left-sidebar-default-max-width left-sidebar-default-width]]
[app.config :as cf]
[app.main.constants :refer [left-sidebar-default-max-width
left-sidebar-default-width
right-sidebar-default-max-width
right-sidebar-default-width]]
[app.main.data.common :as dcm]
[app.main.data.event :as ev]
[app.main.data.style-dictionary :as sd]
[app.main.data.tokenscript :as ts]
[app.main.data.workspace :as dw]
[app.main.features :as features]
[app.main.refs :as refs]
@@ -369,6 +374,12 @@
(ctob/get-tokens-in-active-sets tokens-lib)
{}))
tokenscript? (contains? cf/flags :tokenscript)
tokenscript-resolved-active-tokens
(mf/with-memo [tokens-lib tokenscript?]
(when tokenscript? (ts/resolve-tokens active-tokens)))
resolved-active-tokens
(sd/use-resolved-tokens* active-tokens)]
@@ -380,7 +391,9 @@
:page-id page-id
:tokens-lib tokens-lib
:active-tokens active-tokens
:resolved-active-tokens resolved-active-tokens}])
:resolved-active-tokens (if (contains? cf/flags :tokenscript)
tokenscript-resolved-active-tokens
resolved-active-tokens)}])
[:> right-sidebar* {:section section
:selected selected
:drawing-tool drawing-tool

View File

@@ -119,7 +119,8 @@
[:button {:class (stl/css-case
:toggle-content true
:inverse expanded?)
:aria-label "Toggle layer"
:data-testid "toggle-content"
:aria-expanded expanded?
:on-click on-toggle-collapse}
deprecated-icon/arrow])

View File

@@ -108,6 +108,7 @@
:on-blur accept-edit
:on-key-down on-key-down
:auto-focus true
:id (dm/str "layer-name-" shape-id)
:default-value (d/nilv default-value "")}]
[:*
[:span
@@ -118,6 +119,7 @@
:hidden is-hidden
:type-comp type-comp
:type-frame type-frame)
:id (dm/str "layer-name-" shape-id)
:style {"--depth" depth "--parent-size" parent-size}
:ref ref
:on-double-click start-edit}

View File

@@ -60,8 +60,8 @@
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
(fn [token-name attr]
(st/emit! (dwta/unapply-token {:token-name token-name
:attributes #{attr}
:shape-ids ids}))))

View File

@@ -152,9 +152,9 @@
on-detach-token
(mf/use-fn
(mf/deps token-colors groups)
(fn [token]
(fn [token-name]
(let [prev-colors (mf/ref-val prev-colors-ref)
token-color (some #(when (= (:token-name %) (:name token)) %) token-colors)
token-color (some #(when (= (:token-name %) token-name) %) token-colors)
[color-operations _] (retrieve-color-operations groups token-color prev-colors)]
(doseq [op color-operations]
@@ -166,8 +166,8 @@
(d/without-nils))]
(mf/set-ref-val! prev-colors-ref
(conj prev-colors color))
(st/emit! (dwta/unapply-token {:attributes attr
:token token
(st/emit! (dwta/unapply-token {:token-name token-name
:attributes attr
:shape-ids [(:shape-id op)]})))))))
select-only

View File

@@ -74,7 +74,6 @@
render-wasm? (feat/use-feature "render-wasm/v1")
^boolean
multiple? (= :multiple fills)
@@ -183,9 +182,9 @@
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token]
(st/emit! (dwta/unapply-token {:attributes #{:fill}
:token token
(fn [token-name]
(st/emit! (dwta/unapply-token {:token-name token-name
:attributes #{:fill}
:shape-ids ids}))))]
(mf/with-layout-effect [hide-on-export]
@@ -215,7 +214,8 @@
(when open?
[:div {:class (stl/css :fill-content)}
(cond
(= :multiple fills)
(or (= :multiple fills)
(= :multiple fill-token-applied))
[:div {:class (stl/css :fill-multiple)}
[:div {:class (stl/css :fill-multiple-label)}
(tr "settings.multiple")]

View File

@@ -72,8 +72,8 @@
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
(fn [token-name attr]
(st/emit! (dwta/unapply-token {:token-name token-name
:attributes #{attr}
:shape-ids ids}))))
@@ -229,13 +229,13 @@
:property (tr "workspace.options.opacity")
:applied-token (get applied-tokens :opacity)
:placeholder (if (or (= :multiple (get applied-tokens :opacity))
(= :multiple (or (get values name) 1)))
(= :multiple (or (get values :opacity) 1)))
(tr "settings.multiple")
"--")
:align :right
:class (stl/css :numeric-input-wrapper)
:value (* 100
(or (get values name) 1))}]
(or (get values :opacity) 1))}]
[:div {:class (stl/css :input)
:title (tr "workspace.options.opacity")}
@@ -248,7 +248,6 @@
:max 100
:className (stl/css :numeric-input)}]])
[:div {:class (stl/css :actions)}
(cond
(or (= :multiple hidden?) (not hidden?))

View File

@@ -339,8 +339,8 @@
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
(fn [token-name attr]
(st/emit! (dwta/unapply-token {:token-name token-name
:attributes #{attr}
:shape-ids ids}))))
@@ -475,7 +475,7 @@
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
(st/emit! (dwta/unapply-token {:token-name token
:attributes #{attr}
:shape-ids ids}))))
@@ -722,7 +722,7 @@
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
(st/emit! (dwta/unapply-token {:token-name token
:attributes #{attr}
:shape-ids ids}))))

View File

@@ -97,8 +97,8 @@
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
(fn [token-name attr]
(st/emit! (dwta/unapply-token {:token-name token-name
:attributes #{attr}
:shape-ids ids}))))
@@ -220,8 +220,8 @@
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
(fn [token-name attr]
(st/emit! (dwta/unapply-token {:token-name token-name
:attributes #{attr}
:shape-ids ids}))))
@@ -550,8 +550,8 @@
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
(fn [token-name attr]
(st/emit! (dwta/unapply-token {:token-name token-name
:attributes #{attr}
:shape-ids ids}))))

View File

@@ -283,7 +283,7 @@
(if (or (string? value) (number? value))
(do
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(st/emit! (udw/update-position ids {attr value})))
(st/emit! (udw/update-positions ids {attr value})))
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(dwta/toggle-token {:token (first value)
:attrs #{attr}
@@ -319,8 +319,8 @@
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
(fn [token-name attr]
(st/emit! (dwta/unapply-token {:token-name token-name
:attributes #{attr}
:shape-ids ids}))))

View File

@@ -171,9 +171,9 @@
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attrs]
(st/emit! (dwta/unapply-token {:attributes attrs
:token token
(fn [token-name attrs]
(st/emit! (dwta/unapply-token {:token-name token-name
:attributes attrs
:shape-ids ids}))))]
[:section {:class (stl/css :stroke-section)

View File

@@ -85,14 +85,14 @@
(mf/use-fn
(mf/deps detach-token token applied-token-name)
(fn []
(let [token (or token applied-token-name)]
(detach-token token))))
(let [token-name (or (:name token) applied-token-name)]
(detach-token token-name))))
has-errors (some? (:errors token))
token-name (:name token)
resolved (:resolved-value token)
not-active (and (empty? active-tokens)
(nil? token))
not-active (or (empty? active-tokens)
(nil? token))
id (dm/str (:id token) "-name")
swatch-tooltip-content (cond
not-active
@@ -344,7 +344,6 @@
(mf/with-effect [color prev-color disable-picker]
(when (and (not disable-picker) (not= prev-color color))
(modal/update-props! :colorpicker {:data (parse-color color)})))
[:div {:class [class row-class]}
;; Drag handler
(when (some? on-reorder)

View File

@@ -94,10 +94,7 @@
;; This only checks for the currently explicitly selected set
;; id, it is ephimeral and can be nil
;; FIXME: this is a repeated deref for the same `:workspace-tokens` state
selected-token-set-id
(mf/deref refs/selected-token-set-id)
selected-token-set-id (mf/deref refs/selected-token-set-id)
;; If we have not selected any set explicitly we just
;; select the first one from the list of sets
@@ -112,6 +109,7 @@
tokens
(sd/use-resolved-tokens* tokens)
;; Group tokens by their type
tokens-by-type
(mf/with-memo [tokens selected-token-set-tokens]
(let [tokens (reduce-kv (fn [tokens k _]
@@ -129,9 +127,9 @@
;; Filter tokens by their path and return their ids
filter-tokens-by-path-ids
(mf/use-fn
(mf/deps tokens)
(mf/deps selected-token-set-tokens)
(fn [type path]
(->> tokens
(->> selected-token-set-tokens
(filter (fn [token]
(let [[_ token-value] token]
(and (= (:type token-value) type) (str/starts-with? (:name token-value) path)))))
@@ -139,12 +137,47 @@
(let [[_ token-value] token]
(:id token-value)))))))
remaining-tokens-of-type-in-set?
(mf/use-fn
(fn [selected-token-set-tokens tokens-in-path-ids]
(let [token-ids (set tokens-in-path-ids)
remaining-tokens (filter (fn [token]
(not (contains? token-ids (:id token))))
selected-token-set-tokens)
_ (prn "Remaining tokens:" remaining-tokens)]
(seq remaining-tokens))))
delete-token
(mf/with-memo [selected-token-set-tokens selected-token-set-id]
(fn [token]
(let [id (:id token)
type (:type token)
path (:name token)
tokens-by-type (ctob/group-by-type selected-token-set-tokens)
tokens-filtered-by-type (get tokens-by-type type)
tokens-in-path-ids (filter-tokens-by-path-ids type path)
remaining-tokens? (remaining-tokens-of-type-in-set? tokens-filtered-by-type tokens-in-path-ids)]
;; Delete the token
(st/emit! (dwtl/delete-token selected-token-set-id id))
;; Remove from unfolded tree path
(if remaining-tokens?
(st/emit! (dwtl/toggle-token-path (str (name type) "." path)))
(st/emit! (dwtl/toggle-token-path (name type)))))))
delete-node
(mf/with-memo [tokens selected-token-set-id]
(mf/with-memo [selected-token-set-tokens selected-token-set-id]
(fn [node type]
(let [path (:path node)
tokens-in-path-ids (filter-tokens-by-path-ids type path)]
(st/emit! (dwtl/bulk-delete-tokens selected-token-set-id tokens-in-path-ids)))))]
tokens-by-type (ctob/group-by-type selected-token-set-tokens)
tokens-filtered-by-type (get tokens-by-type type)
tokens-in-path-ids (filter-tokens-by-path-ids type path)
remaining-tokens? (remaining-tokens-of-type-in-set? tokens-filtered-by-type tokens-in-path-ids)]
;; Delete tokens in path
(st/emit! (dwtl/bulk-delete-tokens selected-token-set-id tokens-in-path-ids))
;; Remove from unfolded tree path
(if remaining-tokens?
(st/emit! (dwtl/toggle-token-path (str (name type) "." path)))
(st/emit! (dwtl/toggle-token-path (name type)))))))]
(mf/with-effect [tokens-lib selected-token-set-id]
(when (and tokens-lib
@@ -157,7 +190,7 @@
(st/emit! (dwtl/set-selected-token-set-id (ctob/get-id match)))))))
[:*
[:& token-context-menu]
[:& token-context-menu {:on-delete-token delete-token}]
[:> token-node-context-menu* {:on-delete-node delete-node}]
[:> selected-set-info* {:tokens-lib tokens-lib

View File

@@ -64,14 +64,17 @@
(let [selected? (selected-pred attribute)
props {:attributes #{attribute}
:token token
:shape-ids shape-ids}]
:shape-ids shape-ids}
unnaply-props {:token-name (:name token)
:attributes #{attribute}
:shape-ids shape-ids}]
{:title title
:hint hint
:selected? selected?
:action (fn []
(if selected?
(st/emit! (dwta/unapply-token props))
(st/emit! (dwta/unapply-token unnaply-props))
(st/emit! (dwta/apply-token (assoc props :on-update-shape on-update-shape-fn)))))}))
allowed-attributes)))
@@ -82,12 +85,15 @@
{:keys [all-selected? selected-pred shape-ids]} (attribute-actions token selected-shapes attributes)
all-action (let [props {:attributes attributes
:token token
:shape-ids shape-ids}]
:shape-ids shape-ids}
unnaply-props {:token-name (:name token)
:attributes attributes
:shape-ids shape-ids}]
{:title (tr "labels.all")
:selected? all-selected?
:hint hint
:action #(if all-selected?
(st/emit! (dwta/unapply-token props))
(st/emit! (dwta/unapply-token unnaply-props))
(st/emit! (dwta/apply-token (assoc props :on-update-shape (or on-update-shape-all on-update-shape)))))})
single-actions (map (fn [[attr title]]
(let [selected? (selected-pred attr)]
@@ -96,10 +102,13 @@
:action #(let [props {:attributes #{attr}
:token token
:shape-ids shape-ids}
unnaply-props {:token-name (:name token)
:attributes #{attr}
:shape-ids shape-ids}
event (cond
all-selected? (-> (assoc props :attributes-to-remove attributes)
(dwta/apply-token))
selected? (dwta/unapply-token props)
selected? (dwta/unapply-token unnaply-props)
:else (-> (assoc props :on-update-shape on-update-shape)
(dwta/apply-token)))]
(st/emit! event))}))
@@ -123,9 +132,12 @@
:action (fn []
(let [props {:attributes attrs
:token token
:shape-ids shape-ids}]
:shape-ids shape-ids}
unnaply-props {:token-name (:name token)
:attributes attrs
:shape-ids shape-ids}]
(if all-selected?
(st/emit! (dwta/unapply-token props))
(st/emit! (dwta/unapply-token unnaply-props))
(st/emit! (dwta/apply-token (assoc props :on-update-shape on-update-shape))))))}
{:title "Horizontal"
:selected? horizontal-selected?
@@ -165,10 +177,13 @@
:action #(let [props {:attributes #{attr}
:token token
:shape-ids shape-ids}
unnaply-props {:token-name (:name token)
:attributes #{attr}
:shape-ids shape-ids}
event (cond
all-selected? (-> (assoc props :attributes-to-remove attrs)
(dwta/apply-token))
selected? (dwta/unapply-token props)
selected? (dwta/unapply-token unnaply-props)
:else (-> (assoc props :on-update-shape on-update-shape)
(dwta/apply-token)))]
(st/emit! event))}))
@@ -316,8 +331,9 @@
(generic-attribute-actions #{:y} "Y" (assoc context-data :on-update-shape dwta/update-shape-position)))
(clean-separators)))}))
(defn default-actions [{:keys [token selected-token-set-id]}]
(let [{:keys [modal]} (dwta/get-token-properties token)]
(defn default-actions [{:keys [token selected-token-set-id on-delete-token]}]
(let [{:keys [modal]} (dwta/get-token-properties token)
on-duplicate-token #(st/emit! (dwtl/duplicate-token (:id token)))]
[{:title (tr "workspace.tokens.edit")
:no-selectable true
:action (fn [event]
@@ -333,12 +349,10 @@
:token token}))))}
{:title (tr "workspace.tokens.duplicate")
:no-selectable true
:action #(st/emit! (dwtl/duplicate-token (:id token)))}
:action on-duplicate-token}
{:title (tr "workspace.tokens.delete")
:no-selectable true
:action #(st/emit! (dwtl/delete-token
selected-token-set-id
(:id token)))}]))
:action #(on-delete-token token)}]))
(defn- allowed-shape-attributes [shapes]
(reduce into #{} (map #(ctt/shape-type->attributes (:type %) (:layout %)) shapes)))
@@ -464,7 +478,7 @@
:selected? selected?}])])))
(mf/defc token-context-menu-tree
[{:keys [width errors] :as mdata}]
[{:keys [width errors on-delete-token] :as mdata}]
(let [objects (mf/deref refs/workspace-page-objects)
selected (mf/deref refs/selected-shapes)
@@ -488,10 +502,11 @@
:errors errors
:selected-token-set-id selected-token-set-id
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout}]]))
:is-selected-inside-layout is-selected-inside-layout
:on-delete-token on-delete-token}]]))
(mf/defc token-context-menu
[]
[{:keys [on-delete-token]}]
(let [mdata (mf/deref tokens-menu-ref)
is-open? (boolean mdata)
width (mf/use-state 0)
@@ -538,5 +553,5 @@
:left (dm/str left "px")}
:on-context-menu prevent-default}
(when mdata
[:& token-context-menu-tree (assoc mdata :width @width)])]])
[:& token-context-menu-tree (assoc mdata :width @width :on-delete-token on-delete-token)])]])
(dom/get-body)))))

View File

@@ -10,7 +10,10 @@
[app.common.files.tokens :as cft]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.config :as cf]
[app.main.data.style-dictionary :as sd]
[app.main.data.tokenscript :as ts]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.format :as dwtf]
[app.main.ui.ds.controls.input :as ds]
[app.main.ui.forms :as fc]
@@ -139,6 +142,18 @@
;; -----------------------------------------------------------------------------
(defn- resolve-value-tokenscript
[tokens prev-token value]
(let [result (ts/update-token tokens (assoc prev-token :value value))
token-result (.-resolved result)]
(rx/of
(cond
(ts/processor-error? token-result) {:error (wte/error-with-value :error.style-dictionary/missing-reference (some->> (.-dependencyChain token-result)
(seq)
(rest)))}
(instance? js/Error token-result) {:error (wte/error-with-value :error.style-dictionary/invalid-token-value value)}
:else {:value token-result}))))
(defn- resolve-value
[tokens prev-token token-name value]
(let [valid-token-name?
@@ -216,7 +231,10 @@
(mf/with-effect [resolve-stream tokens token input-name token-name]
(let [subs (->> resolve-stream
(let [resolve-value (if (contains? cf/flags :tokenscript)
resolve-value-tokenscript
resolve-value)
subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/map (fn [result]

View File

@@ -25,7 +25,6 @@
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
[app.main.ui.workspace.tokens.management.forms.validators :refer [default-validate-token]]
@@ -143,10 +142,6 @@
(fm/use-form :schema schema
:initial initial)
warning-name-change?
(not= (get-in @form [:data :name])
(:name initial))
on-toggle-tab
(mf/use-fn
(mf/deps form)
@@ -276,12 +271,7 @@
:max-length max-input-length
:variant "comfortable"
:trim true
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
[:> context-notification*
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
:auto-focus true}]]
[:div {:class (stl/css :input-row)}
(case type

View File

@@ -13,6 +13,7 @@
[app.common.files.tokens :as cft]
[app.common.path-names :as cpn]
[app.common.types.token :as ctt]
[app.config :as cf]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.color :as dwtc]
[app.main.data.workspace.tokens.format :as dwtf]
@@ -177,6 +178,8 @@
[{:keys [on-click token on-context-menu selected-shapes is-selected-inside-layout active-theme-tokens]}]
(let [{:keys [name value errors type]} token
resolved-token (get active-theme-tokens (:name token))
has-selected? (pos? (count selected-shapes))
is-reference? (cft/is-reference? token)
contains-path? (str/includes? name ".")
@@ -209,8 +212,10 @@
is-viewer? (not can-edit?)
ref-not-in-active-set
(and is-reference?
(not (contains-reference-value? value active-theme-tokens)))
(if (contains? cf/flags :tokenscript)
(seq (:errors resolved-token))
(and is-reference?
(not (contains-reference-value? value active-theme-tokens))))
no-valid-value (seq errors)
@@ -220,9 +225,8 @@
color
(when (cft/color-token? token)
(let [theme-token (get active-theme-tokens name)]
(or (dwtc/resolved-token-bullet-color theme-token)
(dwtc/resolved-token-bullet-color token))))
(or (dwtc/resolved-token-bullet-color resolved-token)
(dwtc/resolved-token-bullet-color token)))
status-icon? (contains? token-types-with-status-icon type)

View File

@@ -16,6 +16,7 @@
[rumext.v2 :as mf]))
(defn- on-select-token-set-click [id]
(st/emit! (dwtl/clear-tokens-paths))
(st/emit! (dwtl/set-selected-token-set-id id)))
(defn- on-toggle-token-set-click [name]

View File

@@ -10,6 +10,7 @@
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.common.types.plugins :as ctp]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.main.repo :as rp]
[app.main.store :as st]
@@ -50,7 +51,13 @@
(contains? permissions "comment:write")
(conj "comment:read"))
origin (obj/get (js/URL. plugin-url) "origin")
plugin-url
(u/uri plugin-url)
origin
(-> plugin-url
(u/join ".")
(str))
prev-plugin
(->> (:data @registry)
@@ -59,12 +66,13 @@
(and (= name (:name plugin))
(= origin (:host plugin))))))
plugin-id (d/nilv (:plugin-id prev-plugin) (str (uuid/next)))
plugin-id
(d/nilv (:plugin-id prev-plugin) (str (uuid/next)))
manifest
(d/without-nils
{:plugin-id plugin-id
:url plugin-url
:url (str plugin-url)
:name name
:description desc
:host origin

View File

@@ -391,7 +391,7 @@
(group-by :code)
(clj->js))
(catch :default cause
(errors/print-error! cause))))))
(ex/print-throwable cause))))))
(defn ^:export validate-schema
[]
@@ -399,7 +399,7 @@
(let [file (dsh/lookup-file @st/state)]
(cfv/validate-file-schema! file))
(catch :default cause
(errors/print-error! cause))))
(ex/print-throwable cause))))
(defn ^:export repair
[reload?]
@@ -431,7 +431,7 @@
(when reload?
(dom/reload-current-window)))
(fn [cause]
(errors/print-error! cause)))))))))
(ex/print-throwable cause)))))))))
(defn ^:export fix-orphan-shapes
[]

View File

@@ -177,7 +177,7 @@
;; ==== Action
events [(dwta/unapply-token {:shape-ids [(cthi/id :frame1)]
:attributes #{:r1 :r2 :r3 :r4}
:token (toht/get-token file "test-token-1")})]
:token-name "test-token-1"})]
step2 (fn [_]
(let [events2 [(dwl/sync-file (:id file) (:id file))]]
@@ -289,7 +289,7 @@
;; ==== Action
events [(dwta/unapply-token {:shape-ids [(cthi/id :c-frame1)]
:attributes #{:r1 :r2 :r3 :r4}
:token (toht/get-token file "test-token-1")})
:token-name "test-token-1"})
(dwta/apply-token {:shape-ids [(cthi/id :frame1)]
:attributes #{:r1 :r2 :r3 :r4}
:token (toht/get-token file "test-token-3")

View File

@@ -0,0 +1,37 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns frontend-tests.logic.update-position-test
(:require
[app.common.geom.rect :as grc]
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.shapes :as cths]
[app.main.data.workspace :as dw]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]))
(t/deftest test-update-positions-multiple-ids
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-rect :rect1 :x 10 :y 20 :width 10 :height 10)
(ctho/add-rect :rect2 :x 30 :y 40 :width 10 :height 10))
store (ths/setup-store file)
rect1 (cths/get-shape file :rect1)
rect2 (cths/get-shape file :rect2)
ids [(:id rect1) (:id rect2)]
events [(dw/update-positions ids {:x 123.45})]]
(ths/run-store
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
rect1' (cths/get-shape file' :rect1)
rect2' (cths/get-shape file' :rect2)
x1 (-> rect1' :points grc/points->rect :x)
x2 (-> rect2' :points grc/points->rect :x)]
(t/is (= 123.45 x1))
(t/is (= 123.45 x2))))))))

View File

@@ -1467,7 +1467,7 @@ msgid "errors.generic"
msgstr "Something wrong has happened."
msgid "errors.unexpected-exception"
msgstr "Unexpected exception: %s"
msgstr "Unexpected error: %s"
#: src/app/main/errors.cljs:200
msgid "errors.internal-assertion-error"

View File

@@ -7,7 +7,7 @@ export DEVENV_PNAME="penpotdev";
export CURRENT_USER_ID=$(id -u);
export CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD);
export IMAGEMAGICK_VERSION=7.1.2-0
export IMAGEMAGICK_VERSION=7.1.2-13
# Safe directory to avoid ownership errors with Git
git config --global --add safe.directory /home/penpot/penpot || true
@@ -215,6 +215,23 @@ function build-frontend-bundle {
echo ">> bundle frontend end";
}
function build-mcp-bundle {
echo ">> bundle mcp start";
mkdir -p ./bundles
local version=$(print-current-version);
local bundle_dir="./bundles/mcp";
build "mcp";
rm -rf $bundle_dir;
mv ./mcp/dist $bundle_dir;
echo $version > $bundle_dir/version.txt;
put-license-file $bundle_dir;
echo ">> bundle mcp end";
}
function build-backend-bundle {
echo ">> bundle backend start";
@@ -309,6 +326,16 @@ function build-exporter-docker-image {
popd;
}
function build-mcp-docker-image {
rsync -avr --delete ./bundles/mcp/ ./docker/images/bundle-mcp/;
pushd ./docker/images;
docker build \
-t penpotapp/mcp:$CURRENT_BRANCH -t penpotapp/mcp:latest \
--build-arg BUNDLE_PATH="./bundle-mcp/" \
-f Dockerfile.mcp .;
popd;
}
function build-storybook-docker-image {
rsync -avr --delete ./bundles/storybook/ ./docker/images/bundle-storybook/;
pushd ./docker/images;
@@ -346,6 +373,7 @@ function usage {
echo "- build-frontend-docker-image Build frontend docker images."
echo "- build-backend-docker-image Build backend docker images."
echo "- build-exporter-docker-image Build exporter docker images."
echo "- build-mcp-docker-image Build exporter docker images."
echo "- build-storybook-docker-image Build storybook docker images."
echo ""
echo "- version Show penpot's version."
@@ -397,6 +425,7 @@ case $1 in
## production builds
build-bundle)
build-frontend-bundle;
build-mcp-bundle;
build-backend-bundle;
build-exporter-bundle;
build-storybook-bundle;
@@ -406,6 +435,10 @@ case $1 in
build-frontend-bundle;
;;
build-mcp-bundle)
build-mcp-bundle;
;;
build-backend-bundle)
build-backend-bundle;
;;
@@ -431,6 +464,7 @@ case $1 in
build-frontend-docker-image
build-backend-docker-image
build-exporter-docker-image
build-mcp-docker-image
build-storybook-docker-image
;;
@@ -445,7 +479,11 @@ case $1 in
build-exporter-docker-image)
build-exporter-docker-image
;;
build-mcp-docker-image)
build-mcp-docker-image
;;
build-storybook-docker-image)
build-storybook-docker-image
;;

11
mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.idea
node_modules
dist
*.bak
*.orig
temp
*.tsbuildinfo
# Log files
logs/
*.log

7
mcp/.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
*.md
*.json
python-scripts/
.serena/
# auto-generated files
mcp-server/data/api_types.yml

20
mcp/.prettierrc Normal file
View File

@@ -0,0 +1,20 @@
{
"tabWidth": 4,
"overrides": [
{
"files": "*.yml",
"options": {
"tabWidth": 2
}
}
],
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 120,
"endOfLine": "auto"
}

1
mcp/.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

View File

@@ -0,0 +1,25 @@
# Code Style and Conventions
## General Principles
- **Object-Oriented Design**: VERY IMPORTANT: Use idiomatic, object-oriented style with explicit abstractions
- **Strategy Pattern**: Prefer explicitly typed interfaces over bare functions for non-trivial functionality
- **Clean Architecture**: Tools implement a common interface for consistent registration and execution
## TypeScript Configuration
- **Strict Mode**: All strict TypeScript options enabled
- **Target**: ES2022
- **Module System**: CommonJS
- **Declaration Files**: Generated with source maps
## Naming Conventions
- **Classes**: PascalCase (e.g., `ExeceuteCodeTool`, `PenpotMcpServer`)
- **Interfaces**: PascalCase (e.g., `Tool`)
- **Methods**: camelCase (e.g., `execute`, `registerTools`)
- **Constants**: camelCase for readonly properties (e.g., `definition`)
- **Files**: PascalCase for classes (e.g., `ExecuteCodeTool.ts`)
## Documentation Style
- **JSDoc**: Use comprehensive JSDoc comments for classes, methods, and interfaces
- **Description Format**: Initial elliptical phrase that defines *what* it is, followed by details
- **Comment Style**: VERY IMPORTANT: Start with lowercase for comments of code blocks (unless lengthy explanation with multiple sentences)

View File

@@ -0,0 +1,91 @@
# Penpot MCP Project Overview - Updated
## Purpose
This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication.
## Tech Stack
- **Language**: TypeScript
- **Runtime**: Node.js
- **Framework**: MCP SDK (@modelcontextprotocol/sdk)
- **Build Tool**: TypeScript Compiler (tsc) + esbuild
- **Package Manager**: pnpm
- **WebSocket**: ws library for real-time communication
## Project Structure
```
penpot-mcp/
├── common/ # Shared type definitions
│ ├── src/
│ │ ├── index.ts # Exports for shared types
│ │ └── types.ts # PluginTaskResult, request/response interfaces
│ └── package.json # @penpot-mcp/common package
├── mcp-server/ # Main MCP server implementation
│ ├── src/
│ │ ├── index.ts # Main server entry point
│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation
│ │ ├── PluginTask.ts # Now supports result promises
│ │ ├── tasks/ # PluginTask implementations
│ │ └── tools/ # Tool implementations
│ └── package.json # Includes @penpot-mcp/common dependency
├── penpot-plugin/ # Penpot plugin with response capability
│ ├── src/
│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding
│ │ └── plugin.ts # Now sends task responses back to server
│ └── package.json # Includes @penpot-mcp/common dependency
└── prepare-api-docs # Python project for the generation of API docs
```
## Key Tasks
### Adding a new Tool
1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface.
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
2. Register the tool in `PenpotMcpServer`.
Look at `PrintTextTool` as an example.
Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`.
### Adding a new PluginTask
1. Implement the input data interface for the task in `common/src/types.ts`.
2. Implement the `PluginTask` class in `mcp-server/src/tasks/`.
3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`).
* In the success case, call `task.sendSuccess`.
* In the failure case, just throw an exception, which will be handled centrally!
* Look at `PrintTextTaskHandler` as an example.
4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list.
## Key Components
### Enhanced WebSocket Protocol
- **Request Format**: `{id: string, task: string, params: any}`
- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}`
- **Request/Response Correlation**: Using unique UUIDs for task tracking
- **Timeout Handling**: 30-second timeout with automatic cleanup
- **Type Safety**: Shared definitions via @penpot-mcp/common package
### Core Classes
- **PenpotMcpServer**: Enhanced with pending task tracking and response handling
- **PluginTask**: Now creates result promises that resolve when plugin responds
- **Tool implementations**: Now properly await task completion and report results
- **Plugin handlers**: Send structured responses back to server
### New Features
1. **Bidirectional Communication**: Plugin now responds with success/failure status
2. **Task Result Promises**: Every executePluginTask() sets and returns a promise
3. **Error Reporting**: Failed tasks properly report error messages to tools
4. **Shared Type Safety**: Common package ensures consistency across projects
5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit)
6. **Request Correlation**: Unique IDs match requests to responses
## Task Flow
```
LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API
↑ ↓
Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result
```

Some files were not shown because too many files have changed in this diff Show More