From 3cd073fc6a52b171042a4635346c99aa4e413b29 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 26 Oct 2026 11:04:06 +0100 Subject: [PATCH 01/65] :sparkles: Add auto-repair for integrity error files (#12524) * :sparkles: Add auto-repair for integrity error files * :sparkles: MR changes --- ...-change-validation-migration-subtleties.md | 1 + backend/src/app/main.clj | 6 + backend/src/app/rpc/commands/files_update.clj | 257 +++++++++-- backend/src/app/srepl/procs/file_repair.clj | 45 +- .../rpc_file_update_repair_test.clj | 433 ++++++++++++++++++ common/src/app/common/files/repair.cljc | 58 ++- .../test/common_tests/files/repair_test.cljc | 168 +++++++ .../main/data/workspace/notifications.cljs | 23 +- .../data/workspace_notifications_test.cljs | 50 ++ frontend/translations/en.po | 6 + frontend/translations/es.po | 6 + 11 files changed, 976 insertions(+), 77 deletions(-) create mode 100644 backend/test/backend_tests/rpc_file_update_repair_test.clj create mode 100644 frontend/test/frontend_tests/data/workspace_notifications_test.cljs diff --git a/.serena/memories/common/file-change-validation-migration-subtleties.md b/.serena/memories/common/file-change-validation-migration-subtleties.md index d013415730..7333faadc3 100644 --- a/.serena/memories/common/file-change-validation-migration-subtleties.md +++ b/.serena/memories/common/file-change-validation-migration-subtleties.md @@ -21,6 +21,7 @@ - Full referential/semantic validation currently runs only when file features contain `"components/v2"`. - Validation starts at root plus orphan shapes, then validates component records. `validate-file!` raises `:validation :referential-integrity` with collected details. - `repair-file` does not mutate data directly; it reduces validation errors into redo changes using `changes-builder`. Callers must apply or persist those changes. +- `repair-file-until-valid` stops on: no errors; unchanged `:data` after apply (backend page/`PointerMap` `=` is pointer identity); stable error fingerprint `(frequencies (map #(dissoc % :shape) errors))` vs previous pass (check fingerprint before `repair-file`/`process-changes`); or `max-iterations`. Each `repair-error` builds from the original `file-data`, so N `:set`-based errors on one shape take N passes. - `:missing-slot` fires only for a REAL swap: a copy sub-head whose `shape-ref` is no longer a child of the near main parent. A pure positional mismatch (ref still a sibling elsewhere) is a reorder — valid, realigned by the async component sync; do not "repair" it by assigning swap slots (a slot freezes the child out of normal sync). `fix-missing-swap-slots` (migration 0019) follows the same membership rule. ## Migrations diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index d0b457cc8c..bdb5802053 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -75,6 +75,12 @@ ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} + :update-file-repair + {::mdef/name "penpot_rpc_update_file_repair_total" + ::mdef/help "update-file auto-repair attempts by outcome (repaired or unrepaired)." + ::mdef/labels ["outcome"] + ::mdef/type :counter} + :rpc-main-timing {::mdef/name "penpot_rpc_main_timing" ::mdef/help "RPC command method call timing for main" diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 85a0a58b1f..5c47dbf61d 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -12,6 +12,7 @@ [app.common.features :as cfeat] [app.common.files.changes :as cpc] [app.common.files.migrations :as fmg] + [app.common.files.repair :as cfr] [app.common.files.validate :as val] [app.common.logging :as l] [app.common.schema :as sm] @@ -41,6 +42,7 @@ (declare ^:private get-lagged-changes) (declare ^:private send-notifications!) +(declare ^:private send-repair-notifications) (declare ^:private update-file) (declare ^:private update-file*) (declare ^:private process-changes-and-validate) @@ -124,6 +126,27 @@ (contains? token-change-types type) (contains? file-change-types type))) +;; Not a real editor session: file-change messages with this id are +;; delivered to every connected session, including the saver. +(def repair-session-id + #uuid "a11c0de5-0000-4000-8000-000000000001") + +(defn- referential-integrity-error? + [cause] + (= :referential-integrity (:code (ex-data cause)))) + +(defn- record-repair-outcome + [cfg outcome] + (when-let [metrics (::mtx/metrics cfg)] + (when (mtx/metrics? metrics) + (mtx/run! metrics {:id :update-file-repair + :labels [(name outcome)] + :inc 1})))) + +(defn- error-codes + [errors] + (into [] (keep :code) errors)) + ;; If features are specified from params and the final feature ;; set is different than the persisted one, update it on the ;; database. @@ -225,7 +248,9 @@ :soft-validate-affected-ms (get timings :soft-validate-affected-ms) :soft-validate-schema-ms (get timings :soft-validate-schema-ms) :validate-affected-ms (get timings :validate-affected-ms) - :validate-schema-ms (get timings :validate-schema-ms))))) + :validate-schema-ms (get timings :validate-schema-ms) + :repair-ms (get timings :repair-ms) + :reprocess-changes-ms (get timings :reprocess-changes-ms))))) (catch Throwable cause ;; NOTE: `with-defer` hooks only run on the success path ;; (`handle-response`), so timings collected before a hard @@ -241,7 +266,9 @@ :soft-validate-affected-ms (get timings :soft-validate-affected-ms) :soft-validate-schema-ms (get timings :soft-validate-schema-ms) :validate-affected-ms (get timings :validate-affected-ms) - :validate-schema-ms (get timings :validate-schema-ms))) + :validate-schema-ms (get timings :validate-schema-ms) + :repair-ms (get timings :repair-ms) + :reprocess-changes-ms (get timings :reprocess-changes-ms))) (throw cause)))))) (defn- update-file* @@ -275,27 +302,65 @@ changes skip-validate)) deleted-at - (ct/plus timestamp (ct/duration {:hours 1}))] + (ct/plus timestamp (ct/duration {:hours 1})) - (when-let [file (::snapshot file)] - (let [deleted-at (ct/plus timestamp (ldel/get-deletion-delay team)) - label (str "internal/snapshot/" revn)] + repair + (::repair file) - (fsnap/create! cfg file - {:label label + snapshot-deleted-at + (ct/plus timestamp (ldel/get-deletion-delay team)) + + change-created-at + (cond-> timestamp + repair + (ct/plus (ct/duration {:millis 1})))] + + (if repair + (let [snapshot-file (or (::snapshot file) + (fdata/realize cfg (:original repair)))] + (fsnap/create! cfg snapshot-file + {:label (str "internal/repair/" revn) :created-by "system" - :deleted-at deleted-at + :deleted-at snapshot-deleted-at + :profile-id profile-id + :session-id repair-session-id})) + (when-let [file (::snapshot file)] + (fsnap/create! cfg file + {:label (str "internal/snapshot/" revn) + :created-by "system" + :deleted-at snapshot-deleted-at :profile-id profile-id :session-id session-id}))) + (when repair + (db/insert! conn :file-change + {:id (uuid/next) + :session-id repair-session-id + :profile-id profile-id + :created-at timestamp + :updated-at timestamp + :deleted-at deleted-at + :file-id (:id file) + :revn (:revn repair) + :version (:version file) + :features (into-array (:features file)) + :changes (blob/encode (:changes repair))} + {::db/return-keys false}) + (l/wrn :hint "file auto-repaired" + :file-id (str (:id file)) + :error-codes (error-codes (:errors repair)) + :error-count (count (:errors repair)) + :change-count (count (:changes repair))) + (record-repair-outcome cfg :repaired)) + ;; Insert change (xlog) with deleted_at in a future data for ;; make them automatically eleggible for GC once they expires (db/insert! conn :file-change {:id (uuid/next) :session-id session-id :profile-id profile-id - :created-at timestamp - :updated-at timestamp + :created-at change-created-at + :updated-at change-created-at :deleted-at deleted-at :file-id (:id file) :revn (:revn file) @@ -304,12 +369,14 @@ :changes (blob/encode changes)} {::db/return-keys false}) - (persist-file! cfg file) + (persist-file! cfg (dissoc file ::repair)) (when (contains? cf/flags :redis-cache) (invalidate-caches! cfg file)) ;; Send asynchronous notifications + (when repair + (send-repair-notifications cfg params file repair)) (send-notifications! cfg params file) (with-meta {:revn revn :lagged (get-lagged-changes conn params)} @@ -434,9 +501,105 @@ (l/error :hint "file validation error" :cause cause)))) +(defn- apply-changes + [cfg file changes] + (let [state (atom {}) + file (binding [cpc/*state* state] + (-> file + (update :data cpc/process-changes changes) + (update :data d/without-nils)))] + (if-let [media-refs (-> @state :media-refs not-empty)] + (bfc/update-media-references! cfg file media-refs) + file))) + +(defn- fail-repair + [cfg file errors cause] + (l/wrn :hint "file auto-repair did not recover the file" + :file-id (str (:id file)) + :error-codes (error-codes errors) + :error-count (count errors)) + (record-repair-outcome cfg :unrepaired) + (throw cause)) + +(defn- repair-preexisting-damage + "Repair damage that was already in `original` on the pages/components + the client's `changes` touch, then re-apply those changes. Throws + `cause` when the client's changes are at fault or the repair cannot + fix the file." + [cfg original libs changes tracked tracked-0 cause] + (let [libs (or libs {})] + (try + (let [pre-errors + (binding [pmap/*tracked* nil] + (val/validate-file-affected original libs changes))] + + (when (empty? pre-errors) + (throw cause)) + + (when tracked + (reset! tracked tracked-0)) + + (let [trepair (ct/tpoint) + + result + (try + (binding [pmap/*tracked* tracked] + (cfr/repair-file-until-valid + original libs + (fn [file applied] + (binding [pmap/*tracked* nil] + (val/validate-file-affected file libs (into applied changes)))) + :initial-errors pre-errors)) + (finally + (record-validation-timing cfg :repair-ms "repair" (inst-ms (trepair))))) + + repair-changes (:changes result)] + + (when (seq (:errors result)) + (fail-repair cfg original (:errors result) cause)) + + (let [treprocess (ct/tpoint) + repaired (try + (binding [pmap/*tracked* tracked] + (apply-changes cfg (:file result) changes)) + (finally + (record-validation-timing cfg :reprocess-changes-ms "reprocess-changes" + (inst-ms (treprocess))))) + scope (into repair-changes changes)] + + (binding [pmap/*tracked* nil] + (val/validate-file-affected! repaired libs scope) + + (when (contains? cf/flags :file-schema-validation) + (let [t (ct/tpoint)] + (try + (val/validate-file-schema! repaired) + (finally + (record-validation-timing cfg :validate-schema-ms "validate-schema" (inst-ms (t)))))))) + + (-> repaired + (assoc :revn (+ (:revn original) 2)) + (assoc ::repair {:changes repair-changes + :errors pre-errors + :revn (inc (:revn original)) + :original (dissoc original ::snapshot)}))))) + (catch Throwable inner + (if (identical? inner cause) + (throw cause) + (do + (l/wrn :hint "file auto-repair failed" + :file-id (str (:id original)) + :cause inner) + (record-repair-outcome cfg :unrepaired) + (throw cause))))))) + (defn- process-changes-and-validate [cfg file changes skip-validate] - (let [should-resolve-libs? + (let [original file + tracked pmap/*tracked* + tracked-0 (some-> tracked deref) + + should-resolve-libs? (and (or (contains? cf/flags :file-validation) (contains? cf/flags :soft-file-validation)) (not skip-validate)) @@ -452,33 +615,20 @@ (when should-resolve-libs? (record-validation-timing cfg :resolve-libs-ms "resolve-libs" (inst-ms (tlibs)))))) - ;; The main purpose of this atom is provide a contextual state - ;; for the changes subsystem where optionally some hints can - ;; be provided for the changes processing. Right now we are - ;; using it for notify about the existence of media refs when - ;; a new shape is added. - state - (atom {}) - tproc (ct/tpoint) file (try - (let [file - (binding [cpc/*state* state] - (-> (files/check-version! file) - (update :revn inc) - (update :data cpc/process-changes changes) - (update :data d/without-nils))) - - file - (if-let [media-refs (-> @state :media-refs not-empty)] - (bfc/update-media-references! cfg file media-refs) - file)] - file) + (apply-changes cfg + (-> (files/check-version! file) + (update :revn inc)) + changes) (finally - (record-validation-timing cfg :process-changes-ms "process-changes" (inst-ms (tproc)))))] + (record-validation-timing cfg :process-changes-ms "process-changes" (inst-ms (tproc))))) + + pending-repair + (atom nil)] (binding [pmap/*tracked* nil] (when (contains? cf/flags :soft-file-validation) @@ -500,18 +650,25 @@ (let [t (ct/tpoint)] (try (val/validate-file-affected! file libs changes) + (catch Throwable cause + (reset! pending-repair cause) + (when-not (referential-integrity-error? cause) + (throw cause))) (finally (record-validation-timing cfg :validate-affected-ms "validate-affected" (inst-ms (t))))))) (when (and (contains? cf/flags :file-schema-validation) - (not skip-validate)) + (not skip-validate) + (nil? @pending-repair)) (let [t (ct/tpoint)] (try (val/validate-file-schema! file) (finally (record-validation-timing cfg :validate-schema-ms "validate-schema" (inst-ms (t)))))))) - file)) + (if-let [cause @pending-repair] + (repair-preexisting-damage cfg original libs changes tracked tracked-0 cause) + file))) (defn- take-snapshot? "Defines the rule when file `data` snapshot should be saved." @@ -541,6 +698,34 @@ (mapv (fn [row] (update row :changes blob/decode))))) +(defn- send-repair-notifications + [cfg {:keys [team session-id] :as params} file repair] + (let [changes (:changes repair) + lchanges (filter library-change? changes) + msgbus (::mbus/msgbus cfg)] + + (mbus/pub! msgbus + :topic (:id file) + :message {:type :file-change + :profile-id (:profile-id params) + :file-id (:id file) + :session-id repair-session-id + :revn (:revn repair) + :vern (:vern file) + :changes changes + :repaired true}) + + (when (and (:is-shared file) (seq lchanges)) + (mbus/pub! msgbus + :topic (:id team) + :message {:type :library-change + :profile-id (:profile-id params) + :file-id (:id file) + :session-id session-id + :revn (:revn repair) + :modified-at (ct/now) + :changes lchanges})))) + (defn- send-notifications! [cfg {:keys [team changes session-id] :as params} file] (let [lchanges (filter library-change? changes) diff --git a/backend/src/app/srepl/procs/file_repair.clj b/backend/src/app/srepl/procs/file_repair.clj index f863f250cf..c671f2d736 100644 --- a/backend/src/app/srepl/procs/file_repair.clj +++ b/backend/src/app/srepl/procs/file_repair.clj @@ -6,7 +6,6 @@ (ns app.srepl.procs.file-repair (:require - [app.common.files.changes :as cfc] [app.common.files.repair :as cfr] [app.common.files.validate :as cfv] [app.common.logging :as l])) @@ -17,44 +16,28 @@ (defn repair-file "Internal helper for validate and repair the file. The operation is - applied multiple times untile file is fixed or max iteration counter + applied multiple times until file is fixed or max iteration counter is reached (default 10). - This function should not be used directly, it is used throught the + This function should not be used directly, it is used through the app.srepl.main/repair-file! helper. In practical terms this function is private and implementation detail." [file libs & {:keys [max-iterations] :or {max-iterations 10}}] - (let [validate-and-repair - (fn [file libs iteration] - (when-let [errors (not-empty (cfv/validate-file file libs))] - (l/trc :hint "repairing file" - :file-id (str (:id file)) - :iteration iteration - :errors (count errors)) - (let [changes (cfr/repair-file file libs errors)] - (-> file - (update :revn inc) - (update :data cfc/process-changes changes))))) + (let [orig-revn (:revn file) + result (cfr/repair-file-until-valid + file libs + (fn [file _] + (cfv/validate-file file libs)) + :max-iterations max-iterations) + file' (update (:file result) :revn (fnil + 0) (:iterations result))] - process-file - (fn [file libs] - (loop [file file - iteration 0] - (if (< iteration max-iterations) - (if-let [file (validate-and-repair file libs iteration)] - (recur file (inc iteration)) - file) - (do - (l/wrn :hint "max retry num reached on repairing file" - :file-id (str (:id file)) - :iteration iteration) - file)))) + (when (>= (:iterations result) max-iterations) + (l/wrn :hint "max retry num reached on repairing file" + :file-id (str (:id file)) + :iteration (:iterations result))) - file' - (process-file file libs)] - - (when (not= (:revn file) (:revn file')) + (when (not= orig-revn (:revn file')) (l/trc :hint "file repaired" :file-id (str (:id file)))) file')) diff --git a/backend/test/backend_tests/rpc_file_update_repair_test.clj b/backend/test/backend_tests/rpc_file_update_repair_test.clj new file mode 100644 index 0000000000..e681d559ff --- /dev/null +++ b/backend/test/backend_tests/rpc_file_update_repair_test.clj @@ -0,0 +1,433 @@ +;; 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 SUBSIDIARY SL + +(ns backend-tests.rpc-file-update-repair-test + (:require + [app.binfile.common :as bfc] + [app.common.features :as cfeat] + [app.common.files.repair :as cfr] + [app.common.files.validate :as cfv] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.features.file-snapshots :as fsnap] + [app.metrics :as mtx] + [app.msgbus :as mbus] + [app.rpc :as-alias rpc] + [app.rpc.commands.files-update :as files-update] + [app.srepl.procs.file-repair :as file-repair] + [app.util.pointer-map :as pmap] + [backend-tests.helpers :as th] + [clojure.test :as t]) + (:import + io.prometheus.client.CollectorRegistry)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- with-repair-flags + [f] + (with-redefs [cf/flags (conj cf/flags :file-validation)] + (f))) + +(defn- with-full-repair-flags + [f] + (with-redefs [cf/flags (conj cf/flags :file-validation :file-schema-validation)] + (f))) + +(defn- repair-count + [outcome] + (or (.getSampleValue ^CollectorRegistry + (mtx/get-registry (::mtx/metrics th/*system*)) + "penpot_rpc_update_file_repair_total" + (into-array String ["outcome"]) + (into-array String [(name outcome)])) + 0.0)) + +(defn- add-rect + [page-id shape-id] + {:type :add-obj + :page-id page-id + :id shape-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id shape-id + :name "rect" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect})}) + +(defn- set-attr + [page-id shape-id attr val] + {:type :mod-obj + :id shape-id + :page-id page-id + :operations [{:type :set :attr attr :val val}]}) + +(defn- update-file + [profile-id file-id revn changes & {:keys [skip-validate session-id] + :or {session-id (uuid/random)}}] + (th/command! (cond-> {::th/type :update-file + ::rpc/profile-id profile-id + :id file-id + :session-id session-id + :revn revn + :vern 0 + :features cfeat/supported-features + :changes changes} + skip-validate (assoc :skip-validate true)))) + +(defn- stored-file + [file-id] + (bfc/get-file th/*system* file-id :realize? true)) + +(defn- change-rows + [file-id] + (->> (th/db-query :file-change {:file-id file-id} {::db/remove-deleted false}) + (filter (fn [row] + (and (some? (:changes row)) + (nil? (:label row))))) + (sort-by :created-at) + (vec))) + +(defn- label-rows + [file-id] + (->> (th/db-query :file-change {:file-id file-id} {::db/remove-deleted false}) + (filter :label) + (vec))) + +(defn- pointer-map-in? + [x] + (cond + (pmap/pointer-map? x) true + (map? x) (boolean (some pointer-map-in? (vals x))) + (sequential? x) (boolean (some pointer-map-in? x)) + :else false)) + +(defn- setup-file + [& {:keys [is-shared] :or {is-shared false}}] + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared is-shared}) + page-id (first (get-in file [:data :pages])) + shape-a (uuid/next) + shape-b (uuid/next) + out (update-file (:id profile) (:id file) 0 + [(add-rect page-id shape-a) + (add-rect page-id shape-b)])] + (t/is (th/success? out)) + {:profile profile + :file-id (:id file) + :page-id page-id + :shape-a shape-a + :shape-b shape-b + :revn (:revn (stored-file (:id file)))})) + +(defn- break-frame + [{:keys [profile file-id page-id shape-a revn]}] + (let [out (update-file (:id profile) file-id revn + [(set-attr page-id shape-a :frame-id (uuid/next))] + :skip-validate true)] + (t/is (th/success? out)) + (stored-file file-id))) + +(t/deftest repairs-preexisting-frame-not-found + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + before (repair-count :repaired) + out (update-file (:id profile) file-id (:revn broken) + [(set-attr page-id shape-b :name "renamed")]) + stored (stored-file file-id) + shape (get-in stored [:data :pages-index page-id :objects shape-a]) + frame (get-in stored [:data :pages-index page-id :objects (:frame-id shape)])] + (t/is (th/success? out)) + (t/is (nil? (cfv/validate-file stored {}))) + (t/is (some? frame)) + (t/is (= "renamed" + (get-in stored [:data :pages-index page-id :objects shape-b :name]))) + (t/is (= 1.0 (- (repair-count :repaired) before))))))) + +(t/deftest repairs-preexisting-frame-not-found-with-schema-validation + (with-full-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + n (:revn broken) + before-repaired (repair-count :repaired) + before-unrepaired (repair-count :unrepaired) + out (update-file (:id profile) file-id n + [(set-attr page-id shape-b :name "renamed")]) + stored (stored-file file-id) + shape (get-in stored [:data :pages-index page-id :objects shape-a]) + frame (get-in stored [:data :pages-index page-id :objects (:frame-id shape)]) + labels (label-rows file-id) + repair (filter #(= (str "internal/repair/" n) (:label %)) labels) + snap (filter #(= (str "internal/snapshot/" n) (:label %)) labels)] + (t/is (th/success? out)) + (t/is (nil? (cfv/validate-file stored {}))) + (t/is (some? frame)) + (t/is (= "renamed" + (get-in stored [:data :pages-index page-id :objects shape-b :name]))) + (t/is (= (+ n 2) (:revn stored))) + (t/is (= 1 (count repair))) + (t/is (zero? (count snap))) + (t/is (= 1.0 (- (repair-count :repaired) before-repaired))) + (t/is (= 0.0 (- (repair-count :unrepaired) before-unrepaired))))))) + +(t/deftest rejects-client-caused-break + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a revn]} (setup-file) + before (stored-file file-id) + out (update-file (:id profile) file-id revn + [(set-attr page-id shape-a :frame-id (uuid/next))]) + after (stored-file file-id)] + (t/is (not (th/success? out))) + (t/is (= :referential-integrity (:code (ex-data (:error out))))) + (t/is (= (:revn before) (:revn after))))))) + +(t/deftest skip-validate-does-not-repair + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + out (update-file (:id profile) file-id (:revn broken) + [(set-attr page-id shape-b :name "renamed")] + :skip-validate true) + stored (stored-file file-id) + shape (get-in stored [:data :pages-index page-id :objects shape-a])] + (t/is (th/success? out)) + (t/is (nil? (get-in stored [:data :pages-index page-id :objects (:frame-id shape)]))))))) + +(t/deftest damage-on-untouched-page-is-left + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a revn]} (setup-file) + page-2 (uuid/next) + shape-c (uuid/next) + _ (t/is (th/success? + (update-file (:id profile) file-id revn + [{:type :add-page :id page-2 :name "page-2"} + (add-rect page-2 shape-c)]))) + file (stored-file file-id) + _ (t/is (th/success? + (update-file (:id profile) file-id (:revn file) + [(set-attr page-2 shape-c :frame-id (uuid/next))] + :skip-validate true))) + file (stored-file file-id) + out (update-file (:id profile) file-id (:revn file) + [(set-attr page-id shape-a :name "on-page-1")]) + stored (stored-file file-id) + shape (get-in stored [:data :pages-index page-2 :objects shape-c])] + (t/is (th/success? out)) + (t/is (nil? (get-in stored [:data :pages-index page-2 :objects (:frame-id shape)]))))))) + +(t/deftest growing-scope-repairs-component-touched-by-repair + (thi/reset-idmap!) + (with-repair-flags + (fn [] + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (assoc :revn 0) + (tho/add-simple-component :component1 :root1 :child1) + (ths/add-sample-shape :other) + (update-in [:data :components (thi/id :component1)] + assoc :main-instance-id (uuid/next) + :objects {(uuid/next) {:id (uuid/next)}})) + page-id (thi/id :page1) + changes [(set-attr page-id (thi/id :other) :name "renamed")] + cfg {::files-update/validation-timings (atom {})} + result (with-redefs [bfc/get-resolved-file-libraries (fn [_ _] {})] + (@#'files-update/process-changes-and-validate cfg file changes nil)) + stored result] + (t/is (some? (::files-update/repair stored))) + (t/is (nil? (cfv/validate-file stored {}))) + (t/is (nil? (get-in stored [:data :components (thi/id :component1) :objects]))))))) + +(t/deftest repair-records-timings-with-schema-validation + (thi/reset-idmap!) + (with-full-repair-flags + (fn [] + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (assoc :revn 0) + (ths/add-sample-shape :shape1) + (ths/update-shape :shape1 :frame-id (uuid/next))) + changes [(set-attr (thi/id :page1) (thi/id :shape1) :name "renamed")] + timings (atom {}) + cfg {::files-update/validation-timings timings} + result (with-redefs [bfc/get-resolved-file-libraries (fn [_ _] {})] + (@#'files-update/process-changes-and-validate cfg file changes nil))] + (t/is (some? (::files-update/repair result))) + (t/is (number? (:repair-ms @timings))) + (t/is (number? (:reprocess-changes-ms @timings))) + (t/is (number? (:validate-schema-ms @timings))))))) + +(t/deftest unrepairable-rejects-and-calls-repair-once + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + calls (atom 0) + before (repair-count :unrepaired) + out (with-redefs [cfr/repair-file (fn [_ _ _] + (swap! calls inc) + [])] + (update-file (:id profile) file-id (:revn broken) + [(set-attr page-id shape-b :name "renamed")]))] + (t/is (not (th/success? out))) + (t/is (= :referential-integrity (:code (ex-data (:error out))))) + (t/is (= 1 @calls)) + (t/is (= 1.0 (- (repair-count :unrepaired) before))))))) + +(t/deftest repair-writes-change-rows-and-reuses-snapshot + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + before (count (change-rows file-id)) + session (uuid/next) + calls (atom []) + n (:revn broken) + out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}] + (swap! calls conj {:topic topic :message message}))] + (update-file (:id profile) file-id n + [(set-attr page-id shape-b :name "renamed")] + :session-id session)) + rows (change-rows file-id) + new (subvec rows before) + labels (label-rows file-id) + repair (filter #(= (str "internal/repair/" n) (:label %)) labels) + snap (filter #(= (str "internal/snapshot/" n) (:label %)) labels) + msgs (filter #(= :file-change (:type (:message %))) @calls) + stored (stored-file file-id)] + (t/is (th/success? out)) + (t/is (= "renamed" + (get-in stored [:data :pages-index page-id :objects shape-b :name]))) + (t/is (= 2 (count new))) + (t/is (= (inc n) (:revn (first new)))) + (t/is (= files-update/repair-session-id (:session-id (first new)))) + (t/is (some? (:deleted-at (first new)))) + (t/is (= (:created-at (first new)) (:updated-at (first new)))) + (t/is (= (+ n 2) (:revn (second new)))) + (t/is (= session (:session-id (second new)))) + (t/is (some? (:deleted-at (second new)))) + (t/is (= (:created-at (second new)) (:updated-at (second new)))) + (t/is (not (pos? (compare (:created-at (first new)) + (:created-at (second new)))))) + (t/is (= 1 (count repair))) + (t/is (zero? (count snap))) + (t/is (>= (count msgs) 2)) + (t/is (true? (get-in (first msgs) [:message :repaired]))) + (t/is (= files-update/repair-session-id + (get-in (first msgs) [:message :session-id]))) + (t/is (= session (get-in (second msgs) [:message :session-id]))) + (t/is (not (true? (get-in (second msgs) [:message :repaired])))) + (t/is (= (+ n 2) (get-in (second msgs) [:message :revn]))))))) + +(t/deftest repair-library-change-keeps-saver-session + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-b] :as ctx} (setup-file :is-shared true) + broken (break-frame ctx) + session (uuid/next) + calls (atom []) + out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}] + (swap! calls conj {:topic topic :message message}))] + (update-file (:id profile) file-id (:revn broken) + [(set-attr page-id shape-b :name "renamed")] + :session-id session)) + lib-msgs (filter #(= :library-change (:type (:message %))) @calls)] + (t/is (th/success? out)) + (t/is (seq lib-msgs)) + (doseq [msg lib-msgs] + (t/is (= session (get-in msg [:message :session-id]))) + (t/is (not= files-update/repair-session-id + (get-in msg [:message :session-id])))))))) + +(t/deftest repair-snapshot-without-auto-snapshot + (with-redefs [cf/flags (-> cf/flags + (conj :file-validation) + (disj :auto-file-snapshot))] + (let [{:keys [profile file-id page-id shape-b] :as ctx} (setup-file) + broken (break-frame ctx) + n (:revn broken) + out (update-file (:id profile) file-id n + [(set-attr page-id shape-b :name "renamed")]) + labels (label-rows file-id) + repair (filter #(= (str "internal/repair/" n) (:label %)) labels) + snaps (filter #(re-find #"^internal/snapshot/" (str (:label %))) labels) + row (first repair) + snap (fsnap/get-snapshot th/*system* file-id (:id row)) + sfile {:id file-id + :features (:features (stored-file file-id)) + :data (:data snap)}] + (t/is (th/success? out)) + (t/is (= 1 (count repair))) + (t/is (zero? (count snaps))) + (t/is (false? (pointer-map-in? (:data snap)))) + (t/is (some? (cfv/validate-file sfile {})))))) + +(t/deftest srepl-repair-grows-revn-by-iterations + (thi/reset-idmap!) + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (assoc :revn 3) + (ths/add-sample-shape :shape1) + (ths/update-shape :shape1 :frame-id (uuid/next))) + loop (cfr/repair-file-until-valid + file {} + (fn [file _] (cfv/validate-file file {}))) + wrapped (file-repair/repair-file file {})] + (t/is (pos? (:iterations loop))) + (t/is (= (+ (:revn file) (:iterations loop)) (:revn wrapped))) + (t/is (nil? (cfv/validate-file wrapped {}))))) + +(t/deftest healthy-save-writes-one-change-row + (with-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-b revn]} (setup-file) + before (count (change-rows file-id)) + out (update-file (:id profile) file-id revn + [(set-attr page-id shape-b :name "ok")]) + labels (label-rows file-id)] + (t/is (th/success? out)) + (t/is (= 1 (- (count (change-rows file-id)) before))) + (t/is (empty? (filter #(re-find #"^internal/repair/" (str (:label %))) labels))))))) + +(t/deftest repairs-several-missing-children-on-root + (with-full-repair-flags + (fn [] + (let [{:keys [profile file-id page-id shape-a shape-b revn]} (setup-file) + r1 (uuid/next) + r2 (uuid/next) + r3 (uuid/next) + _ (t/is (th/success? + (update-file (:id profile) file-id revn + [(set-attr page-id uuid/zero :shapes + [shape-a shape-b r1 r2 r3])] + :skip-validate true))) + broken (stored-file file-id) + errors (cfv/validate-file broken {}) + out (update-file (:id profile) file-id (:revn broken) + [(set-attr page-id shape-b :name "renamed")]) + stored (stored-file file-id) + root (get-in stored [:data :pages-index page-id :objects uuid/zero])] + (t/is (= [:child-not-found :child-not-found :child-not-found] + (mapv :code errors))) + (t/is (= #{uuid/zero} (into #{} (map :shape-id) errors))) + (t/is (th/success? out)) + (t/is (= [shape-a shape-b] (:shapes root))) + (t/is (nil? (cfv/validate-file stored {}))) + (t/is (= "renamed" + (get-in stored [:data :pages-index page-id :objects shape-b :name]))))))) diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc index 4756f8a758..f0a52b788c 100644 --- a/common/src/app/common/files/repair.cljc +++ b/common/src/app/common/files/repair.cljc @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.changes :as cfc] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.tokens :as cfo] @@ -22,8 +23,6 @@ [app.common.types.variant :as ctv] [app.common.uuid :as uuid])) -(log/set-level! :debug) - (defmulti repair-error (fn [code _error _file-data _libraries] code)) @@ -867,3 +866,58 @@ (pcb/empty-changes nil) errors)] redo-changes)) + +(defn- error-fingerprint + [errors] + (frequencies (map #(dissoc % :shape) errors))) + +(defn repair-file-until-valid + "Repeatedly validate and repair `file` until `errors-fn` returns nil, + a repair pass leaves `:data` unchanged, or the error fingerprint + (errors without `:shape`) is unchanged from the previous pass. + + `errors-fn` is `(fn [file applied-changes] errors-or-nil)`. Never + changes `:revn`. + + Returns `{:file file' :changes [...] :iterations n :errors remaining}`." + [file libraries errors-fn & {:keys [max-iterations initial-errors] + :or {max-iterations 10}}] + (loop [file file + applied [] + iteration 0 + prev-fp nil + seed-errors initial-errors] + (if (>= iteration max-iterations) + {:file file + :changes applied + :iterations iteration + :errors (not-empty (errors-fn file applied))} + (let [errors (if (and (zero? iteration) (some? seed-errors)) + seed-errors + (errors-fn file applied))] + (if (empty? errors) + {:file file + :changes applied + :iterations iteration + :errors nil} + (let [fp (error-fingerprint errors)] + (cond + (= fp prev-fp) + {:file file + :changes applied + :iterations (inc iteration) + :errors (not-empty errors)} + + :else + (let [batch (repair-file file libraries errors) + data' (cfc/process-changes (:data file) batch)] + (if (= data' (:data file)) + {:file file + :changes applied + :iterations (inc iteration) + :errors (not-empty errors)} + (recur (assoc file :data data') + (into applied batch) + (inc iteration) + fp + nil)))))))))) diff --git a/common/test/common_tests/files/repair_test.cljc b/common/test/common_tests/files/repair_test.cljc index 27f6964de2..f6f24821ce 100644 --- a/common/test/common_tests/files/repair_test.cljc +++ b/common/test/common_tests/files/repair_test.cljc @@ -378,3 +378,171 @@ (t/is (= uuid/zero (:parent-id shape1'))) (t/is (= uuid/zero (:parent-id shape2'))) (t/is (= #{(thi/id :shape1) (thi/id :shape2)} (set (:shapes root'))))))) + +(defn- broken-frame-file + [] + (-> (thf/sample-file :file1 :page-label :page1) + (assoc :revn 3) + (ths/add-sample-shape :shape1) + (ths/update-shape :shape1 :frame-id (uuid/next)))) + +(t/deftest repair-file-until-valid-fixes-frame-not-found + (t/testing "repairs a :frame-not-found shape and leaves :revn untouched" + (let [file (broken-frame-file) + errors (cfv/validate-file file {}) + result (cfr/repair-file-until-valid + file {} + (fn [file _] (cfv/validate-file file {}))) + file' (:file result) + errors' (cfv/validate-file file' {}) + shape' (ths/get-shape file' :shape1 :page-label :page1)] + + (t/is (= #{:frame-not-found} (into #{} (map :code) errors))) + (t/is (pos? (count (:changes result)))) + (t/is (nil? (:errors result))) + (t/is (nil? errors')) + (t/is (= 3 (:revn file'))) + (t/is (some? (get-in file' [:data :pages-index (thi/id :page1) :objects (:frame-id shape')])))))) + +(t/deftest repair-file-until-valid-healthy-file + (t/testing "a valid file is returned unchanged" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (assoc :revn 2) + (ths/add-sample-shape :shape1)) + result (cfr/repair-file-until-valid + file {} + (fn [file _] (cfv/validate-file file {})))] + (t/is (identical? file (:file result))) + (t/is (= [] (:changes result))) + (t/is (zero? (:iterations result))) + (t/is (nil? (:errors result))) + (t/is (= 2 (:revn (:file result))))))) + +(t/deftest repair-file-until-valid-passes-applied-changes + (t/testing "errors-fn receives the changes applied so far" + (let [calls (atom []) + file (broken-frame-file) + result (cfr/repair-file-until-valid + file {} + (fn [file applied] + (swap! calls conj applied) + (cfv/validate-file file {})))] + (t/is (nil? (:errors result))) + (t/is (>= (count @calls) 2)) + (t/is (= [] (first @calls))) + (t/is (pos? (count (last @calls))))))) + +(t/deftest repair-file-until-valid-stops-on-noop-repair + (t/testing "an empty repair batch stops after one iteration" + (let [file (broken-frame-file) + errors (cfv/validate-file file {})] + (with-redefs [cfr/repair-file (fn [_ _ _] [])] + (let [result (cfr/repair-file-until-valid + file {} + (fn [file _] (cfv/validate-file file {})))] + (t/is (= 1 (:iterations result))) + (t/is (= [] (:changes result))) + (t/is (= (mapv :code errors) (mapv :code (:errors result))))))))) + +(t/deftest repair-file-until-valid-repairs-several-errors-on-one-shape + (t/testing "three missing children of one frame are all removed although + every pass reports the same [code shape-id]" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (ths/add-sample-shape :frame1 :type :frame) + (ths/update-shape :frame1 :shapes [(uuid/next) (uuid/next) (uuid/next)])) + errors (cfv/validate-file file {}) + result (cfr/repair-file-until-valid + file {} + (fn [file _] (cfv/validate-file file {}))) + frame1' (ths/get-shape (:file result) :frame1 :page-label :page1)] + (t/is (= [:child-not-found :child-not-found :child-not-found] (mapv :code errors))) + (t/is (= #{(thi/id :frame1)} (into #{} (map :shape-id) errors))) + (t/is (nil? (:errors result))) + (t/is (nil? (cfv/validate-file (:file result) {}))) + (t/is (= [] (:shapes frame1')))))) + +(t/deftest repair-file-until-valid-stops-when-repair-changes-nothing + (t/testing "a non-empty repair that leaves data equal stops after one iteration" + (let [file (broken-frame-file) + shape-id (thi/id :shape1) + page-id (thi/id :page1) + cur-name (:name (ths/get-shape file :shape1 :page-label :page1))] + (with-redefs [cfr/repair-file + (fn [_ _ _] + [{:type :mod-obj + :id shape-id + :page-id page-id + :operations [{:type :set :attr :name :val cur-name}]}])] + (let [result (cfr/repair-file-until-valid + file {} + (fn [_ _] [{:code :frame-not-found :shape-id shape-id}]) + :max-iterations 10)] + (t/is (= 1 (:iterations result))) + (t/is (= [] (:changes result))) + (t/is (= [:frame-not-found] (mapv :code (:errors result))))))))) + +(t/deftest repair-file-until-valid-stops-when-error-fingerprint-stable + (t/testing "a mutating repair with an unchanged error report stops after two iterations" + (let [file (broken-frame-file) + shape-id (thi/id :shape1) + page-id (thi/id :page1) + pass (atom 0) + sticky {:code :frame-not-found + :shape-id shape-id + :hint "stuck" + :args {:x 1}}] + (with-redefs [cfr/repair-file + (fn [_ _ _] + [{:type :mod-obj + :id shape-id + :page-id page-id + :operations [{:type :set + :attr :name + :val (str "pass-" (swap! pass inc))}]}])] + (let [result (cfr/repair-file-until-valid + file {} + (fn [_ _] [sticky]) + :max-iterations 10)] + (t/is (= 2 (:iterations result))) + (t/is (= 1 (count (:changes result)))) + (t/is (= [:frame-not-found] (mapv :code (:errors result))))))))) + +(t/deftest repair-file-until-valid-stops-at-max-iterations + (t/testing "a repair that never empties errors stops at max-iterations" + (let [file (broken-frame-file) + pass (atom 0) + shape-id (thi/id :shape1) + page-id (thi/id :page1)] + (with-redefs [cfr/repair-file + (fn [_ _ _] + [{:type :mod-obj + :id shape-id + :page-id page-id + :operations [{:type :set + :attr :name + :val (str "pass-" (swap! pass inc))}]}])] + (let [result (cfr/repair-file-until-valid + file {} + (fn [_ _] + [{:code :frame-not-found + :shape-id (uuid/next)}]) + :max-iterations 3)] + (t/is (= 3 (:iterations result))) + (t/is (= 3 (count (:changes result)))) + (t/is (some? (:errors result)))))))) + +(t/deftest repair-file-until-valid-uses-initial-errors + (t/testing "errors-fn is not called on the first iteration when :initial-errors is given" + (let [calls (atom 0) + file (broken-frame-file) + seed (cfv/validate-file file {}) + result (cfr/repair-file-until-valid + file {} + (fn [file applied] + (swap! calls inc) + (cfv/validate-file file {})) + :initial-errors seed)] + (t/is (nil? (:errors result))) + ;; First iteration used the seed; errors-fn runs only after the repair. + (t/is (= 1 @calls))))) + diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index b3eead45ef..c733337e2a 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -16,6 +16,7 @@ [app.main.data.common :as dc] [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.plugins :as dpl] [app.main.data.websocket :as dws] [app.main.data.workspace :as-alias dw] @@ -26,6 +27,7 @@ [app.main.data.workspace.texts :as dwt] [app.main.router :as rt] [app.util.globals :refer [global]] + [app.util.i18n :refer [tr]] [app.util.mouse :as mse] [app.util.object :as obj] [app.util.rxops :as rxs] @@ -245,7 +247,8 @@ [:session-id ::sm/uuid] [:revn :int] [:vern :int] - [:changes cpc/schema:changes]]) + [:changes cpc/schema:changes] + [:repaired {:optional true} :boolean]]) (def ^:private check-file-change-params! (sm/check-fn schema:handle-file-change)) @@ -267,13 +270,17 @@ ;; and update the persistence internal state with the updated ;; file-revn - (rx/of (dch/commit {:file-id file-id - :file-revn revn - :file-vern vern - :save-undo? false - :source :remote - :redo-changes (vec changes) - :undo-changes []}))))) + (rx/concat + (rx/of (dch/commit {:file-id file-id + :file-revn revn + :file-vern vern + :save-undo? false + :source :remote + :redo-changes (vec changes) + :undo-changes []})) + (if (:repaired msg) + (rx/of (ntf/info (tr "workspace.notifications.file-repaired"))) + (rx/empty)))))) (defn handle-file-deleted [{:keys [file-id] :as msg}] diff --git a/frontend/test/frontend_tests/data/workspace_notifications_test.cljs b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs new file mode 100644 index 0000000000..95872a0034 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs @@ -0,0 +1,50 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.workspace-notifications-test + (:require + [app.common.data :as d] + [app.common.uuid :as uuid] + [app.main.data.changes :as dch] + [app.main.data.notifications :as ntf] + [app.main.data.workspace.notifications :as dwntf] + [app.util.i18n :as i18n] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [potok.v2.core :as ptk])) + +(defn- file-change-msg + [extra] + (merge {:type :file-change + :profile-id (uuid/next) + :file-id (uuid/next) + :session-id (uuid/next) + :revn 1 + :vern 0 + :changes []} + extra)) + +(defn- ^:async watch-file-change + [msg] + (let [events (atom [])] + (await (async/observe (ptk/watch (dwntf/handle-file-change msg) {} (rx/empty)) + :on-next #(swap! events conj %))) + @events)) + +(t/deftest ^:async repaired-file-change-applies-and-notifies + (let [events (await (watch-file-change (file-change-msg {:repaired true}))) + types (mapv ptk/type events) + show (d/seek #(= ::ntf/show (ptk/type %)) events) + state (ptk/update show {})] + (t/is (= [::dch/commit ::ntf/show] types)) + (t/is (= :info (get-in state [:notification :level]))) + (t/is (= (i18n/tr "workspace.notifications.file-repaired") + (get-in state [:notification :content]))))) + +(t/deftest ^:async ordinary-file-change-does-not-notify + (let [events (await (watch-file-change (file-change-msg {})))] + (t/is (= [::dch/commit] (mapv ptk/type events))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index c1699b9564..2f4810c536 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6977,6 +6977,12 @@ msgstr "UPDATES" msgid "workspace.notification-pill.detail" msgstr "Details" +#: src/app/main/data/workspace/notifications.cljs:282 +msgid "workspace.notifications.file-repaired" +msgstr "" +"We fixed some issues in this file. You can find a copy of the original version in " +"the version history." + #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 msgid "workspace.options.add-interaction" msgstr "Click the + button to add interactions." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index b579adfd77..137798f51f 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -7035,6 +7035,12 @@ msgstr "ACTUALIZACIONES" msgid "workspace.notification-pill.detail" msgstr "Detalles" +#: src/app/main/data/workspace/notifications.cljs:282 +msgid "workspace.notifications.file-repaired" +msgstr "" +"Hemos corregido algunos problemas de este archivo. Hay una copia de la " +"versión original en el historial de versiones." + #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 msgid "workspace.options.add-interaction" msgstr "Pulsa el botón + para añadir interacciones." From 68ecfb1234e01112e7988b3b94dcbdfb63a8b0a7 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 11:56:09 +0100 Subject: [PATCH 02/65] :bug: Fix default shortcuts that fail on some layouts and browsers (#12506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the problems found after #12326 and listed in #12327. Mousetrap now tries several names for each key press, in order, and fires the first one with a binding: letters and digits by keyCode when a modifier is held, punctuation by position (layout map, or a US table when the browser has none), the typed glyph, and the typed glyph without the Shift it needed. With AltGr the typed glyph comes before the position. The shortcut recorder asks Mousetrap for the key it stores, so a recorded shortcut always fires. This makes "?" and "+" work on every layout, Shift and Ctrl+Shift with punctuation work in Firefox and Safari, Option+letter work on macOS, and AltGr+º hide the UI on Linux with a Spanish layout. Chrome on Linux returns the map of the first configured layout, not the active one. Mousetrap now drops the map when an unmodified punctuation key types something else. The layout twins are no longer needed and are removed: "shift+'" made "?" zoom to the selection on Spanish layouts. The Add node shortcut is removed too: on US layouts its key is the "+" zoom key, and on Firefox with a Spanish layout it cannot fire. The Firefox and Safari warning in the shortcuts settings is gone, since those shortcuts now work there. The user guide and translations follow these changes. AI-assisted-by: claude-opus-5-5 --- docs/user-guide/first-steps/shortcuts.njk | 11 +- frontend/packages/mousetrap/index.js | 320 +++++++++++++++--- .../mousetrap/test/bind-dispatch.test.js | 8 +- .../mousetrap/test/layout-map.test.js | 81 ++++- .../packages/mousetrap/test/modifiers.test.js | 35 +- .../packages/mousetrap/test/normalize.test.js | 111 +++++- .../scripts/check-translations/words.es.txt | 1 - frontend/src/app/main/data/shortcuts.cljs | 42 +-- .../src/app/main/data/workspace/path.cljs | 1 - .../main/data/workspace/path/shortcuts.cljs | 8 - .../app/main/data/workspace/path/tools.cljs | 3 - .../app/main/data/workspace/shortcuts.cljs | 6 +- .../src/app/main/ui/settings/shortcuts.cljs | 5 - .../src/app/main/ui/settings/shortcuts.scss | 4 - frontend/src/app/main/ui/shortcuts.cljs | 77 +---- .../ui/settings_shortcuts_test.cljs | 89 +++-- frontend/translations/ar.po | 4 - frontend/translations/ca.po | 4 - frontend/translations/cs.po | 4 - frontend/translations/de.po | 4 - frontend/translations/en.po | 11 - frontend/translations/es.po | 11 - frontend/translations/eu.po | 4 - frontend/translations/fa.po | 4 - frontend/translations/fr.po | 4 - frontend/translations/fr_CA.po | 4 - frontend/translations/ha.po | 4 - frontend/translations/he.po | 4 - frontend/translations/hi.po | 4 - frontend/translations/hr.po | 4 - frontend/translations/id.po | 4 - frontend/translations/it.po | 4 - frontend/translations/jpn_JP.po | 4 - frontend/translations/ko.po | 4 - frontend/translations/lv.po | 4 - frontend/translations/ms.po | 4 - frontend/translations/nl.po | 4 - frontend/translations/pl.po | 4 - frontend/translations/pt_BR.po | 4 - frontend/translations/pt_PT.po | 4 - frontend/translations/ro.po | 4 - frontend/translations/ru.po | 4 - frontend/translations/sr.po | 4 - frontend/translations/sv.po | 4 - frontend/translations/tr.po | 4 - frontend/translations/ukr_UA.po | 4 - frontend/translations/yo.po | 4 - frontend/translations/zh_CN.po | 4 - frontend/translations/zh_Hant.po | 4 - 49 files changed, 572 insertions(+), 376 deletions(-) diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk index ab8279486a..e80d59f025 100644 --- a/docs/user-guide/first-steps/shortcuts.njk +++ b/docs/user-guide/first-steps/shortcuts.njk @@ -35,9 +35,9 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface

Paste cannot be customized.

Keyboard layouts

-

Letters are recorded by the character they type, so they follow your layout (on AZERTY, the A key records a). With Shift, digit keys record the digit itself (Shift0, not the shifted symbol), on every layout and in every browser.

-

Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so the same key works on AZERTY, QWERTZ and other layouts — but only in browsers that expose the keyboard layout (Chromium). There, Shift with the + key records Shift+. In browsers without that support, such as Firefox or Safari, punctuation falls back to the typed character (Shift* instead): those shortcuts still work in the browser where you recorded them, but Shift combinations with punctuation may stop working if you switch browsers. If that affects you, record those shortcuts in a Chromium browser.

-

Two known limitations: number keys set opacity (0–9) only where digits are typed without Shift, so AZERTY layouts need the numeric keypad for them; and \ needs AltGr on some layouts (Spanish), where it is recorded with Alt and its exact form depends on the browser.

+

Letters are recorded by the character they type, so they follow your layout (on AZERTY, the A key records a). With Shift, Alt or another modifier, letter and digit keys record the key itself, not the character the modifier makes (Shift0, not Shift); OptionM on macOS, not µ).

+

Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so Shift with the + key records Shift+ on a Spanish layout. In browsers that do not expose the keyboard layout, such as Firefox or Safari, Penpot records the character you type instead; the shortcut still works there. Keys typed with AltGr record the character they type (\ on a Spanish layout).

+

One known limitation: number keys set opacity (0–9) only where digits are typed without Shift, so AZERTY layouts need the numeric keypad for them.

Reset to default

When you have custom shortcuts, a Reset all button appears next to the search field. It lists every shortcut that will go back to its default before you confirm.

@@ -617,11 +617,6 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface - - Add node - Shift+ - ⇧+ - Delete node, segment or handle Delete or Backspace diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js index e3864396a4..3166b24404 100644 --- a/frontend/packages/mousetrap/index.js +++ b/frontend/packages/mousetrap/index.js @@ -94,8 +94,8 @@ const _SPECIAL_ALIASES = { /** * Physical codes treated positionally. Letters and digits resolve by * glyph (`event.key`), but punctuation resolves by position (`event.code`) - * through the layout map, so the same physical key matches on every - * layout with and without `Shift`. Without a map the typed glyph applies. + * through the layout map, or `_US_PUNCTUATION` without one, so the same + * physical key matches with and without `Shift`. See `_candidates`. * * @type {Set} */ @@ -113,6 +113,45 @@ const _PUNCTUATION_CODES = new Set([ "Slash", ]); +/** + * Primary character of each punctuation key on a US layout. Used when + * the browser has no layout map (Firefox, Safari), so punctuation still + * resolves by position and every browser names a key the same way. + * + * @type {Object} + */ +const _US_PUNCTUATION = { + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Backquote: "`", + Comma: ",", + Period: ".", + Slash: "/", +}; + +/** + * Letter or digit named by `keyCode`. With a modifier held, `event.key` + * carries the glyph the modifier makes (`!` for Shift+1, `µ` for + * Option+M on macOS, or `Dead`), while `keyCode` still names the key. + * Unlike `event.code` it follows the layout, so Ctrl+Z stays `z` on + * AZERTY. + * + * @param {Event} e + * @return {string|null} + */ +function _letterOrDigitFromKeyCode(e) { + const keyCode = e.keyCode; + if ((keyCode >= 48 && keyCode <= 57) || (keyCode >= 65 && keyCode <= 90)) { + return String.fromCharCode(keyCode).toLowerCase(); + } + return null; +} + /** * Resolves an event to its canonical key. Implemented as * `Mousetrap#_characterFromEvent` so punctuation can consult the @@ -130,27 +169,38 @@ function _modifiersMatch(modifiers1, modifiers2) { return modifiers1.sort().join(",") === modifiers2.sort().join(","); } +/** + * whether the `AltGraph` modifier is active for the event + * + * @param {Event} e + * @returns {boolean} + */ +function _isAltGraph(e) { + return ( + typeof e.getModifierState === "function" && e.getModifierState("AltGraph") + ); +} + /** * takes a key event and figures out what the modifiers are * * On Windows `AltGr` reports `ctrlKey + altKey` as well. When the - * `AltGraph` modifier is active the phantom `ctrl` is dropped so typing - * (e.g. `]` on Spanish keyboards) does not fire `ctrl+alt` shortcuts. + * `AltGraph` modifier is active both phantom modifiers are dropped, so + * typing (e.g. `]` or `\` on Spanish keyboards) neither fires `ctrl+alt` + * shortcuts nor hides the plain glyph binding. * * @param {Event} e * @returns {Array} */ function _eventModifiers(e) { const modifiers = []; - const altGraph = - typeof e.getModifierState === "function" && - e.getModifierState("AltGraph"); + const altGraph = _isAltGraph(e); if (e.shiftKey) { modifiers.push("shift"); } - if (e.altKey) { + if (e.altKey && !altGraph) { modifiers.push("alt"); } @@ -383,22 +433,209 @@ class Mousetrap { } /** - * takes the event and returns the canonical key + * drops the layout map when it does not describe the active layout * - * Letters, digits and named keys resolve by glyph: single characters - * lowercase so `Shift+Z` matches a `ctrl+shift+z` bind, `" "` maps to - * `space`, and named keys map through `_KEY_NAMES`. The exception is a - * digit row key held with `Shift`, which resolves to the digit itself - * (`Digit0` types ")" on US layouts but "0" on AZERTY, so `shift+0` - * must mean the physical digit key everywhere). Punctuation - * resolves positionally via the layout map (see `setLayoutMap`), - * falling back to the glyph when no map is set. Numpad digits stay - * positional (`num0`..`num9`) so they keep their own bindings. + * Chrome on Linux returns the map of the first configured layout, not + * the active one: with Spanish first and US active, the map names the + * `=` key `¡`. A punctuation key pressed without modifiers types the + * primary character of its position, so when that differs from the + * map, punctuation goes back to the glyph and US-table fallback for the + * rest of the session. + * + * @param {Event} e + * @returns void + */ + _checkLayoutMap(e) { + if ( + !this._layoutMap || + e.type !== "keydown" || + !e.code || + !_PUNCTUATION_CODES.has(e.code) || + typeof e.key !== "string" || + e.key.length !== 1 || + e.shiftKey || + e.ctrlKey || + e.altKey || + e.metaKey || + _isAltGraph(e) + ) { + return; + } + + let mapped = null; + try { + mapped = this._layoutMap.get(e.code); + } catch { + mapped = null; + } + + if ( + typeof mapped === "string" && + mapped !== "" && + mapped.toLowerCase() !== e.key.toLowerCase() + ) { + this._layoutMap = null; + } + } + + /** + * takes the event and returns the canonical key: the first candidate + * of `_candidates`, which is also what the shortcut recorder stores + * (see `keyForRecording`). * * @param {Event} e * @return {string|null} */ _characterFromEvent(e) { + const candidates = this._candidates(e); + return candidates.length ? candidates[0][0] : null; + } + + /** + * public entry point for the shortcut recorder: the key token to store + * for this event, always one of `_candidates`, so this instance matches + * the recorded shortcut. Returns `null` for modifiers alone. + * + * Letters and digits with a modifier use `keyCode`. Punctuation uses + * its position when there is a layout map, since the map names the + * printed key. Without a map (Firefox, Safari) the typed glyph is a + * better label than the US key at that position, so the Spanish `+` + * records `+`, not `]`. With AltGr the typed glyph wins too: AltGr+º + * on a Spanish layout records `\`, the character the user typed. + * + * @param {Event} e + * @return {string|null} + */ + keyForRecording(e) { + this._checkLayoutMap(e); + + const modifiers = _eventModifiers(e); + const numpad = e.code && e.code.indexOf("Numpad") === 0; + const keyCodeKey = + modifiers.length && !numpad ? _letterOrDigitFromKeyCode(e) : null; + const position = this._positionFromEvent(e); + const glyph = this._glyphFromEvent(e); + + let key; + if (keyCodeKey) { + key = keyCodeKey; + } else if (this._layoutMap && !_isAltGraph(e)) { + key = position || glyph; + } else { + key = glyph || position; + } + + return key && !_isModifier(key) ? key : null; + } + + /** + * the `[key, modifiers]` pairs an event may match, most specific + * first. `_handleKeyEvent` dispatches the first pair that has a + * binding: + * + * 1. With a modifier held, letters and digits by `keyCode`, so + * `Shift+1` is `shift+1` and `Option+M` is `alt+m`. + * 2. Punctuation by position: the primary character from the layout + * map (see `setLayoutMap`), or from a US table when the browser has + * no map, so `Shift+*` on a Spanish layout is `shift++`. + * 3. The typed glyph (`event.key`), so `+` on a US layout (typed with + * Shift) matches a `shift++` binding. + * 4. The typed glyph without the Shift it needed, so `?` and `+` + * bindings fire. + * + * With AltGr, 3 comes before 2 (see `keyForRecording`). + * + * @param {Event} e + * @return {Array} + */ + _candidates(e) { + const modifiers = _eventModifiers(e); + const candidates = []; + const add = (key, mods) => { + if (key) { + candidates.push([key, mods]); + } + }; + + const numpad = e.code && e.code.indexOf("Numpad") === 0; + if (modifiers.length && !numpad) { + add(_letterOrDigitFromKeyCode(e), modifiers); + } + + // With AltGr the typed glyph comes before the position: AltGr+º + // types `\` on a Spanish layout, and the key at that position is `º`. + const altGraph = _isAltGraph(e); + const position = this._positionFromEvent(e); + const glyph = this._glyphFromEvent(e); + if (altGraph) { + add(glyph, modifiers); + add(position, modifiers); + } else { + add(position, modifiers); + add(glyph, modifiers); + } + + if ( + glyph && + glyph.length === 1 && + modifiers.includes("shift") && + !/[a-z0-9]/.test(glyph) + ) { + add( + glyph, + modifiers.filter((m) => m !== "shift"), + ); + } + + // On Windows Ctrl+Alt is AltGr: Ctrl+Alt+E types `€` on a Spanish + // layout. When nothing is bound to the glyph, fall back to the + // `ctrl+alt` binding of the key. + if (altGraph && e.ctrlKey && e.altKey && !numpad) { + add(_letterOrDigitFromKeyCode(e), [...modifiers, "alt", "ctrl"]); + } + + return candidates; + } + + /** + * primary character of a punctuation key by its position, or `null` + * + * @param {Event} e + * @return {string|null} + */ + _positionFromEvent(e) { + if (!e.code || !_PUNCTUATION_CODES.has(e.code)) { + return null; + } + + let mapped = null; + if (this._layoutMap) { + try { + mapped = this._layoutMap.get(e.code); + } catch { + mapped = null; + } + } + + if (typeof mapped !== "string" || mapped === "") { + mapped = _US_PUNCTUATION[e.code]; + } + + return mapped ? mapped.toLowerCase() : null; + } + + /** + * the typed glyph of the event as a canonical key + * + * Single characters lowercase so `Shift+Z` matches a `ctrl+shift+z` + * bind, `" "` maps to `space`, and named keys map through + * `_KEY_NAMES`. Numpad digits stay positional (`num0`..`num9`) so they + * keep their own bindings. + * + * @param {Event} e + * @return {string|null} + */ + _glyphFromEvent(e) { // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types. if (e.code && e.code.indexOf("Numpad") === 0) { const suffix = e.code.substring(6); @@ -407,35 +644,11 @@ class Mousetrap { } } - // Punctuation is positional: with a layout map the primary - // (unshifted) character of the physical key wins, so `Shift+*` on a - // Spanish layout (code `BracketRight`) resolves to `+` like the - // recorder stores. Without a map the typed glyph applies. - if (e.code && _PUNCTUATION_CODES.has(e.code) && this._layoutMap) { - let mapped = null; - try { - mapped = this._layoutMap.get(e.code); - } catch { - mapped = null; - } - if (typeof mapped === "string" && mapped !== "") { - return mapped.toLowerCase(); - } - } - const key = e.key; if (typeof key !== "string" || key === "") { return null; } - // Shifted digits resolve to the digit itself: with `Shift` held, - // `Digit0` types ")" on US layouts but "0" on AZERTY, so the glyph - // can't be the token. `shift+0` means the physical digit key - // everywhere, like `shift+z` means `z` regardless of `Shift`. - if (e.shiftKey && e.code && /^Digit[0-9]$/.test(e.code) && key.length === 1) { - return e.code.charAt(5); - } - if (_KEY_NAMES[key]) { return _KEY_NAMES[key]; } @@ -862,18 +1075,35 @@ class Mousetrap { * @returns void */ _handleKeyEvent(e) { - const character = this._characterFromEvent(e); + this._checkLayoutMap(e); + + const candidates = this._candidates(e); // no character found then stop - if (!character) { + if (!candidates.length) { return; } + // dispatch the first candidate that has a binding, or the first one + // so sequences still reset on unbound keys + let picked = candidates[0]; + for (const candidate of candidates) { + if ( + this._callbacks[candidate[0]] && + this._getMatches(candidate[0], candidate[1], e).length + ) { + picked = candidate; + break; + } + } + + const [character, modifiers] = picked; + if (e.type == "keyup" && this._ignoreNextKeyup === character) { this._ignoreNextKeyup = false; return; } - this.handleKey(character, _eventModifiers(e), e); + this.handleKey(character, modifiers, e); } } diff --git a/frontend/packages/mousetrap/test/bind-dispatch.test.js b/frontend/packages/mousetrap/test/bind-dispatch.test.js index 7512151126..6ae0eb45a2 100644 --- a/frontend/packages/mousetrap/test/bind-dispatch.test.js +++ b/frontend/packages/mousetrap/test/bind-dispatch.test.js @@ -36,11 +36,15 @@ describe("bind and dispatch", () => { shiftPlus++; }); fire(target, makeKeyEvent({ key: "+", code: "Equal" })); + // Without a layout map Shift + the Spanish "+" key types "*" and + // nothing says the key is "+", so "shift++" does not fire. fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); - // "*" normalizes by glyph, so "shift++" (shift + "+" key) does not fire; - // this pins the layout-independent behavior for the "+" physical key. assert.equal(plus, 1); assert.equal(shiftPlus, 0); + // A layout map names the key, so it does. + mousetrap.setLayoutMap({ get: (code) => (code === "BracketRight" ? "+" : undefined) }); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + assert.equal(shiftPlus, 1); }); it("resolves bind-string aliases to canonical tokens", () => { diff --git a/frontend/packages/mousetrap/test/layout-map.test.js b/frontend/packages/mousetrap/test/layout-map.test.js index 3cb34eb722..e3b914932d 100644 --- a/frontend/packages/mousetrap/test/layout-map.test.js +++ b/frontend/packages/mousetrap/test/layout-map.test.js @@ -23,7 +23,16 @@ describe("layout map", () => { it("resolves punctuation by position, so Shift+* fires a shift++ bind", () => { const event = { key: "*", code: "BracketRight", shiftKey: true }; assert.equal(firedWithMap(spanishMap, "shift++", event), 1); - assert.equal(firedWithMap(spanishMap, "shift+*", event), 0); + }); + + it("prefers the position over the glyph when both are bound", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(spanishMap); + const calls = []; + mousetrap.bind("shift++", () => calls.push("shift++")); + mousetrap.bind("shift+*", () => calls.push("shift+*")); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + assert.deepEqual(calls, ["shift++"]); }); it("matches the unshifted primary on the same position", () => { @@ -92,4 +101,74 @@ describe("layout map", () => { fire(target, makeKeyEvent({ key: "a", code: "KeyQ" })); assert.equal(calls, 1); }); + + // Spanish layout as Chrome reports it: the "'" key sits on Minus and + // "º" on Backquote. + const fullSpanishMap = { + get(code) { + return { BracketRight: "+", Minus: "'", Backquote: "º", Equal: "¡" }[code]; + }, + }; + + const altGraph = (over) => ({ + getModifierState: (name) => name === "AltGraph", + ...over, + }); + + it("fires ? on Spanish, where it is Shift + the ' key", () => { + const event = { key: "?", code: "Minus", shiftKey: true }; + assert.equal(firedWithMap(fullSpanishMap, "?", event), 1); + }); + + it("matches the AltGr glyph before the key position (Linux)", () => { + // AltGr+º types "\" with no ctrl or alt on Linux. + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + const calls = []; + mousetrap.bind("\\", () => calls.push("\\")); + mousetrap.bind("º", () => calls.push("º")); + fire(target, makeKeyEvent(altGraph({ key: "\\", code: "Backquote" }))); + assert.deepEqual(calls, ["\\"]); + }); + + it("matches the AltGr glyph when AltGr reports ctrl+alt (Windows)", () => { + const event = altGraph({ key: "\\", code: "Backquote", ctrlKey: true, altKey: true }); + assert.equal(firedWithMap(fullSpanishMap, "\\", event), 1); + }); + + it("records the AltGr glyph, not the key position", () => { + const { mousetrap } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + const event = makeKeyEvent(altGraph({ key: "\\", code: "Backquote" })); + assert.equal(mousetrap.keyForRecording(event), "\\"); + }); + + it("drops a map that disagrees with an unmodified punctuation key", () => { + // Chrome on Linux keeps the map of the first layout (Spanish) while + // US is active: the "=" key types "=" but the map says "¡". + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + const calls = []; + mousetrap.bind("=", () => calls.push("=")); + mousetrap.bind("-", () => calls.push("-")); + fire(target, makeKeyEvent({ key: "=", code: "Equal" })); + fire(target, makeKeyEvent({ key: "-", code: "Minus" })); + assert.deepEqual(calls, ["=", "-"]); + assert.equal(mousetrap._layoutMap, null); + }); + + it("drops a disagreeing map before recording", () => { + const { mousetrap } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + assert.equal(mousetrap.keyForRecording(makeKeyEvent({ key: "=", code: "Equal" })), "="); + }); + + it("keeps a map that agrees with the typed key", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + fire(target, makeKeyEvent({ key: "+", code: "BracketRight" })); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + fire(target, makeKeyEvent({ key: "Dead", code: "Quote" })); + assert.equal(mousetrap._layoutMap, fullSpanishMap); + }); }); diff --git a/frontend/packages/mousetrap/test/modifiers.test.js b/frontend/packages/mousetrap/test/modifiers.test.js index 748b71feeb..a12453821c 100644 --- a/frontend/packages/mousetrap/test/modifiers.test.js +++ b/frontend/packages/mousetrap/test/modifiers.test.js @@ -31,7 +31,7 @@ describe("modifiers", () => { assert.equal(fired("shift+ctrl+alt+q", event), 1); }); - it("drops the phantom ctrl while AltGraph is active", () => { + it("drops the phantom ctrl and alt while AltGraph is active", () => { const altgr = { key: "]", code: "BracketRight", @@ -40,7 +40,38 @@ describe("modifiers", () => { getModifierState: (mod) => mod === "AltGraph", }; assert.equal(fired("ctrl+alt+]", altgr), 0); - assert.equal(fired("alt+]", altgr), 1); + assert.equal(fired("alt+]", altgr), 0); + assert.equal(fired("]", altgr), 1); + }); + + it("falls back to ctrl+alt for a letter that AltGr turns into a glyph", () => { + // Spanish layout on Windows: Ctrl+Alt+E types "€". + const event = { + key: "€", + code: "KeyE", + keyCode: 69, + ctrlKey: true, + altKey: true, + getModifierState: (mod) => mod === "AltGraph", + }; + assert.equal(fired("ctrl+alt+e", event), 1); + assert.equal(fired("€", event), 1); + }); + + it("matches letters and digits by keyCode when a modifier changes the glyph", () => { + // Shift+1 types "!" on US and Spanish layouts. + assert.equal(fired("shift+1", { key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), 1); + // Spanish layout: Shift+0 types "=". + assert.equal(fired("shift+0", { key: "=", code: "Digit0", keyCode: 48, shiftKey: true }), 1); + // macOS: Option+M types "µ" and Option+E is a dead key. + assert.equal(fired("alt+m", { key: "µ", code: "KeyM", keyCode: 77, altKey: true }), 1); + assert.equal(fired("alt+e", { key: "Dead", code: "KeyE", keyCode: 69, altKey: true }), 1); + assert.equal(fired("alt+shift+5", { key: "%", code: "Digit5", keyCode: 53, altKey: true, shiftKey: true }), 1); + }); + + it("keeps AZERTY letters by layout, not by position", () => { + // AZERTY: the key at the US "W" position types "z" and has keyCode 90. + assert.equal(fired("ctrl+z", { key: "z", code: "KeyW", keyCode: 90, ctrlKey: true }), 1); }); it("keeps a real ctrl+alt without AltGraph", () => { diff --git a/frontend/packages/mousetrap/test/normalize.test.js b/frontend/packages/mousetrap/test/normalize.test.js index b91b5ff40d..3219a08458 100644 --- a/frontend/packages/mousetrap/test/normalize.test.js +++ b/frontend/packages/mousetrap/test/normalize.test.js @@ -54,30 +54,54 @@ describe("canonical key normalization", () => { it("resolves a shifted digit row key to the digit itself", () => { // US Shift+0 types ")", AZERTY Shift+0 types "0": shift+0 must mean - // the physical digit key on every layout (production keyCode behavior). - assert.equal(fired("shift+0", { key: ")", code: "Digit0", shiftKey: true }), 1); - assert.equal(fired("shift+1", { key: "!", code: "Digit1", shiftKey: true }), 1); - assert.equal(fired("shift+2", { key: "@", code: "Digit2", shiftKey: true }), 1); - assert.equal(fired("shift+8", { key: "*", code: "Digit8", shiftKey: true }), 1); - // ...so the shifted glyphs are not bindable tokens for digit codes. - assert.equal(fired("shift+)", { key: ")", code: "Digit0", shiftKey: true }), 0); - assert.equal(fired("shift+*", { key: "*", code: "Digit8", shiftKey: true }), 0); + // the physical digit key on every layout, named by keyCode. + assert.equal(fired("shift+0", { key: ")", code: "Digit0", keyCode: 48, shiftKey: true }), 1); + assert.equal(fired("shift+1", { key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), 1); + assert.equal(fired("shift+2", { key: "@", code: "Digit2", keyCode: 50, shiftKey: true }), 1); + assert.equal(fired("shift+8", { key: "*", code: "Digit8", keyCode: 56, shiftKey: true }), 1); // AZERTY shifted digits already type the digit: unchanged. - assert.equal(fired("shift+0", { key: "0", code: "Digit0", shiftKey: true }), 1); + assert.equal(fired("shift+0", { key: "0", code: "Digit0", keyCode: 48, shiftKey: true }), 1); // Unshifted digits are untouched. - assert.equal(fired("0", { key: "0", code: "Digit0" }), 1); - assert.equal(fired("&", { key: "&", code: "Digit1" }), 1); + assert.equal(fired("0", { key: "0", code: "Digit0", keyCode: 48 }), 1); }); - it("without a layout map, punctuation resolves by glyph", () => { + it("prefers the digit over the shifted glyph when both are bound", () => { + const { mousetrap, target } = freshInstance(); + const calls = []; + mousetrap.bind("shift+0", () => calls.push("shift+0")); + mousetrap.bind("shift+)", () => calls.push("shift+)")); + fire(target, makeKeyEvent({ key: ")", code: "Digit0", keyCode: 48, shiftKey: true })); + assert.deepEqual(calls, ["shift+0"]); + }); + + it("without a layout map, punctuation resolves by US position or by glyph", () => { // Spanish layout: Shift + the physical "+" key (BracketRight) types "*". + assert.equal(fired("shift+]", { key: "*", code: "BracketRight", shiftKey: true }), 1); assert.equal(fired("shift+*", { key: "*", code: "BracketRight", shiftKey: true }), 1); - assert.equal(fired("shift+]", { key: "*", code: "BracketRight", shiftKey: true }), 0); assert.equal(fired("+", { key: "+", code: "BracketRight" }), 1); - // US layout: Shift + BracketRight types "}". + // US layout: Shift + BracketRight types "}"; the glyph still matches. assert.equal(fired("shift+}", { key: "}", code: "BracketRight", shiftKey: true }), 1); - assert.equal(fired("shift+]", { key: "}", code: "BracketRight", shiftKey: true }), 0); assert.equal(fired("?", { key: "?", code: "Slash" }), 1); + // US layout: Shift + Comma types "<". + assert.equal(fired("shift+,", { key: "<", code: "Comma", shiftKey: true }), 1); + assert.equal(fired("ctrl+shift+.", { key: ">", code: "Period", ctrlKey: true, shiftKey: true }), 1); + }); + + it("matches a glyph binding without the Shift the glyph needs", () => { + // US layout: "?" is Shift+Slash and "+" is Shift+Equal. + assert.equal(fired("?", { key: "?", code: "Slash", shiftKey: true }), 1); + assert.equal(fired("+", { key: "+", code: "Equal", shiftKey: true }), 1); + // Spanish layout: "?" is Shift+Minus. + assert.equal(fired("?", { key: "?", code: "Minus", shiftKey: true }), 1); + }); + + it("dispatches only the most specific match", () => { + const { mousetrap, target } = freshInstance(); + const calls = []; + mousetrap.bind("shift+/", () => calls.push("shift+/")); + mousetrap.bind("?", () => calls.push("?")); + fire(target, makeKeyEvent({ key: "?", code: "Slash", shiftKey: true })); + assert.deepEqual(calls, ["shift+/"]); }); it("lowercases function keys", () => { @@ -107,3 +131,60 @@ describe("canonical key normalization", () => { assert.equal(fired("ñ", { key: "Ñ", code: "Semicolon" }), 1); }); }); + +describe("keyForRecording", () => { + const record = (eventOver, layoutMap = null) => { + const { mousetrap } = freshInstance(); + mousetrap.setLayoutMap(layoutMap); + return mousetrap.keyForRecording(makeKeyEvent(eventOver)); + }; + const spanishMap = { get: (code) => ({ BracketRight: "+", Minus: "'" })[code] }; + + it("records letters and digits with a modifier by keyCode", () => { + assert.equal(record({ key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), "1"); + assert.equal(record({ key: "µ", code: "KeyM", keyCode: 77, altKey: true }), "m"); + assert.equal(record({ key: "Dead", code: "KeyE", keyCode: 69, altKey: true }), "e"); + }); + + it("records punctuation by position when there is a layout map", () => { + assert.equal(record({ key: "*", code: "BracketRight", shiftKey: true }, spanishMap), "+"); + assert.equal(record({ key: "+", code: "BracketRight" }, spanishMap), "+"); + }); + + it("records the typed glyph when there is no layout map", () => { + assert.equal(record({ key: "+", code: "BracketRight" }), "+"); + assert.equal(record({ key: "*", code: "BracketRight", shiftKey: true }), "*"); + }); + + it("falls back to the US position for a dead key without a map", () => { + assert.equal(record({ key: "Dead", code: "Quote" }), "'"); + }); + + it("returns null for a modifier pressed alone", () => { + assert.equal(record({ key: "Shift", code: "ShiftLeft", shiftKey: true }), null); + }); + + it("records a key the dispatcher then matches", () => { + const cases = [ + [{ key: "!", code: "Digit1", keyCode: 49, shiftKey: true }, null], + [{ key: "µ", code: "KeyM", keyCode: 77, altKey: true }, null], + [{ key: "*", code: "BracketRight", shiftKey: true }, null], + [{ key: "*", code: "BracketRight", shiftKey: true }, spanishMap], + [{ key: "?", code: "Minus", shiftKey: true, ctrlKey: true }, null], + [{ key: "?", code: "Minus", shiftKey: true, ctrlKey: true }, spanishMap], + ]; + for (const [eventOver, map] of cases) { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(map); + const event = makeKeyEvent(eventOver); + const mods = ["ctrl", "alt", "shift"].filter((m) => event[m + "Key"]); + const combo = [...mods, mousetrap.keyForRecording(event)].join("+"); + let calls = 0; + mousetrap.bind(combo.replace(/\+\+$/, "++"), () => { + calls++; + }); + fire(target, event); + assert.equal(calls, 1, `${combo} should fire`); + } + }); +}); diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt index ef5db91657..009f4c1c4e 100644 --- a/frontend/scripts/check-translations/words.es.txt +++ b/frontend/scripts/check-translations/words.es.txt @@ -280,7 +280,6 @@ lectores letras manifiesto menos -navegadores nosotros otras pero diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 9a045d4995..be6d97068b 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -17,36 +17,18 @@ (log/set-level! :warn) -;; Keyboard layout map shared by the shortcut recorder -;; (`app.main.ui.shortcuts`) and the Mousetrap dispatcher: punctuation -;; resolves by physical position through this map, so recording and -;; dispatch use the same token. `nil` until `getLayoutMap` resolves (or -;; when the browser has no keyboard API); both sides then fall back to -;; the typed glyph, which keeps them consistent with and without a map. -(defonce layout-map (atom nil)) - -(defn layout-map-supported? - "Synchronous keyboard-layout API probe: true when the browser exposes - navigator.keyboard.getLayoutMap (Chromium). Punctuation then resolves - by physical position; without it both sides fall back to the typed - glyph, so Shift+punctuation shortcuts may not fire." - ([] (layout-map-supported? - (when (exists? js/navigator) js/navigator))) - ([navigator] - (let [keyboard (when (some? navigator) (.-keyboard ^js navigator))] - (boolean - (and (some? keyboard) - (exists? (.-getLayoutMap ^js keyboard))))))) - -(let [navigator (when (exists? js/navigator) js/navigator)] - (when (layout-map-supported? navigator) - (-> (.getLayoutMap ^js (.-keyboard ^js navigator)) - (.then (fn [m] - (cljs.core/reset! layout-map m) - (.setLayoutMap mousetrap m)) - (fn [_] - (cljs.core/reset! layout-map nil) - (.setLayoutMap mousetrap nil)))))) +;; Keyboard layout map for Mousetrap: punctuation resolves by physical +;; position through it. The shortcut recorder (`app.main.ui.shortcuts`) +;; asks Mousetrap for the key it stores, so both use the same map. Absent +;; until `getLayoutMap` resolves, and in browsers without the keyboard API +;; (Firefox, Safari). +(let [navigator (when (exists? js/navigator) js/navigator) + keyboard (when (some? navigator) (.-keyboard ^js navigator))] + (when (and (some? keyboard) + (exists? (.-getLayoutMap ^js keyboard))) + (-> (.getLayoutMap ^js keyboard) + (.then (fn [m] (.setLayoutMap mousetrap m)) + (fn [_] (.setLayoutMap mousetrap nil)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Helpers diff --git a/frontend/src/app/main/data/workspace/path.cljs b/frontend/src/app/main/data/workspace/path.cljs index de8decc1ba..636d253a2f 100644 --- a/frontend/src/app/main/data/workspace/path.cljs +++ b/frontend/src/app/main/data/workspace/path.cljs @@ -55,7 +55,6 @@ ;; Path tools (dm/export tools/make-curve) (dm/export tools/make-corner) -(dm/export tools/add-node) (dm/export tools/remove-node) (dm/export tools/delete-selected) (dm/export tools/delete-selected-with-segments) diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index 21f1c87960..fbe1483de6 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -57,14 +57,6 @@ :overwrite true :fn #(st/emit! (drp/change-edit-mode :draw))} - :add-node {:tooltip (ds/shift "+") - :label (fn [] (tr "shortcuts.add-node")) - :command ["shift++" "shift+="] - :show-command "shift++" - :subsections [:path-editor] - :section [:workspace] - :fn #(st/emit! (drp/add-node))} - :delete-node {:tooltip (ds/supr) :label (fn [] (tr "shortcuts.delete-node")) :command ["del" "backspace"] diff --git a/frontend/src/app/main/data/workspace/path/tools.cljs b/frontend/src/app/main/data/workspace/path/tools.cljs index fe25a9e13e..54fa954410 100644 --- a/frontend/src/app/main/data/workspace/path/tools.cljs +++ b/frontend/src/app/main/data/workspace/path/tools.cljs @@ -121,9 +121,6 @@ (fn [ht] (reduce #(assoc %1 %2 type) (or ht {}) nodes))))) state))))) -(defn add-node [] - (process-path-tool (fn [content points] (path/split-segments content points 0.5)))) - (defn remove-node "Removes nodes and heals the gap with a fitted curve." ([] diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 17246e5889..230a242a93 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -679,8 +679,7 @@ :hide-ui {:tooltip "\\" :label (fn [] (tr "shortcuts.hide-ui")) - :command ["\\" "alt+\\" "alt+º"] - :show-command "\\" + :command "\\" :subsections [:panels :basics] :section [:workspace :basics] :fn #(st/emit! (toggle-layout-flag :hide-ui))} @@ -717,8 +716,7 @@ :zoom-selected {:tooltip (ds/shift "2") :label (fn [] (tr "shortcuts.zoom-selected")) - :command ["shift+2" "shift+num2" "shift+'" "shift+\""] - :show-command "shift+2" + :command ["shift+2" "shift+num2"] :subsections [:zoom-workspace] :section [:workspace] :fn #(st/emit! dw/zoom-to-selected-shape)} diff --git a/frontend/src/app/main/ui/settings/shortcuts.cljs b/frontend/src/app/main/ui/settings/shortcuts.cljs index 841841ac5b..e1e9b884c0 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.cljs +++ b/frontend/src/app/main/ui/settings/shortcuts.cljs @@ -23,7 +23,6 @@ [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] - [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.empty-state :refer [empty-state*]] [app.main.ui.settings.import-shortcuts-diff-modal] [app.main.ui.settings.restore-shortcuts-modal] @@ -529,10 +528,6 @@ [:> icon* {:icon-id i/info :class (stl/css :shortcuts-description-icon)}] (tr "shortcuts.reload-hint")] - (when-not (ds/layout-map-supported?) - [:> context-notification* {:level :warning - :class (stl/css :layout-map-warning)} - (tr "shortcuts.layout-map-missing")]) [:> (mf/provider ctx/shortcuts-ctx) {:value shortcuts-ctx-value} [:div {:class (stl/css :shortcuts-content)} diff --git a/frontend/src/app/main/ui/settings/shortcuts.scss b/frontend/src/app/main/ui/settings/shortcuts.scss index c86ca879b5..522147f61e 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.scss +++ b/frontend/src/app/main/ui/settings/shortcuts.scss @@ -314,7 +314,3 @@ padding: var(--sp-s) 0; margin-block-end: var(--sp-l); } - -.layout-map-warning { - margin-block-end: var(--sp-m); -} diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 30aed17a2c..4166314a37 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.shortcuts (:require-macros [app.main.style :as stl]) (:require + ["@penpot/mousetrap$default" :as mousetrap] [app.common.data :as d] [app.common.data.macros :as dm] [app.config :as cf] @@ -34,10 +35,6 @@ (def ^:private modifier-keys #{"Control" "Shift" "Alt" "Meta"}) -(def ^:private punctuation-codes - #{"Minus" "Equal" "BracketLeft" "BracketRight" "Backslash" - "Semicolon" "Quote" "Backquote" "Comma" "Period" "Slash"}) - (def ^:private key-name-map {"ArrowUp" "up" "Up" "up" @@ -202,71 +199,16 @@ (fn? (.-getModifierState native))) (.getModifierState native "AltGraph")))))) -(defn- numpad-code? - [code] - (and (string? code) - (str/starts-with? code "Numpad") - (= 1 (count (subs code 6))))) - -(defn- numpad-alias - [code] - (let [suffix (subs code 6)] - (when (re-matches #"[0-9]" suffix) - (str "num" suffix)))) - -(defn- punctuation-alias - "Resolves punctuation by physical position through the shared layout - map (`app.main.data.shortcuts/layout-map`, also pushed into Mousetrap), - so recording and dispatch use the same token. Without a map the typed - glyph applies, which keeps both sides consistent." - [code key] - (let [mapped (when (some? @ds/layout-map) - (.get ^js @ds/layout-map code))] - (if (string? mapped) - (str/lower mapped) - (str/lower key)))) - -(defn- digit-code? - [code] - (and (string? code) - (boolean (re-matches #"Digit[0-9]" code)))) - -(defn- digit-alias - "Shifted digits resolve to the digit itself: with Shift held, Digit0 - types ) on US layouts but 0 on AZERTY, so shift+0 must mean the - physical digit key everywhere, like shift+z means z." - [^js event code key] - (when (and (.-shiftKey ^js event) - (digit-code? code) - (= 1 (count key))) - (subs code 5))) - (defn- normalize-key - "Normalizes `event.key` to the lowercase canonical token mousetrap - matches on. Letters and unshifted digits resolve by glyph (`a`, not - `q` on AZERTY); a digit row key held with Shift resolves to the digit - itself (`shift+0`, never `shift+)`); punctuation resolves by physical - position through the layout map, so `Shift+*` on a Spanish layout - records `shift++`, the primary key the user sees." + "Returns the key token Mousetrap matches for `event`, asking Mousetrap + itself (`keyForRecording`) so a recorded shortcut always fires: letters + and digits with a modifier by keyCode (`shift+1`, `alt+m`), punctuation + by position when the browser has a layout map, the typed glyph + otherwise." [^js event] - (let [native (event->native event) - key (.-key ^js event) - code (.-code ^js native)] + (let [key (.-key event)] (when (and (string? key) (not (contains? modifier-keys key))) - (cond - ;; Numpad digits keep their own `numN` bindings, like mousetrap. - (numpad-code? code) - (numpad-alias code) - - ;; Punctuation: primary key of the physical position. - (contains? punctuation-codes code) - (punctuation-alias code key) - - :else - (or (get key-name-map key) - ;; Shifted digit row: the digit itself, on every layout. - (digit-alias event code key) - (str/lower key)))))) + (.keyForRecording ^js mousetrap (event->native event))))) (defn- event->modifier-parts [^js event] @@ -281,7 +223,8 @@ (cf/check-platform? :macos)) (conj "command") - (.-altKey event) + (and (.-altKey event) + (not alt-graph?)) (conj "alt") (.-shiftKey event) diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs index 3ed7e035c9..f2df889ef0 100644 --- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs @@ -1,5 +1,6 @@ (ns frontend-tests.ui.settings-shortcuts-test (:require + ["@penpot/mousetrap$default" :as mousetrap] [app.config :as cf] [app.main.data.dashboard.shortcuts :as dsc] [app.main.data.profile :as du] @@ -495,17 +496,25 @@ (defn- mock-wrapped-keydown "Simulates a goog.events.BrowserEvent-wrapped keydown: `key` and modifiers - are forwarded directly, and `code` is only reachable via `getBrowserEvent`. - `key` is passed through as-is (not defaulted) so callers can simulate a - missing `event.key` with `:key nil` - substituting \"\" would defeat that, - since an empty string is truthy in ClojureScript." - [{:keys [key code shift ctrl alt meta]}] - #js {:key key - :shiftKey (boolean shift) - :ctrlKey (boolean ctrl) - :altKey (boolean alt) - :metaKey (boolean meta) - :getBrowserEvent (fn [] #js {:code code})}) + are forwarded directly, and the native event behind `getBrowserEvent` + carries every field (`code` and `keyCode` only live there). `key` is + passed through as-is (not defaulted) so callers can simulate a missing + `event.key` with `:key nil` - substituting \"\" would defeat that, since + an empty string is truthy in ClojureScript." + [{:keys [key code key-code shift ctrl alt meta]}] + (let [native #js {:key key + :code code + :keyCode key-code + :shiftKey (boolean shift) + :ctrlKey (boolean ctrl) + :altKey (boolean alt) + :metaKey (boolean meta)}] + #js {:key key + :shiftKey (boolean shift) + :ctrlKey (boolean ctrl) + :altKey (boolean alt) + :metaKey (boolean meta) + :getBrowserEvent (fn [] native)})) (defn- mock-native-keydown "Simulates a plain, unwrapped native KeyboardEvent (no getBrowserEvent), @@ -531,7 +540,7 @@ even when the typed glyph differs" (with-redefs [cf/check-platform? (constantly false)] (try - (reset! ds/layout-map #js {:get (fn [code] (when (= code "Period") "."))}) + (.setLayoutMap mousetrap #js {:get (fn [code] (when (= code "Period") "."))}) (t/is (= "shift+." (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key ">" :code "Period" :shift true})))) @@ -539,7 +548,7 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key ":" :code "Period" :shift true})))) (finally - (reset! ds/layout-map nil)))))) + (.setLayoutMap mousetrap nil)))))) (t/deftest mousetrap-shift-period-on-spanish-iso-layout (t/testing "Spanish (ISO) layout without a layout map: Shift + Period @@ -633,28 +642,27 @@ (t/deftest mousetrap-shifted-digit-resolves-to-digit (t/testing "a digit row key held with Shift records the digit itself: US Shift+0 types \")\" but records \"shift+0\", so the physical - digit key means the same on every layout (production keyCode - behavior)" + digit key means the same on every layout (named by keyCode)" (with-redefs [cf/check-platform? (constantly false)] (t/is (= "shift+0" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key ")" :code "Digit0" :shift true})))) + (mock-wrapped-keydown {:key ")" :code "Digit0" :key-code 48 :shift true})))) (t/is (= "shift+1" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "!" :code "Digit1" :shift true})))) + (mock-wrapped-keydown {:key "!" :code "Digit1" :key-code 49 :shift true})))) (t/is (= "shift+2" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "@" :code "Digit2" :shift true})))) + (mock-wrapped-keydown {:key "@" :code "Digit2" :key-code 50 :shift true})))) (t/is (= "shift+8" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "*" :code "Digit8" :shift true}))))))) + (mock-wrapped-keydown {:key "*" :code "Digit8" :key-code 56 :shift true}))))))) (t/deftest mousetrap-shifted-digit-already-digit-unchanged (t/testing "AZERTY Shift+Digit0 already types \"0\": unchanged" (with-redefs [cf/check-platform? (constantly false)] (t/is (= "shift+0" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "0" :code "Digit0" :shift true}))))))) + (mock-wrapped-keydown {:key "0" :code "Digit0" :key-code 48 :shift true}))))))) (t/deftest mousetrap-space-and-named-keys (t/testing "\" \" records as space and named keys stay lowercase" @@ -672,15 +680,15 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "Delete" :code "Delete"}))))))) -(t/deftest mousetrap-altgr-drops-phantom-ctrl - (t/testing "Spanish AltGr++] types \"]\" with ctrl+alt flags, but the - phantom ctrl is dropped so no ctrl+alt shortcut fires" +(t/deftest mousetrap-altgr-drops-phantom-modifiers + (t/testing "Spanish AltGr+] types \"]\" with ctrl+alt flags, but both + phantom modifiers are dropped and it records the glyph" (with-redefs [cf/check-platform? (constantly false)] (let [event #js {:key "]" :shiftKey false :ctrlKey true :altKey true :metaKey false :getModifierState (fn [m] (= m "AltGraph")) :getBrowserEvent (fn [] nil)}] - (t/is (= "alt+]" + (t/is (= "]" (#'ui-shortcuts/keyboard-event->mousetrap event))))))) (t/deftest mousetrap-real-ctrl-alt-keeps-both @@ -699,7 +707,7 @@ what the user sees and what Mousetrap matches" (with-redefs [cf/check-platform? (constantly false)] (try - (reset! ds/layout-map #js {:get (fn [code] (when (= code "BracketRight") "+"))}) + (.setLayoutMap mousetrap #js {:get (fn [code] (when (= code "BracketRight") "+"))}) (t/is (= "shift++" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "*" :code "BracketRight" :shift true})))) @@ -707,7 +715,7 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "+" :code "BracketRight"})))) (finally - (reset! ds/layout-map nil)))))) + (.setLayoutMap mousetrap nil)))))) (t/deftest mousetrap-spanish-shift-plus-without-layout-map-records-glyph (t/testing "Spanish layout without a layout map: Shift + the physical @@ -725,11 +733,24 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "+" :code "BracketRight"}))))))) -(t/deftest layout-map-supported-matches-keyboard-api - (t/testing "false without a navigator or without a keyboard API" - (t/is (false? (ds/layout-map-supported? nil))) - (t/is (false? (ds/layout-map-supported? #js {}))) - (t/is (false? (ds/layout-map-supported? #js {:keyboard #js {}})))) - (t/testing "true when getLayoutMap exists (Chromium)" - (t/is (true? (ds/layout-map-supported? - #js {:keyboard #js {:getLayoutMap (fn [] nil)}}))))) +(t/deftest mousetrap-shift-digit-records-the-digit + (t/testing "Shift+1 types \"!\" but records \"shift+1\", the key the user + sees and the default zoom shortcuts use" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "shift+1" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "!" :code "Digit1" :key-code 49 :shift true})))) + (t/is (= "shift+0" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "=" :code "Digit0" :key-code 48 :shift true}))))))) + +(t/deftest mousetrap-option-letter-records-the-letter + (t/testing "macOS Option+M types \"µ\" and Option+E is a dead key, but + both record the letter" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "alt+m" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "µ" :code "KeyM" :key-code 77 :alt true})))) + (t/is (= "alt+e" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "Dead" :code "KeyE" :key-code 69 :alt true}))))))) diff --git a/frontend/translations/ar.po b/frontend/translations/ar.po index a027c7f3dd..0b07c80ca7 100644 --- a/frontend/translations/ar.po +++ b/frontend/translations/ar.po @@ -1727,10 +1727,6 @@ msgstr "مُختلط" msgid "shortcuts.add-comment" msgstr "تعليقات" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "إضافة عقدة" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "محاذاة لأسفل" diff --git a/frontend/translations/ca.po b/frontend/translations/ca.po index 900e2048d1..005dc3b908 100644 --- a/frontend/translations/ca.po +++ b/frontend/translations/ca.po @@ -4745,10 +4745,6 @@ msgstr "Selecciona els elements que utilitzen aquest estil" msgid "shortcuts.add-comment" msgstr "Comentaris" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Afegeix node" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinea a baix" diff --git a/frontend/translations/cs.po b/frontend/translations/cs.po index 6807d634eb..5760d638f1 100644 --- a/frontend/translations/cs.po +++ b/frontend/translations/cs.po @@ -2597,10 +2597,6 @@ msgstr "Vyberte položky pomocí tohoto stylu" msgid "shortcuts.add-comment" msgstr "Komentáře" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Přidat uzel" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Zarovnat dolů" diff --git a/frontend/translations/de.po b/frontend/translations/de.po index b535d2d859..b04b506455 100644 --- a/frontend/translations/de.po +++ b/frontend/translations/de.po @@ -3507,10 +3507,6 @@ msgstr "Elemente mit diesem Stil auswählen" msgid "shortcuts.add-comment" msgstr "Kommentare" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Punkt hinzufügen" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Unten ausrichten" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2f4810c536..861862dcc3 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -4761,10 +4761,6 @@ msgstr "Select items using this style" msgid "shortcuts.add-comment" msgstr "Comments" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Add node" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Align bottom" @@ -5041,13 +5037,6 @@ msgstr "Join nodes" msgid "shortcuts.key-combo" msgstr "Press the new keys" -#: src/app/main/ui/settings/shortcuts.cljs:484 -msgid "shortcuts.layout-map-missing" -msgstr "" -"This browser does not expose the keyboard layout, so shortcuts that " -"combine Shift with punctuation (for example Shift+,) may not work here. " -"They keep working in Chromium browsers." - #: src/app/main/data/workspace/text/shortcuts.cljs:249 msgid "shortcuts.line-through" msgstr "Toggle line through" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 137798f51f..5ebc56901b 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -4815,10 +4815,6 @@ msgstr "Seleccionar elementos que usan este estilo" msgid "shortcuts.add-comment" msgstr "Añadir comentarios" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Añadir nodo" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinear abajo" @@ -5087,13 +5083,6 @@ msgstr "Unir nodos" msgid "shortcuts.key-combo" msgstr "Pulsa las nuevas teclas" -#: src/app/main/ui/settings/shortcuts.cljs:484 -msgid "shortcuts.layout-map-missing" -msgstr "" -"Este navegador no expone el mapa de teclado, así que los atajos que " -"combinan Shift con puntuación (por ejemplo Shift+,) pueden no funcionar " -"aquí. Siguen funcionando en navegadores Chromium." - #: src/app/main/data/workspace/text/shortcuts.cljs:249 msgid "shortcuts.line-through" msgstr "Alternar tachado" diff --git a/frontend/translations/eu.po b/frontend/translations/eu.po index 78a7089a5e..4594f82431 100644 --- a/frontend/translations/eu.po +++ b/frontend/translations/eu.po @@ -1862,10 +1862,6 @@ msgstr "Batzuk" msgid "shortcuts.add-comment" msgstr "Gehitu iruzkinak" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Gehitu nodoa" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Lerrokatu behean" diff --git a/frontend/translations/fa.po b/frontend/translations/fa.po index bf01868ef6..a8c6460fba 100644 --- a/frontend/translations/fa.po +++ b/frontend/translations/fa.po @@ -4678,10 +4678,6 @@ msgstr "انتخاب موارد با استفاده از این سبک" msgid "shortcuts.add-comment" msgstr "نظرات" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "افزودن گره" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "تراز به پایین" diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index 5686f05331..63c56a4fc0 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -4785,10 +4785,6 @@ msgstr "Sélectionnez les éléments qui utilisent ce style" msgid "shortcuts.add-comment" msgstr "Commentaires" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Ajouter un nœud" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Aligner en bas" diff --git a/frontend/translations/fr_CA.po b/frontend/translations/fr_CA.po index 79fb1a0323..e5b34259e6 100644 --- a/frontend/translations/fr_CA.po +++ b/frontend/translations/fr_CA.po @@ -4509,10 +4509,6 @@ msgstr "Sélectionner les éléments avec ce style" msgid "shortcuts.add-comment" msgstr "Commentaires" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Ajouter un nœud" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Aligner en bas" diff --git a/frontend/translations/ha.po b/frontend/translations/ha.po index dbc38704ca..6382d0fa45 100644 --- a/frontend/translations/ha.po +++ b/frontend/translations/ha.po @@ -1957,10 +1957,6 @@ msgstr "gauraya" msgid "shortcuts.add-comment" msgstr "Bayyana Ra'ayi" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "kara Girma" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "tsarin kasa" diff --git a/frontend/translations/he.po b/frontend/translations/he.po index 0e9eabca97..38e7e83532 100644 --- a/frontend/translations/he.po +++ b/frontend/translations/he.po @@ -3967,10 +3967,6 @@ msgstr "בחירת פריטים שמשתמשים בסגנון הזה" msgid "shortcuts.add-comment" msgstr "הערות" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "הוספת מפרק" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "יישור לתחתית" diff --git a/frontend/translations/hi.po b/frontend/translations/hi.po index b55d4239cd..7d0d01d99d 100644 --- a/frontend/translations/hi.po +++ b/frontend/translations/hi.po @@ -3362,10 +3362,6 @@ msgstr "इस शैली का उपयोग करके आइटम च msgid "shortcuts.add-comment" msgstr "टिप्पणियाँ" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "नोड जोड़ें" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "नीचे संरेखित करें" diff --git a/frontend/translations/hr.po b/frontend/translations/hr.po index b3c94b2d6a..93081b3c64 100644 --- a/frontend/translations/hr.po +++ b/frontend/translations/hr.po @@ -2596,10 +2596,6 @@ msgstr "Odaberite stavke koristeći ovaj stil" msgid "shortcuts.add-comment" msgstr "Komentari" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Dodaj čvor" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Poravnaj dno" diff --git a/frontend/translations/id.po b/frontend/translations/id.po index 03058d1683..250ff26b5f 100644 --- a/frontend/translations/id.po +++ b/frontend/translations/id.po @@ -2805,10 +2805,6 @@ msgstr "Pilih item menggunakan gaya ini" msgid "shortcuts.add-comment" msgstr "Komentar" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Tambahkan simpul" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Sesuaikan ke bawah" diff --git a/frontend/translations/it.po b/frontend/translations/it.po index 3512c5d007..3fa5b6d7b9 100644 --- a/frontend/translations/it.po +++ b/frontend/translations/it.po @@ -3875,10 +3875,6 @@ msgstr "Seleziona gli elementi che utilizzano questo stile" msgid "shortcuts.add-comment" msgstr "Commenti" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Aggiungi nodo" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Allinea in basso" diff --git a/frontend/translations/jpn_JP.po b/frontend/translations/jpn_JP.po index 7b0a3d78b9..65ee25f35b 100644 --- a/frontend/translations/jpn_JP.po +++ b/frontend/translations/jpn_JP.po @@ -4521,10 +4521,6 @@ msgstr "このスタイルを使用している要素を選択" msgid "shortcuts.add-comment" msgstr "コメント" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "ノードを追加" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "下揃え" diff --git a/frontend/translations/ko.po b/frontend/translations/ko.po index fdb131f37d..e3beca6dcc 100644 --- a/frontend/translations/ko.po +++ b/frontend/translations/ko.po @@ -3200,10 +3200,6 @@ msgstr "이 스타일을 사용하는 항목 선택" msgid "shortcuts.add-comment" msgstr "댓글" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "노드 추가" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "아래 정렬" diff --git a/frontend/translations/lv.po b/frontend/translations/lv.po index 6f2f7b95a9..33ab93be32 100644 --- a/frontend/translations/lv.po +++ b/frontend/translations/lv.po @@ -3106,10 +3106,6 @@ msgstr "Atlasīt vienumus, kas izmanto šo stilu" msgid "shortcuts.add-comment" msgstr "Komentāri" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Pievienot mezglu" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Līdzināt pie apakšas" diff --git a/frontend/translations/ms.po b/frontend/translations/ms.po index 4a858ea689..42d2623a31 100644 --- a/frontend/translations/ms.po +++ b/frontend/translations/ms.po @@ -2019,10 +2019,6 @@ msgstr "Bercampur" msgid "shortcuts.add-comment" msgstr "Komen" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Tambah nota" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Sejajarkan bahagian bawah" diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 3d55474017..678e93c364 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -4755,10 +4755,6 @@ msgstr "Selecteer items in deze stijl" msgid "shortcuts.add-comment" msgstr "Commentaar" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Knooppunt toevoegen" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Onderaan uitlijnen" diff --git a/frontend/translations/pl.po b/frontend/translations/pl.po index c3afe44ee7..352c7e9eec 100644 --- a/frontend/translations/pl.po +++ b/frontend/translations/pl.po @@ -1856,10 +1856,6 @@ msgstr "Mieszane" msgid "shortcuts.add-comment" msgstr "Komentarze" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Dodaj węzeł" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Wyrównaj do dołu" diff --git a/frontend/translations/pt_BR.po b/frontend/translations/pt_BR.po index 3c27a3205f..0847bf55bf 100644 --- a/frontend/translations/pt_BR.po +++ b/frontend/translations/pt_BR.po @@ -2511,10 +2511,6 @@ msgstr "Misto" msgid "shortcuts.add-comment" msgstr "Comentários" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Adicionar ponto" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinhar a base" diff --git a/frontend/translations/pt_PT.po b/frontend/translations/pt_PT.po index 3140b7a1a6..57f7cbd423 100644 --- a/frontend/translations/pt_PT.po +++ b/frontend/translations/pt_PT.po @@ -2657,10 +2657,6 @@ msgstr "Selecionar itens que usem este estilo" msgid "shortcuts.add-comment" msgstr "Comentários" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Adicionar nó" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinhar abaixo" diff --git a/frontend/translations/ro.po b/frontend/translations/ro.po index d4b4fc6751..2581de553f 100644 --- a/frontend/translations/ro.po +++ b/frontend/translations/ro.po @@ -3189,10 +3189,6 @@ msgstr "Alege elemente folosind acest stil" msgid "shortcuts.add-comment" msgstr "Comentarii" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Adaugă nod" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Aliniază jos" diff --git a/frontend/translations/ru.po b/frontend/translations/ru.po index 72cd5b5d4b..24fcebf98a 100644 --- a/frontend/translations/ru.po +++ b/frontend/translations/ru.po @@ -3227,10 +3227,6 @@ msgstr "Выбрать элементы, использующие этот ст msgid "shortcuts.add-comment" msgstr "Комментарии" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Добавить узел" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Выровнять по нижнему краю" diff --git a/frontend/translations/sr.po b/frontend/translations/sr.po index 589b4d6c88..75ebabcdde 100644 --- a/frontend/translations/sr.po +++ b/frontend/translations/sr.po @@ -2200,10 +2200,6 @@ msgstr "Изаберите ставке користећи овај стил" msgid "shortcuts.add-comment" msgstr "Коментари" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Додај чвор" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Поравнај на дно" diff --git a/frontend/translations/sv.po b/frontend/translations/sv.po index 293b654895..c749f67233 100644 --- a/frontend/translations/sv.po +++ b/frontend/translations/sv.po @@ -4706,10 +4706,6 @@ msgstr "Välj objekt med denna stil" msgid "shortcuts.add-comment" msgstr "Kommentarer" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Lägg till nod" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Justera botten" diff --git a/frontend/translations/tr.po b/frontend/translations/tr.po index adbc51754c..853e76ab7a 100644 --- a/frontend/translations/tr.po +++ b/frontend/translations/tr.po @@ -4702,10 +4702,6 @@ msgstr "Bu biçimi kullanan ögeleri seç" msgid "shortcuts.add-comment" msgstr "Yorumlar" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Düğüm ekle" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alta hizala" diff --git a/frontend/translations/ukr_UA.po b/frontend/translations/ukr_UA.po index e716947918..519ce16bf8 100644 --- a/frontend/translations/ukr_UA.po +++ b/frontend/translations/ukr_UA.po @@ -3581,10 +3581,6 @@ msgstr "Оберіть елементи, використовуючи цей с msgid "shortcuts.add-comment" msgstr "Коментарі" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Додати вузол" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Вирівняти по низу" diff --git a/frontend/translations/yo.po b/frontend/translations/yo.po index 1f8e5ab1b4..23d4d7dfc4 100644 --- a/frontend/translations/yo.po +++ b/frontend/translations/yo.po @@ -1844,10 +1844,6 @@ msgstr "Dàárú" msgid "shortcuts.add-comment" msgstr "Ọ́rọ̀íwòye" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Fí ípàdé kùn" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Sópọ sí ísàle" diff --git a/frontend/translations/zh_CN.po b/frontend/translations/zh_CN.po index 94376eaabc..f17366656f 100644 --- a/frontend/translations/zh_CN.po +++ b/frontend/translations/zh_CN.po @@ -3948,10 +3948,6 @@ msgstr "选择使用此样式的项目" msgid "shortcuts.add-comment" msgstr "评论" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "添加节点" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "底部对齐" diff --git a/frontend/translations/zh_Hant.po b/frontend/translations/zh_Hant.po index ebb8577137..56f41a99d3 100644 --- a/frontend/translations/zh_Hant.po +++ b/frontend/translations/zh_Hant.po @@ -4443,10 +4443,6 @@ msgstr "選取使用此樣式的項目" msgid "shortcuts.add-comment" msgstr "留言" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "新增節點" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "靠下對齊" From 55ad7d1da8ac4140a489efb7c318b7f807ad4762 Mon Sep 17 00:00:00 2001 From: Juan de la Cruz Date: Mon, 26 Oct 2026 12:11:12 +0100 Subject: [PATCH 03/65] :sparkles: Add new slides content for 2.19 release (#12185) * :sparkles: Add new slides content for 2.19 release * :tada: Update user settings instructions in v2_19.cljs Signed-off-by: Juan de la Cruz --------- Signed-off-by: Juan de la Cruz --- .../resources/images/features/2.19-paths.jpg | Bin 0 -> 142074 bytes .../images/features/2.19-per-side-strokes.jpg | Bin 0 -> 136300 bytes .../images/features/2.19-performance.jpg | Bin 0 -> 183242 bytes .../images/features/2.19-shortcuts.jpg | Bin 0 -> 115160 bytes .../images/features/2.19-slide-0.jpg | Bin 0 -> 96740 bytes .../resources/images/features/2.19-tokens.jpg | Bin 0 -> 126419 bytes frontend/src/app/main/ui/releases.cljs | 3 +- frontend/src/app/main/ui/releases/v2_19.cljs | 208 ++++++++++++++++++ frontend/src/app/main/ui/releases/v2_19.scss | 7 + 9 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 frontend/resources/images/features/2.19-paths.jpg create mode 100644 frontend/resources/images/features/2.19-per-side-strokes.jpg create mode 100644 frontend/resources/images/features/2.19-performance.jpg create mode 100644 frontend/resources/images/features/2.19-shortcuts.jpg create mode 100644 frontend/resources/images/features/2.19-slide-0.jpg create mode 100644 frontend/resources/images/features/2.19-tokens.jpg create mode 100644 frontend/src/app/main/ui/releases/v2_19.cljs create mode 100644 frontend/src/app/main/ui/releases/v2_19.scss diff --git a/frontend/resources/images/features/2.19-paths.jpg b/frontend/resources/images/features/2.19-paths.jpg new file mode 100644 index 0000000000000000000000000000000000000000..72ed1c9e43d02da69afbba6f50fd247d3c043d4a GIT binary patch literal 142074 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 J8SU51{{ZOd)iwYC literal 0 HcmV?d00001 diff --git a/frontend/resources/images/features/2.19-per-side-strokes.jpg b/frontend/resources/images/features/2.19-per-side-strokes.jpg new file mode 100644 index 0000000000000000000000000000000000000000..aebb4bb26396b139fbe3f8df3b589f91cede261d GIT binary patch literal 136300 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%0DPY8C_uGYG`rG<4kz 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 YY5*;gmqInHT3U}rN%!bn@DNIDM*J-;+E_?T4 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 literal 0 HcmV?d00001 diff --git a/frontend/resources/images/features/2.19-performance.jpg b/frontend/resources/images/features/2.19-performance.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dbdcad2aa62e0eb6aaf650f5dbe98a5c49d37158 GIT binary patch literal 183242 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 literal 0 HcmV?d00001 diff --git a/frontend/resources/images/features/2.19-shortcuts.jpg b/frontend/resources/images/features/2.19-shortcuts.jpg new file mode 100644 index 0000000000000000000000000000000000000000..39e20afca40c5e0f1caaeb2abb74d9075948b18c GIT binary patch literal 115160 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

2b}74v3(_{Bq`J8(_t|aPV-8 z8QK?SK_jQR?CumP%Ak#VkDbVt=*1J|o|M-6SP!|;`zBc?h9yzBC7X+uc=eHN=4Wm& zd(S^E16PRdIowg1Devr_U-4?E--2##$*Si@Rmkg>MSG`}l@8^O2v+YOsX)<&zL9B| z1b}8JG>yaa8oE(ha36S}S2!-WZGFZTg}E}N$^^0YrTYz%sYE$>`a5jqbR(r;#E=N< z!t0LLnoEX>p4@XR43y6mZESz^x>1z0V5Y?M@`GLcDGOv&u-YZGg>e>M&RUUnI~-)< zAKJW94-N3nBh`>Upq|*zD<|E`!!0(sVh&NV(eZ@5F%j&~Z%Jr9#ny*t|Z-SIfnuqqF=Xq)ir{!@V=LE`Xdz10^=++%I_<)FHiUQkU8GbYyL7Qv(IKA`Z!k6gMkhnjzQEEnukgwPJ6BWKWL?WiUP0sc zgZ(C(itOyQ+31YJg+sLk6E+_aQ%^W%`8lRIc{!Mhqh(Q4PCHOdKU+ag*vs|dwY}XK zmkjc?eq~Oyigdb8viqxb(r(s%veuBR^(vO7*w-D(iuu*v>A1x)99k4=^s+R{hDXVl z-fE!%*4W605)zh*0#od(1`;m96Sm1dqU-J7T;it4w zSI-CB@IuJ59U-9^24#a$Y3t=@AAK{2tltB{#|e2$Wqc15ZF8?C5s+ECTj7cT5xV(5-Ea;D&PiMG?ZO*@}!Fc9SV;t2NXK9Rs}h{$yDrnd-^=v8q4Y@0sir zN8j`_HX_@e3T&}HEHsqYSgmdY7kVWA&+NqpW5<|LJ?Y?$ve{E$1~86l+^gKlH4jRZs4{e(%sdH2zDN(X$( zju(hXPR`mG)+!$LvOarpbgz-TLlL_y+&qlx zjF)DCe~hdwXH`C!N+w{Wx2iJ1D#<+;Z~69{dZqAXmAzH#?0V-no~ zn^DjXCe0K8HC;$LT`4o>(ONKj?3v#|u1N5{8C_)3@M17iE`k=J0kHIh85_RNiV@aq z@_@2@8PbEX-m6!hwOvfoGP1=8W;%$#Ai18wwLe3!QVlaj%(b}jGUq*YuxP&7<{ams zjMOtse|JbLYp5^bF_R6KoNSz>ODi~@q%fmRH8epy?I>S)Km^s-`L?sPs9QDV6lVPp2D`mOE#70s}6iwA|FO!+aQge5A* zW*Pt@^=BLg%C60wYT+x}f8W1lieM*O{4Cgqv9&)q(gUOU@qr%=mh?#%U*fuow(8>` zqvQsII4CZ=i3>qjtY+6k6yO$hHbM;+|928J}i?g^N zBifPY$Xx;6nM@nX&+X;xcRme);315Y&2Lm`<_sc5-{u;~_WB8j%knkKdu5p}Cg9^* z(9l|raTPU4q9Ta}w*TNRs(y9(qm)V-n_vUSXfI95%9J7)CJKmB z%)^6QwGNzzBTUnQZ92OIG7-@>72;)v93zb%RN3;O&bG-tk7EKk4Mtp-_bMp0hq*Hw z1lrc<$TIOEh_w}Cx9m5fjfSC-d9!7=1#Jft8V6K!cYQ}Pnq0UmGy0H0%48RIp$6^$~VMnqT*q2-MfJw*4Qx%TO7rTd4WYj zR)YzanHwTyU3FHA$>r$iM|)xGy4mH=b=(Omj|0M}T7hg51PYZq2l;$bZi)bMs^|^B ziZkzv4IlOF!ot`6(EfcCaFQ;WaHjNZ1Lu8E(FjnD_iaLsjU|D%NfpzAXn>p?eyAVV zn|Kbg=LCMq+ttGwflpNcv?eK@k*KeaF@c2vnF8pcc(JI-x3MC@4;J)RN@jlfCFRk7 z1jej~g5V#oPk9-h?G6JQnwA%PKkQW|K&H7@wUgwr(3;l4dJgh7+yVf_xRA4l06r-> zDft}qnok;Yx$i?_Vncp}3|#?^+*!|DM0(rA4Sz|5wC>E@`;sCm^|3U}Bv2sdLLZO9 z@KG06oOmPd&$&X!Z@kP>0#1XDi7Fj~q5q;ZwMz#qhtvbyw6FGhasmQ^*rUX-y z-F>MAGn*VgQARzvscnW_&6qZ``-WY3 zuS#&?_HaJ)9U%bQ;q89OEWB5)3)eDVZ?1-$twruap+Gr%8;9Dy>xxLzj$kq%5Z?c3 zk;3v_s{w*Y8JwG{)n%BIy0|Uw&Q>6-<;FmUL+*@X@6J!OZ5m=`K2ART>Gec6va@8g zD{(zWq~c@5FOB6eli~7ttK(fsvM|(*s@AgrYz0z+Z?T^AhjwX1LRexiZ%D^Tl<>Sj zi+T?tFj8O0SbkhYR$-hSGB8DKh!>$a%Aq2~BVqo!QsQ%byt!kAN4%Tiz0!k2N{0;G zxPl3zfq{bT_!G-b75C*Qu)TCR!`&oGnzFOos}kugI&Pm|9xd~An~*W}?Oj1)eQqyo zY%CxLVF(VVxWyaQ)io0-@WGXIK0LRd;7&wyH?a?H8{?n> z%i8YkQm&lvHMAb{BiH%TUrI;Sr2o=un)i>@C9l<-?ZhO_KK^Y-?vW8=m;vkq6J@qO06i znoY*z!DNyy?rzR3#5jeS!4LnH?I4^?h5WC9(A9 zOfn%AjsFJHU-zr~8!9Qf<*IyhjiTP6=KGb^Og*}8JM4oc6OfcGm@utRof{=dsl$Ue z9bjwMz4(-Z#CdqS{Jp80pHf9~2KTkiaDIkc9zV9}ANd&A@zOC6IvRP#&aotPIp>c^ zG=b9?c{}Gd=g?=|=>r*r40i0`4ULG@iPa;$VgJChgtg`OzS86Lz?A0vYkY>ih!iG{ z)n0h^8UGfTK9SuxI!#02W(%hz2bNUo-K?^%!R(c;EK71;n;ZIIZmD4pu_>Bsft}mc zy?1p*;Csz(zU@QWL+-iOIlr#SHXlUFA+=O9Yh6}<*yOh27`Sh7R3kZe%* zuly%2GPergL2)prQmDP4)`CWOB}pLNb-95v1>!qJK)u*ym;_$inMSnezvW!U z5=OB|Fx#y$`=I#mr9$_wtycot(CUB3nLqVoLIKHYH9z<}_M7Z)?*a9%RVZ>Pr8A;x zg+=Ws?CVS%w=A(m2xe0qrdjk6O@<7?4>Qg+{yb(W=svbzZxFM0qcn_> zcbN3eM#6_4yWIeZFFgfO5A=+=L!x?jZc^#!1|mPU&gIF)(+52eRe>uAW5VR8!N#@}Y;Y^4q}tZAht5+w zQ|G2@?qE!uHAP`EkYpD0+36NcYbuIU@I7%7?7dxM5Vtan-~ei%-@pvwUKS?%4$8jo za!FM`jQ3P%_IxwfE2;gJzuzKjPZ4QIAQ#7dn}k_>sD<}MM=p$Vyy|}AvjS_!GxJHN zW+#_slbq&yLF>rx#YLjsw%$B8GTBt3z4F62_=efwM&B3GTU0g%4OQ2DDlI~E_~;!8 zt3hmX-MHimHg=wsA@zmG28W@@uvtVL zDZ^gdThS1T9i!_}-4hL9k}RjhG0uH81g|ws-kwYC3JzkiHLX;0>eJC;;`Mo?p!X;> zpVRxnDO@bkUZUVkSNw!!n!b?Xn$=>3v0>+yDQ<$2ThqC6a*yHR_S&p}%T{oh7PgjE zA>YdFfkAdg?}j18RQ@4doDFM*5Y5r#(KaDm`Tmcu&>Q<~RkF3N0~sKKOw(0h-L^Q+ z#T8*0h)(S&+~2tcEpxEnHs(ow=6D z*LO}x=%E?!+m$Qxm}=!xbzMhfsQ|=`g1Z&~z9AQJM2IxBz?OsGmfg->JRH7~O^s5l zcHxZ`*nStS zRMORwXKwc+YjAa^D+Q?ENAf1_M@&H43()Qwx&a8%i(~qIXVU56ld>A^f3oc8n1j%T3A9&{JpVgO^cl&>$7tLmJR|UVAol7gn^-2Q#Eput4Bmr ziqkfdQd11E&SpI>Afk7>D*|m)AXmhdzd`jRrs3 zEfKlxP8#lIY|kQm%|t6t4M|I33UIG+i*!mM5w5RvKNJPJ&EIMnX%h_jXmB52kZof? z(rEW_pQ`tUd%aVrkja$cJ{GocVCH1@9DmlU6w2|iF(|k66qr;8?r_;n5+?)kZU6_NB;2b#;^@f!fKwoa315eVr<07M2=bllk3)Gimft#3c7Nz`-f~Rzu`SJ7=Cuq( zkl0kR3D>}&k9OUG47eF>C z9iM|(-%A-|o}caOM$OExpZd?>G*_i>o}PmaDVxa8K?j#VdH0W>00n1<4?BQTHji!8 zj?K|}1#kb*>M<$Jw%$Dx0nhi2H9@MoQ8$KwGiv|6ethSd5bx@)at5A@VJfAS6km)y z``pB!i6vTKm=kYbPAnqx94ZvDGS2YyO0jh_BuGLpwp&p_$>rjVa?CQAGAQQRc7|cp zE6hZNr(M`Yds4P7Ba{aG6~vlkfP+p)Lot7sG!QFS$O?JGN*V&^fO5PtQZlZ@5V<-kZzFwwsidsL_^BBhWBf zSi*R~vC zVv^uImHZS+yeQa0wsY zxH7KBJ{n>N^4bbJALb|M*C+wOusG)k69(SNq#^xaDqW{KPV)tcL@F_Q1{~!(YBh-#$aEx9Iz)^`8ul zgJh%v7;Z>$F1AFEMM*ut2k%C&I<7{qAz$VDuR69KdNuN4(=FE<5_lmtz1LgM>BXNQ>3`iO5=5vQ(+ zs0Wr23x6^GA)o+(0jZ-@=7W;C^^0%w;h zwXwC?t+toVHx6?&a8U-UHxI&F2uCEOJa1xS=duM$B(7}F=ekkcEQi@F?K&Sk=XF-; zl`!_WNtB%~FD+lv=qKx^^wy%4LOmD@of}l{pvfHa;-<*2SbIrkY+YNj7AgX zChLDf*?iJJEmI`TnV03udR7H}GhF6GV1z3;kM|09;!_k=a$$6D)>N4s1vjojb^2WT zZ%_@r6@1t)^0KB9lB8{iFE36ED@!?$<4*2Cy7p!LOxouKuMtZ|b-5^AvzDieU(!?f z0TkTxY$H`{!1oLVlg3qV_>ogGY#Wq@tnXo}INyu z`i3uP*iQaz7@om5Y{M~}{Q^w@VDyaENsqFC&D6Gm9Vr?32A^ZT(vIsAAfJifkIb4W zb&do(sKxHX==FQHDQ)y4;mYJ9L#ltAilfSYvee{qnAk~6s)r@GOjfI62BRmvPt{HX zL}>Ee5UZ-Cr&^~rD=-}kth8PKdP7m$O8?l-u^6m=cxeGz)YT98q0+qKa$>J=4*zPk zgdZdf84SEF-dRk&iC&l&QFyDMAUOPM;~X@bT309~jRj7!nwLwG=WV-jM_562*Y@ek zgmI@qx!l=RM(tlyx*0-Ggk{$v`4Sh#`V2qDGw^k}7?CSUvEDM^89-=2|3VP4PV*~d zZo!yw;X4oK-4%k-KGNHtbD7__%c|ZA?eh)Rr7reGFhrr@o)triWyYGXHZ8R9lH25q z*ft)lmUwgM@bJ zAmt)X+feoGV~ibJ%R+f>#ch?+Ufba|^9;Wj*X_PLUpDq7_H?n)^buQpJ@;_FsR~ZH z1<17WDouuQ;a*MEW~%_Ft?pYLs9 zR7d5H8|y4L-&4?M_yXaImg1qt@60hvlfXR+#!xst!=S0sgS%A zM$babWpDNR)t-1!g5ji+}mDXf=>xmn$#j zl5)tBr>QikIrAe<(PTjVwJz(DMIi0XdFhYD>U88yBox+1WkOI`vHo>kU2-uehsm9V za4pO<4*E5J^N5C2{`+9B;oZ+@wBQM&6(^|*i3UPa9x7L7vdViuxAQp8P+S>GAe04;f5Q_=MJr<{Rh6!V|ALvR-e=(Yd?dt-WEYA zMzlwetE?-WEgoU%S4}*nXayn(a?8nSmF*=TaI0sOo#|URC&#KhZZpf1>1{vhdJtw* zs+tu?v*=6d^$AZmWCEFHFH5^-EfR*G{Hh*hmQ|L#i#9Qf*LL1_abMYZ$krFP9)LmPq%BtCs(EMzJ{k_{3plvhFdu zI={l|(CS&v{I!IY2nN=QC`PiI>5ufF*EX0|eUj<&5tPgmJv&NLx)!jSI&Y@xs#C0h zEJ@Xk)&6(W`i+wHN~SqWlhoT4xn;B{GG(pirO|aJ=M!?z3O5c<*h{2Fa@Ba}fqW@e zv$LgVBEU<2b+c^yJ<`rB&IrLRZg>mUb=P<@BViceQ<>i2NmXe*vGHLgrurfz>q|6? z2uhSRq$RgjX)GGLS0GQI-!_&nMV%64Q0xa~74xl?Q9v?CZDY5yJrh)Qe>Qm*Y;Sy; zu8mT*ekQ%j6+b`$f(Dt@GA=rt|kSZ-t#QorkRcI%WP?6 zXl1xkc7h_wU%l9{^EO2&x z3Qom3Bg)mEc-6@#&J}A%Vv7A~-O1 zv3AkJm}-RZ9VC$99Avr`*uaW$Ics$skza$%1K^8;y;Cf2#ECV-N1!KW_#hJ%NQASm zPf@2|Lg~g}w4Z>#87YBC5wK%ff?7qimHR=`&MKvKBtwMT8$vdusrLD+cq@+p38s;R zHSHsxc7K1tJQ_<~OJ-$1tF^wVzqZOZ+C^ZMLt;h(JK=j062M!>DX~-dIcP85?i?g9 z<=@(O&&lw^_~%#CeB0e>D-swC=A*t&Ho)n@#}k*Lj$F@c=3jK+FE6mRE}4)%1FDhF zK|dG)aaG{tskRU0)9vF{#K&;&4)!`;_o#nyaMY*>V6=DHlR0a5v)qs@D$bl4_jP1d zG1)woO1K)@=P)xER-BV9k{dfyQ$~`kF_ydoP-$t=Z2hQ~jO=L|WsNa`!b1kBj|~*6 zBvhoYeh4~i(+Dz%#vTJjqcM@c#593d2^Dcqw><4Pu@H0qNh#L5)T8x(nEM27P?;9=>(2?9g3huF(Z58GC~UvwO`EC z1&XdRpY3~2-#-k;E6jBvLYkbUf)yf>4`v9EW>|JPjSU7H;q|PofuAW-49f}ykKL-R zzm&H_A`vE0)-dyuZKRy7T5^Wm{Xu0{|3-iI@rJk1klb9V=~nYsdIot_S{p;_V!L@9 ziD9KhCIGiwXO|1CaAC%MChr2I`F`hz4~%@lu&lzLgNJ>|M{XM)-070M$1yTcQ(&K+ zY7i;RaW{}-0P>bE7g^*c=%(fFxPZwPxSig@W0yW}<2=~AXZf~Tet&o6XQo2;Ni`aR zQ`v_g&7Z7Ga=|OYf<}8QK=hd1j$6QcvCC`GL`%Iyq-N`^?EsQ;jp`ZVP$gu&QvR)x zBD$KJss(lq(p?gQN=WI(%$2z10z|(y191~4FMd_yT%-g&PD6rVtv_e>?_M5R1FVE(IPBw6t;5vuK`)ghhfA=oKI?oD3A z+q8r`ZVrYqmYy5D8_ZF?PCr@(_R%Hx;~w@tiH(iaAM;ol9VZd+D#n{*DsJK>$s*hB zXuDFJf@rhD5y2W2BTpWERp=;f5Ry8Lb|mtf646GsGAIDYUsWFv#tMHO_olR(vzf7} zl7|)D)Iv6O!ND==5$4c|*ZYbt>LQpWr)(!$(?KKR?VYg9WJNyoC@sAkGSkKk1I}i- zlgcH0&?!=xs#>6XDA7AD<~wK6u}tryTwC+REv18Lury_1a7<$HrZ(g7^ti`T;psp* zPprDqt}2PsGM4Ded}CSHIjCSjglCC*Rcit>?zXiQRXapcsoA>u3-q?5nxwdXQMxvd z1JLe$oIYG)ZH>?~aZaDohWdv-P+ik2gGWb2PkW6a)*F7ITQ;W+-ul`yP>*v&f^WKR zSCn|xCKxlWZ$sgfX9n-LW6gc?%8aB8=XI6Mu(t|@uO+HwZHz7M-eUP!CiKb ze$HZ$#w?*5COegH2zEn80MVMO8K-2YCv#>*_ z3|~X-lZid?Vllya_pqL7k9J@U-Z2saYnRp^lZV*m8#qVUcE=lgBjpBEhXhYmh51wO z7iNC%1zxy0b5(WdBxTdS;U3c8-AC~{a4$_VVlA?;o?MoCraL`dY061ILWYOop)m-0 z%W_*Wmzf3Mo$A{Jaru-7Fnmupz?o4A^0&VTmuxV?c~qkkJy>w8c$BD#a`wuQ-> z3@(+RIry{zhPPLB#)&HZ#e~{fZa3mAc)HpwDeujG=L>j9!d{nPsy30%**`5da(DsP zCRTW(^XoOB0;P1l0NR!ic4r38UXQMr+yy zs|FV(MgQN`K(yH0RHYrr9nc@3dtYZ>>X~lsV4ec*Jn`ATz0mMfo=@xwP$aZjkI201rgO*4h;I3V5B| zKlzh^X8L|S85ny@Jo>&Xltj-DZrh`ECM}b^n-LAx)+|-v$Sr>a%_N`v`Nq3CpPrJ& zQTB*jad6+Qa6T$ImDFOF^=rMW*Ym?4uV94N@hi~9QRU}7=OE49#+sOKRuAm|8i+^( z0<#cRmkQ^?Kn_Ca#?GLE-jPWp?%k{h=?Sr25y-&sV{LL;)ChITo=&0kT@G?SJhemi zP3H_E9Gl`nYNn{niq>k@JPsvDQCsFA{p<`Rv{}=3LYG{-MOh$VTeCzOWH7BKv6OA8 zqgQ*cWzOZ5*(9f^OWQ!Cm{Wl20*8}&X_yqNXqUs{lY)AoaVSYSl-D(PD!U;yb=h{R z&8gu9&dqH(w&=4}O8d)Xze-5put>x`MASD+sI+#4lr?>Q>)=f_DD2B$finTu5t!u5$!4yq#@M(sxY`k>qk7zgF zxovFZCrz%=@OF%AIpJ6naj*?bZ5pfTH>as{sVq2)Zd9qzT|>%@ z(M~|Ht5qHYf*)luzg{C@OD zj9Qdnoyy!9QI{fd2mHjB_fWtBC{W{fA4$}uwMR`^tNCZyrG7dgE1I5s>)$Z|lsY^S zo7rJL2N`ijNn^`$qz8ohlfD6oAH-!8P@WwQTnWc$1m zSrIT4Yhbv+o;52w8n!TTQMMQhjDhtpN9A+S^okbn(FvqbvRcyOAzjYq$!>En#{$^F zIVc28nu(Xe-pV;)_e}mN^-!>#r$THTqNdhnj{NEF8Nn*d!b)-u3fBqD9rRmi?C2xd zqvZbSKJgXKp5wcGGj3n{S^ACR04iaM`f6LO{UXX-t?2j;rn+^uK>f%i4s`kJk)8>;WS*oPw=*R|hnBRHa`Zgh4Yf9?Na zoyg6;WFlAld6m@`hoMl{K?$HXuu=CEQP=@78=O-2c2Y<(gz%TeY^um|Vs44Xv8O4Y zK)^YOgHP)D|Hs&ShqKxD@!#om(W0%Y+HKW}RU@^zT18P&dq#?)w%A)+t2M6@dtF9E z1Szq1&Dyp147G_pqtB_=ec!+PKAv$r{%|DD8XB9Jeh*zjxLiRV+hbt$;w?yWi14Zm1 zI+*szQUgiCGs>Jrud;_xajxcjQIcCKS1&6cE2UiGJaHRY5Tb{^7Lwwbq(<#$Grl@C zhON(6`*^q#IfwfN(%Xd(FiV9%%0D!;2|DHF*;T~~r2{j&_Nqo`Z6ozQyRe}dIcMoJcIP5q?Hsj(VaTZ5y^q9HfJ&A zNfv8Q8gxxe3RP9C(74bw_PbVj=FEo2+eCNXIqJlvTPD$S+&{iqTLui?#b`#<+qRg@ z@xDl}(@Pjh222j@1-?lE#rxiKYrrbTXh=X-_Lda&HV+~GMzbGin~Z;O4EzJom(^Lu zK{ax=pA>{T1&zbB%+*oP=r`TuD)tdAjd-Ttl+tk=`IM*t)0xE8*UaUm$@iwY`A@=jAf+Um$u<%Y9p`xg{E?=SK`8u6D+=U2%0);*zYQ z-&-b?k;z9p&|b*WO^ChZqn@whH=riOu|h*wKkoVca40+k+YT9~(#ji*60%lFTFHr# z6^nBymVLoui#_~f{|?aRH;w^{@{4a5X~FsS6iMaK`m!%Jvx9yfoZ^GYDGQ6-l4Vm9 zZ|Pn?dtA-7x+fesM$I*IJ8>ph_K7{?n(cKnjbM0+<)F)X2AbZ0_UrxCFBvkfYxkjA zYwQPurX;G4^Sih!#p}SL>ZSI1KIbUfCGs;56FraBgc;M&KX=tkTZK4&QL)tD?1`^% zHe{Cj3eKF5^X;qoyNsq=BRB zFa-dr>E-J!5c_*wcXs}$agd2_`wL)n+vBRck8PfW7(Dqln7*OsGakgPodM(AFLXp} z1eA|RAaVPl(*)w*K2Oc7=$duHwuS$C^H7;#iya%kWS?T!8`Q~X*^~vO zBpg@I)>t#sS$=`m$Svx(|M}4jx2!eb2-#)4$49hWvRHHBFPFcx@7N=BXqe$z#ibmc z{&z6Bv-`55x)9lTe{{(TCYo+zry9RZ5{y>^hVT}fnl1hpqWcpJK!(~4$^4F|Rba>xqq?%$_ohP;RnS1D3 z&xq^NZJzyk{pNs3XYs4@+bK);SM_WG#WB_t0r{Clp*5e3%$|7>ty<>Zt2IImk%5>T z!RP$xv!5gk-5k&xi8KsG->ZychDg0wKX3a2wmXsNlgO=_Vm03$nD?fckB_`xpcb!- zVU~_!9J~sNWqk*NoIh4FxEc&wK2c6P4wMZ@x_8HUg5A_OG(bD;8}|Gv4ugv`67rH3B}b ze7TqAvFTkNTq$t+Xzxn>sar>Vp#~q%ZrHGsVH(1#Z>j=x;=FNbm@Wl4M#Y-@90Y%T#sx&2{ zRN$vS4Stg1HCxE~e3hv!S!;y}BE=yAG54G&Vx0P#IVPNZFwY(8rR+C)B9Jn1Q!bMk zT2;NcQn%#cAs6DCp4eqY-)Fk)NLq}o`ZjAGif?vXy4{N z-i!`|(M0xraGIifH=r>JE0tdr87`Bs9AUY3X1ILw?N`sMe}Y~g@!d~X4j474JX5iW}L;6k!14bWij)! zyJxW$9u+d=vjGi-(ZmSAHTRW;iJI6@C5l~5sGFeFn#!PC-?Gd(^n68JX+K%11Q?SX*L}^C1Wjuf> z{9^}n#iCn(_rv#Pi|Jd_COWB0an?TfIAX>shBD7X^H)nYlDk9V&l}fwlj~Ot;SOh} zWSWuW)sx%8)Z>y->0NWC-l3PLd#B|B_9PV30Ncc=%a8}4n@ymJC>|h6WatBOHuK+u zy5eQ$z%9GR75#gZjb(&0X6L_k%><3z`~KLq(u?V}1l|!BCj@XN_8)08KlhfgE=pcD4!`{!AWXm; zsh>`o_-CNq>>+%<>_Sy?6MJxSG0(m}a%u&5J6t$DG+1`~mnEbVfu|{5WM8x-5|`>8 z#M1FM3jW;ZkA{V_bGU52coXmRr^qD3C&`l|wepkHG0M~Pj!K7prsn0lTWfQ&K-xF+ zpS16q3_Kd_6m*V0U>X5JI1t%j%7ZOJ=g-dB5du&=O!9l+Z`cF<&_g<_tKYH#{@!i1 zX6iN1_MNjl|4jW~phUH*thBa9zpTFlW87@3l-#%s?(A9iGf9{^HGt$Q>OZ=kPDH_p zd-V(5k>zLY{v#BGs59GD>b(f(Vrt;v%>T>5pN_IyGCU-aQ;c{XNuIL>L^igdJ>LsJ-#jNz%-fZR{w%QlJV>!au4I8+&&OYB2AQ?V~|55^i@>BwNHY`vbmnmf|i z!mS&VoKx08OmeZ7f1q{WmMhE>(p8BdLwDKFW8WemnhoGw_wN`PHwnqujk z&Eqty;x;N5>an4p`?bFK1Knh=wQfdw_CA$x5?vRTmB@y zDSX`Z$UVbEm%l$oH(Z#aIQpo^q~ipo^E%MPudX9j^j(LBLLwLYw-#kyexE!vi!R+tc9$YP$*r>P# zJr9u!=KK?)0?rfzaJQn!60JAHbEZ{tg?xJ4y+YUz{94n%qV6e3CM#{L zN(E~Oqw(<5A8Jq1A5h<|V{|POP(L#5r?00GVc{=9$LFV0ZimnChppzCJ{cT=SX6^c zve?-fJ47bbrOrZ_p$4m+2sKr$a#U@MnxCxlMvOc6&{7tz!PkZRJiVcg&%t`Z=P`k$Hyzc{i}@iNrntA@{Jt7K<2(r zaOb0rzmNNH;P$J8fHbPJQxg>jJONo7zw?S7*)2Ns)dPV`RVnkC`RKK#-dW-)o?$_5FeQ? zvDhp&9RP_+;`|uLMoS^OTqGuDTp*4bhElkYJWo;EEO$JeJT?fy=-`;SkswM0{LY#; zxFWpiLBWT{AJJ~sg={noE>@6|oOA_W_WY(RUcHj?<1PmsC*G1he6+8gElL%NktKN` zw@({4>1V%E-wuk*YqCh}h&~otcl{{IYiD^mqZTA+_hvV-VmHqlOu9c zhlO^zS`>RmC@bqakbTI2Na9q6n}6W<7vg76{9{An+$qm;D!6ki4(E9c7I69mCye%x zSHy4`*`rRY^HE}nHB=B8gEW(fDPBUF+{Zo>cbWN_NWhEkUf#A!LAV7L-4&^WUyEH% zslHWd%*v1A78^wRg0+EeyzjX4>+08^cipi1OI+O5Hr_hAEaSDTGuKuWMc$QDhsk6R zy^dPqiOF5PugpEmz#>n^d)d20_OMVLWdK`$TS3;(_3Pl@wYv)KXWU!V`mL-+0DMme z7T@9NCjiEx@V2k~$|9__t>=k-@pXuTQ2Z~Dryn7YnHgKk@_uMcr>5_?)abfHw1t-7 zv5rJQ$;BdO(};a}N)T(odC4l&+?+oQR+E|$nDh%DwvLrDHhG^FI9$nbCtpWc&EiMN z+^4LrWc7o;4v^L&1hL)cy4KnEc2UOq$@*QbP6 zr*A>(1o!}x%m_q|G@hg~G%m|s5T0Qi;}AQHQd0IXh+E<6cG`6b|5?DfyLt84{%B#F z9{c1(B{+?~)Hc=YON3D`q#gUlQS4p0r9?&Oc7zyeXna$Zvm<%gmz_TN`eK?}GIC`D zX{M4O6Te_Lw}6wrZ@H6prJQK!MJL?btAl5O&bo9iN3!Vkk?Dr?)VJ%Keo-FWf0?J5*1Z(xq!bBue=bInYh)0= zYBQKy$z^fGaJy+7RY+isFp06Le~ep=G}h887zwJ?{gzWID|brIx1XM(Wa31s)UPGYb+O6D>qjw7~s}EekqoC;QwXCHl8*Yuo4e$Fh2qd6@;}Ccob5>BJ+id z+)CNeQ)wBS>t8yMS$|aaC_OHm@Vz6kxXZhJsPdV%NQM;{)a8DA7re8|yF@9I82hq* z{q90_Ul<&$vNm3P%(F0=Nq5NLFz&GZR_snH<*)dr{9?WDqmpB`I?UYe8alzQwh(7&5(Y5!@m^|<06X{b)Q^O+YjVa=K*k1G_5iD6+$Yab;} zFEytzTMB)%@brbgli6E)MsfvzIhAmNG5J&Al?n%?*gekXs6S8QDWiwGxE2hm$55dI zM;*fl{VMriciptJ`s%cI=Bu&#b(xPV;m-#JX}=C-e9ACzS=Ee2a*~V8k7y0R633e6 zZN47Njk*ylsw)g?rbbL=-bqBvaVENlL2u7JjQugMswPK$f0dg0{!LY_IwPW2q|2k3DxnN@Tx%~h+>ZrS*M?WH490l&SJx4~ zI=;Iu+;b|F%E?sUWKN-3uB0f@gvp5DR@Vxys99r;)eL;-r+gdDq#1FabNqJT zY&=e-x@=-CM5brARyAp)x0D4D+9f!exI2$Xn0%2pgykPWE=o5*SLg%oA3P?y(@@V~`E-#I}Y5ky)2H{CI4&i=QrNoJUTgzT9m00_(ea?U4T@WcHz<|%6fWIzuSM7-)!D?evUf> z{CfmhAV&HFKF^dCrhIPzEDRGd@^9fF+fiWJf$ZTz)%$x6#rM3Q(AfS@r!V)wTi9L- zNBcEt{1<5EwuWHV@TS~d;WD7qp!f( z-!a@0)Sm@?3Kx-mzx`uA(t|?XVE6H3pFVP5B#OA?&hm%Rr+MZ;CI86z%HNuMm?f)O ze1x$?4c(79SP@B?7xE zb_s!PN-HgBs*@nrOY~QEGnRkoaTLy6RXA8eb5vS?JAqQE`$eR)wcnJFkK0v7VV=&r zM^r_&K9qLy`gG4*&=N0GF-zvp0_Xk(TD92*u5GvME!l!4&Od=aq&{5$$hcQ+5hL(T zHQ>D#ERl8YVE8{diG)&6sP*g$jy<6nRfO2A2V3{pDrFoLGVP!EQ@LXuUUL?VKh4EU^ff zKTZ@?ds6scqVX?BGRDS!>}OQV_77Ln??^aY`!eskQ0!^$$t3*8NUKRkpx(5nDXru{ z&f<2iQwvnXs8~bYigUm}5mJ|{#w1FL`z;{fNrX*^MQWG=!F^t;$YzCY^kK?=PJVyl zwxp1e-D(g@_IU?4tNtIeFY^KuE^LLOl<<2>S%`#n{49~TiN95=Y2NXi0RiDM<sCoC2+O7s){7pjR#cN*3DTOVs$knMJE8OXnb^7TT!hbn0WXb0!CF)9G+rZ~c5(TP9)?iV=viPgDECqq3&=-9B1!me0lrv-NH;#x=>~A$L<5g+Q9EZ`OGa&Q}vRi zWrQJKf;$gJ?hVKrd^@0`3G8J$FBs)y{?Gzn#)j~l~*T%ENO(AIze zRPe&4W^iR;m{Prp!?Hc4WIT9WWga1xV$YEyO#dm>dMIZ}r$E0_*~xxWwXUFi_~#(7 zqVjBhfq3gj%GVf!#AVPVZ@-)~n|(t@ov4;N&h0{DHOrY~f=2DB6JNI$8TM1-Xv>&e zAqKT=TtHetSvmPqw}gDMT>`!p=+5)JUYX?Aj2QBaZxf_>KTRfIEAuEUQKTm@a5QOgi_KHGl{{Xrue0Q9(NEvAL1DO%w>3M z{Fbx?jl4!2Ns0}_u1>Yw(dr&XcrsDf9*ncHwndDU7M8o4qdywj_6;zS6Ti29U^|8n zDJvL9X)3QOGHNWwK27h8>c%ze#@`cQ{qoNC`W21kbpjm|)FvCB7t5xqtFG=!+<&tb zg5}C>1u%hcvsm7V-mh34`R#i&{E&p@2KWlnwy7&DP{b~lPaO%VWwthW3fsf55#pB{`;6hGX z16mYRLwl`T0df~6`As23+&6oxyQ?7I2(SxOa+5k^QH*=z;9O?cqEcvhh=*1s1#9$L z@B%ioUc|11^^1SX+o$o3mOzwx*JF}6-WGO)1|`zGA@;n0-^lN&?3T+yy>-~BXF*bU zOl7rOAuNwukq;~CFp?9*+2A4hjfE4jrl;P=#NS^L-pQzrHgJsMV-jIJZ`a8>7MU&g zJ>{up!?RvLH#!p~4yjP)?>lGYmL^4suEr|ESADMd5`qJ(MXJ1YchU`=IOcuR1C8#@ z#b0)bm8NZ+;tcNi`lKZ9lxsTYkz08QR{bn5#%+t^KnF%`CG8_a6=v*eh+lS0bghw1 zHQx9A1LT8k_7iByF+ui1J>GqHIj1VMwuiV% zGhhbX3F+#c+l=$|0crO|wpQzJmtOW5ADk>ci@Ih-5VZ5n7uYfLi*zc{IV_fF-G&na z!lrqItxSaTRXSXT7((@!ML*_~qOJB)tz2C507VGgj25^dzaH|_k$Jj5{CA6WtvSZ& zX0K4|;8KejqUvLTaxD9nef+FSu@d`_?HLW47W5&BXBQ9?z3}u1Epji@Dv6?@)sdz7 zna6;}Z$ZqW4XG6DWCsJctpFW_^4YFzA2o)2e&F;x_V}lNrbH^^Z4&S92^G%?J(lUW zrSZ7`e;tR+eD;y)JyOTv?HO0ma{G!Lt-UwGJ*-pqjtMhysyw)ca9!)gQV5sWrU}kK z6ZXV|O0i1#jZjw;q_w}(sfN}F%26v%aoXblMRX z!B*7#aC)x+ayh}yBkr)qxwRH$h_I0xD>+wAVG3pl!w|ORG+Bq;nchy#cHA$j=(%Bv zaN%lcAMC*2d1#qB(>~llW3mpqwGjv%@wkZ3!=1%on6%B>RQoM z$ArFy!fEqHMp|K7h9~VJBha@eu^W@SMfbhF?l;Z$veE_O$embLh!(7tThb#qoYxcAnMCJUX)5Yd z`J|gDZzL`yRHMYqkMyA;I6xSGGH#Q##t{!)GLyQw6E;f~6wE3ypN0qEJA@X-^-^Z7G1?Ky; zYmQ#SPS{unL|e_;$cJfFBl$sPV6H9|5i)RO#m>D*FU!w)ZvbuxjylM5QXBG?{{ehp zXeV#t=6a*N0)h}8pJ%`7;jECRf=bRK-v*8Lvd5K_a_jU(qMZV(_&clcvn-NQRB!$G zN?KVV?Sg|swQ!^c(y6Ak1YX>%>FXz87?0x=)obUNov;!a_bLB+?-Xq4wG5Pz+ZSZOj|<$Sifv{SKGA{c2S|@rRr)CVeb7 zq;wEug8FXhv;3%>O&naf)AC~$(KAc&(P7^pW5+#%n>@IB+S@J8$uxuwouLa+$k#;{(&J0VB?DRZekganIBE3%HzG z3psRCtv~xS?K;wO`kw6Lyu+=ME%GD(6a<@&sc#%SdKPS2B(xtL zS{3MyOwEkPS}_mzYxK&OAn^tDVlo^9!^Z4ilc=wSg%eLSAETe=KdLLIg`HZb`uf|r z&{gJG+h+DHRm9@(J>1)5;`2|la~C6fcEO`viXC>(dZ0!?tZH_5P-9Pt?OQuc@3?bB ze4?;$V9{Z6$w3=#^`{!oXqa?jE8xoybC&J5bEois z+=!tHCiiQ6-A7JEYNgj3Gq$AMn^$E3N+zY-Tl&D4F92X<=#F%sqXrVN z_^7dKyan*vf2cwxV}#x@+AaG2N5_icnDB)A?G4ZTHhJk2__%qJ1tNobv?vMFvaZ z)^Pzf#JyB-r4JTjZ_8o!t~KQZMc+B9Rej8TC~=)EDB}B*`{R|(CAuW7^`%MDc}#S2 z=|H*qWLtP|038Z)o%J^E>^cG}MrOb=9T_o{fYE6j4AWJij0|-SEv+=uf$&dF>1E~B z->##PQuKH^OfBaTI(Hp`D(cbD^CS?@MtuyTJYP3I>xlKhm7&GiR#+%~hH7k%7@J>r z9!a-&9d(NU`_q=c0R|vmuf84CYmG zG>Hl#MIJEipkQ-!QOtTawu7#zZm0I0!jq~}os(25XC%Gi8l9}2D=x6Q>O5V)qSQIK zYx&dGMCb1LK_}20ca=sSaw_@UM>tvjfE|IJy{An^KLH($Q>~WW1f0vV# zt@$v2Fqq0USN`y4t;d0qJR|*_a3d#+hwK76;YeaQ={XW|=E73k@ow?HPixRi-DZNI zc*@s22$Gb}_i`lb@gjQ%dZ*IB%5!WO@xIGZr!p@CSifoAw!c7?hK!~5Rbb~)KL@pU zb7WU5YTHOwA#*cV0Zz(l_!bQ*ioe~F2B)rY;2aMwd4L;svKE9HzTAlU7WM5@r_idH zuP>oD6p>=pqJzy6iR6q%KB}siNAxR|+DAv+Zym16>n+`O?#6XwbxbIjigC}ezF0Vk zdD_UYzrZ9JBsRzVE(cjunXD0^zlU&AyR8kedh9KFEHB>~*#ozm!u6H%0NZToir7$s zreT2L8SUA9J5P)scY%}>;*o^+AIH`z3RV((YlQ6T9o?2vun0U$E7lO*kFXe6t7no? zJ6BCIq-&i1<1M`mkERBCy~xY`xkjX<5KconPL@?1avf#UTB<9C_873q7;=aZkc2ax z3(JV+Y@Zx27qET$8M3CUpp|wn)xZJG&vx72?o-tYSBG-)CMHY$F5{+Se!O5;T2061 zOdqtf7HFZLsPt$;2id%Qtb87*F-~)zi=sRnQrd#5Gyr|HAxcn#^twRxM5nEkA>vSN zG*8fKEa%_cSlE;rsG9PF4Oc^GV!7Wb?;6`cKq1OacA;{Q-QXy(|7& z@KKy(>n_iB%k1H2W2pERnVA2)uELT;ULA5ki)&@QS|&@EPND=c=Ck@uGrTf6(5k!L zi%U8(0wsCyXv+?g-+4V3Gkn7z^UU#A3=-+iB|Di$YHs+3O!3NVXO_s^t+BeWSqyuSOujWuz5z z_-Zp{#imj8P?NZH=H4k=pec!xma5)&_3F*&U`xUdgUB+yW`VLH@O34po=JAfxtVo4 z`S!8;WX1qIl6A9kp8BZ!(^>BBF6)qh{=&~+APjog%kq!nv+H&(zJjq9B^gruoUdmw zrY7pfLTE5iLv6~UXZzGmg0XSFNQ_Ba%ATrbMJG!g8f)NGazWzpix92^T2sakN_sy- zDHo08zZW5m`L);-B^w%1ivg_)JmZ?to3ZCwhRWMWIHoOLD<&_#dA@^R6QVg%wDH8-RU!%2)L4Qy#-XD@Qg zdfVYUyR6aO==^PGuN5z=wI?iVbNJKDJ?f*iSbxK8FZInjM%gL>UfF&WtY z@^|>oi@bW)8^{J&O{t2`2=!IzJis`Qx6K7i;+J`d${Wu`NQX{kKvo6-o_In)?xa5V@&_3UO#Fh&)DPasvU!?_DRR{EKG56*aDfGVo3I^%VE!f(U3AhxI zQ$<@fy|!|u8h@q!jTc!Suw1BQ?2^s(sSFLpdxd#rsL6wKR!s%<^7_zFB%PWClIHdP}1)qNkj&f4GJHwE>1K~HIX~qTJZYDf9hcZL@T4So?y$6ezSN0EBnbNGp zq=0&)V|){p-BT?0>uy(H5yCrH4Rl~_>WqWkavi=Z=|?$>rKeKaDZaDSG*%@!2reS zF7?bla1R5lfKuEc%TB_;Qviw@zi4rT?+c-h^L6lGysm{#&xk-La|n3que;Z>Gto+Z`ZpgsfIsN1^V`g*%%4^^pH9s3w<35?b;k+HB>29*K;wD#l69QgXFJK^^b(DN=RGr} z0ybT^Q(LOkXNv?UTRf{l-hRo9_1Ix)Tg{Xx56adyB)lH-3&bcRwl*&`&4Lc&YBmXWiI0k5(y~E($3MzNqME#A}T9rs}MJ zYT?g*@67Ofs{%a+{qfWAOV`ZH%x@;e4MA6{_aD=}2KO1x-d+-5Bc`*>AOuGHJ-HYP&eLYBV`L~9b<8M!UW}B3WV1r*E_W7rBAx*<=Z}m|({qDpG-2rTI zQR+~zwNMzs9=sy5u^N$ff|mh8;P**4e%3gUo(xtO{HU5@Lc%NUpf4r63^F^$K zR%VF*`)dzJf)T7z^M|*c(>PX*`0oVEO11$Wlvy7FvlM9JU9X8NEBPLaZb{KB+ElT{ z`WCgp*2?c{Ef6X{)q2X?E2qtT^OM>(Y&ys@s1IT&tJ4pS4o8S&BA4g?PCW(>#1bGg zRaMni=)rw~->O~{Q57U&0I(y2(pO7p_Ea0c5jyekwQ0!53bn|yYu=8x)P90Uo{4r@ zGu%#u$fmlLwVDrcEl*!D_w)m+TgUpeNnyeQS+g92z`(E%GJ+K;qrC|qJM95BmPZip z$Qh&{f}t+xkY%Zj`c{q6HVq~YGhbIwkf5E7Vq&=`s6{+_De7mSMc6T0aUX_uCRVc` z|4E*P^U#`cuHd}9)ufbSc7C{A`?4K8VVTbcX_Eq&9w*eqi!>34CEJ2dXbGgXLbleR zfjwUfj3c#HeSVd5APcNj)o3TKFh%t*B2za|IZpSA>!`TJ>DM) zwet^|7ro__k0y(3T67`aLdCqsLSxVru^h?TU@e~!EKl`#yUWaydOWwxz$9YCYrln7 zT+o@uZ$rW+?{(6O6RNT@Heai8*71^iHR$VygER`y_ea^YFbaL+)cL!+uL{HM)VKzp zcvK0(?x@FDqeKe&pQPwkjBZdanJ~&xHy2)$%oVT-`@ZY9bEtu(3IpIU7J*li!sSnG ztRuT?EI9f@txuty#5(6`Mr(N=hY+v%lv-`6dsH^tH5%MeyXnLgXdI39>B6FGrO>?zZxpGoY z%$7qu`{5}=bt~>}isv?2sH+&J*~nh@W}b6@CwHkLxk|xMF5n|s*r6(JSZ(q z(DkIJlw~_j=Ob&fOBeSKYo)SjJ^u?K@F;crCuu*m^Mi|&bh5#3NX@@u@T~3B1b)2Y zr(+`gf50qhlJhPNp!c5@6KSp5@cfrFc{ZdQzFt;uyTr36PoSmwYo|Xt?xMRX1cI@f z-8g(Y)3MJUINa{<^Beh=Wmki%|F=HX2|SU9=Sh4*_^J?g<~x;3%P(qYJAk)y_6_BmAF zXk#CTuIMc3srB^j@QS)ic)VfCdtfP(bJ?awKiI=c-bIlyhpIr&dPQvb_oeLM=kLN= zxte`w%gBuLelh^-BZ6Ma2P!AS zqM5ed{&_3#5it27E;{-C;U54i;4bJYfE56NUjBLU-~wX@j5h|7FPWwOb@c(crj_3N zhnG9NV|tl8E?ZsgMvuiJR44z^yY`#K)p^;o0U+`*6&nYCBYHQcMb0~1S+-6}EaQlD zO;9fRIvT{&|M#4Z%OI0XyNpWt3=TTw$5XJUfSUW6i9_uh!+4*91J+Mkvwb z8#QP9yG22!V$mC`B+Pu#Iq!Nu_{`;Bt&6KpBL8~yu%x=!Rs;!LC&<)2=P_E|M<@_* z0jOr_|1$Gs{vf3DmiAe}1CZV;D{iimlSdbeQB)z^&0E;6rie!04`1~QWYOUF>KP_y z?AYvld)UACqWW|obiD?95i3=`C*=XM2?VABh~kF+iO$%bnvuT#7EU}#lT*TDvw=1u zC>grk^jahg=$pa%-t}%I-m$6+4+PwLFRuu4Wxpij748OLI`98CLQDSdiqraAo->^b zrG`Hbsp_{;o|DO*RwSY>QCzYXuz~^@W9kE5^RH)f1^H0#Ni+FG73IVNnTED&k9IYh+7gMkE;}! zRO9>DzGX4*)MtesY@Fq(I${yNf$Y$!@!OS(WMp@!p%xBo->{hVWRlUokJa1g3z11C zk5-;yp`niU+F|Mte{`zZt=Z5jZ6MS}{l^J&kU85F9yyM(kD^iI2tB0%cRaNJz7v|F z`&QhvmP+0p>2B394pR&#y^ILa*_+4Z8BuFCzb)X|VUeVbX@@VQZRlPh=jhiJO9;=q zQ!n>1U&1oxbNf4=+sg^zWv{D?ny|r0`pt^2IB7qnc|;{7*_!p)U}2|Xx9LvO22rs< zIe8SvHpQYw6W>9-24z>K63CO-*XWyr;bCQOUIW2j+;_Z=+k>Wd5~4;%e*t zCO3dC>n;PfdLGxFIsA?zF8vPo@P{96=JEl=yB)aLYoLvKl9hJjAfkpxBem`Y!7pkENCQhwj}N(LI5*yiuR=zvRkenz z&ZcgSpj0MsKjv_&DVDtX3q}#&?abD(N4%<`1ydOXXFYacjQ`N93Jo+SCkBp8V{;^E z5vw(Z1{ko{)>oSr)eCM^xvtD)&yqCHZ18v~^6~TpwRmGhV}7d_=cfvK!VWJ?sq>X} z4TQzGZ2k090^{o{+OGtI=@*uY;TmDujm#>%0qS$dn4X<)W|)>!DOE|5i<=FKFeU4( zT6AB9Ru4@dfP;k|YV+gd)_sj@Q-pV@40p)iy5)8kC#Rgl+`ahurfAL}2OE-qR-ed-wY|n~*fjs_ofA2nf?;$GjGGxz>WY1Ja-TP#&Z4v2I*1YsJFyUOI*FKAQ_ynj^8L4djWy59r?ld?2DcrC!nN(5M0 zX?1d^{TJI%M(p{N8o)2eN>Uih;Y0z`*g%#bo^WU1w@1N-R08z5F1UPeq~Z=G_$n{) zR9_m6ZZ7s@Sq3;_=8xMSQUH8OgcU6Qk?#lHJD*|W3p>(3lx8ydDa9WNmg~H{9`~L5 zS{51S=%=>ClyGS>(YK+=7AvU1*`&KnQ_)g6@Obf(W517M_C|}AW}{9u1qNreku6x& zboqGmQkYfAo6a5N#*Ug`AdvO>#C!>8$TdVo+M3+w|Fp*ixF%1|?yT}@WVfi?xX+*4 zeV_mP(|9*i%;#__W3rssdwq#mugpROY^<>$Q9<^tNjUZ z1(KlJ65Ey>7|Fq@%y~hbZ>=^(4Xx&1tm-to&=jG=+!`NkF!9Qk%yMb4F_ILzbZ@)jlBZQWRT5*ERXFZVhInPB-QNQ2Y1q%KD)zMa-~ z7XDIEdXc{Vyov0iE(L~a?HBhi35BU;GDVNhO9Wo_WG|8mbGxJtSSn41x#{z`$j4DQ z)rPQ5(b1`nTED@uO~zhL-BhCNAl+vC!u1#J#vrf(R)au*UA*-h(ABS?%X*+oydcm8 zWfTNr|HwRE{P+?EXtrd0Ka_9a&jgTez{;z*HW&)553rDO%X@||P=GobjFln zP^<35K1Q0=w5+*mdc5c6`=S0oswY*&zY*qYt=|Y{`=C&pKAnvoSF3LZL)0BJjhBk*AJ30IP${g z!$*7Wz)ZyI@|3%UbJGP``K8(dVzE{m+8?kf%QgLY((}P5IkB&#RYVzF8FbLbV3J>1 zrYg$*onobCi%kS|c0~+IaGcd>LCmKY-1SaEuj?KMT{z)b6WXn$-;zC`%HgBS?x`QT z@bh7x#)xTsh}>aeM_tIbeaNoV=fxhL=y+_?Ojg>rsqu}xa#iW%95-!6R~5^_k;NO~ zDYQdOjDrHaJ!~69?d)lILgT$4kBu{ZoZgA|P~ysDl#4svPtL{4(AgS6;~5@oy5p%x zL1#dhCF4-%s^m_8e*ZBI+)Q0xWpaxK%OXx>RVNxsmO_1u)jlU51)FB{ot%nKtXag* zM(Y}5{Oa9$=x77ctnaa~q}r0YwlNkUR#8KB8b3F2^k7fl@vHL|sGQNx0lW(8D%9Ri zuLT)4VSiL!>!h<>5bWGEHE7W%!8#P6^?+XNS8$aO(y&op}B%%HY*JF zT|lPgvTx4FWhTwlAAt7!G2lsa>5i@r&&8qw+udF8&ayo#enj{%?0!yq4{%^KyNh-@ zNQC_Qw2PQ-ZQ)-bmBZzUmJ86IoM2d_OgSKa&7qO8JU#jS=t`Si`-tKDOL5JQibL{v zEUL2}0#w*WM~ch92jNC;vB}jISX}WK6V|z_{{>@hp`Jef_uIK_s#ol!z5=-sdj zMUlxRQH+N4+;#bltL@b}uHb4-8RK(Hw{pDrzB9e({)|$jeqg#q539te^Jyffq0~u{ zuC)(TqfOQK%e$Y#@V7m5l4CR*(oOoYpDL<)*;PbRM>@q5Y^Rk+zRbqHsI@hQOnU7y z>e7#uMpnzsds|S8tsnPt2Xt#HIs0;Ey>6>GNt3&htP1h0TQZ#@Onl8_C>pZ_mu$P_5lA4SjmqK_@c zU?=eBIN;CJ3RdTB~^|tWmA$&Si!{-H;vJ{rW8GHtPn-~kw%@jK>u*sVvmKeqv zf|>Hm3C$x>Rqy-EUC`fX-F4gG8vkkAMa{9y`VQd79(-dS<#tDwuU{;keU#Dnzv@35 z_#qg6N@?8le16O1VcFf5?J{!zq0zCz#3ZY#YdrqXBnlwZ(X+wtcXF6$>;J2BWPSMY z8JStdR?CAqyUROsE_Ws*9{y%+Vp;|u4G zN=La={iVi8zt_#JRCnGg?)pYI4a{7q$Q;e{thotHOMU15^$2NO5ojd(U__%>Na&b< zD)%UP6{EAqvtwv{Lx@C^JZ6>E058(^C}+v`wZoi&_{1-f{Ga9@qj@Aa1U~IDns*tK zTW~!Zdq2&TW^qEZJ<6hS;JrBVZt~#v#BSbs^db353Ezq=uUE3tWJ%cMbg^sN$HIVk z--lw~HVc1K8wg%-ezgFrWt2IFx|{YQ|&v(5&?jZ zkC|~3;1FkwLv)hv0hQ(DDF(nPQ+d=o$o4C$`q~=}v|p`?xjsQTuIAf|{$a|AhkxT9 z+$leAN<@FLT|zZWfFDs4mqOV3lBHG$M=2H{3+kn6gVbG$?e{^?EaB(`@65ug=`j+1 z!Xe1s1U~ZUWn0ia2X)nuxngp=A*MhN0Z{?{jPWPB8`r%!`U{5#jvLLS0Y+CUJ`ZR& z@CYi@@ynMK0+YJ8D}QOb9?E{5E?H00FD=ndA{=mbj<2_yu5N3Jzl=ZEnlkkg*F6jO zqwd;sJxI-+aTwjV0Kg>IiPZn@7p?e7dMOdI1xi@uD(5p7`mbSEtP(CvXH3V4qIOS{ z4UXUbgp=2{(a#$s{+`3Io^au#i5gp#?XtV=&Z>>M^_iA8ese<-2m}Cls(iV9OMklS z1OW<_D|L*4gHKE`&Y;F$-($MY$$$IH;6(A-C0!10`HkO6e(*0p6fEb5&pv54 zrUi*-ez-Q7HB()9oLwtFAU?j8&miTqDBgKcS-8CTI_QM~2u#Kc{}wVa+(doN#7*vt zjP2}My=!isvO#!REEYVr(8H$XgX}4&rKgdKW9#((i`IhRBa{4TzVGI*H=>VJ zx1@wW)^a(Wpblas+t;ISez@+0I!LN#^au=ObCF8j?+>Qug+Ie}q5%7yzw>8TfO=65 z#7E;st!!7U`T{=nWS>=WGnP)rrsA{=w!{BkNm(=6)t@u-d4p?Js}bRKhY4zTEF41XqXrzw=)woKfw2Qeq2qTrM;t z&ixaZ{qR3WB?|EWGcAv;hbMO*4gTT>2(W*MWAYDiyx{tKZLYs#`)y9upKeJ!r!3&Oc#AW>9#=C+y|6^c-LCrI!Z^Kw0>m=g*M8~Ga zM9Z&f0Yy{sp$OX2i9>ZQGn$|DXzCjF`=_*~@uM?=7z=hA%rrQ47UaJ~5ABWh{FJpb?6+5y+aWR|ih6VxKsin(e5`a=H zywn!AVZG-;Rl@7&v`bN@Q~z}mUqRw(_pr&vyU>|+eHb@gkYz3r51~5Y*YdV$vW@m$ z3e4qM-s!IESlbz&v*e9s>u%f0pm1@=^JgT-8GI5%TICTGxP@wYsaoWZ&F*7~)k*j}Z(*`EgZzR42 z>@6lwx#+d8f|;F7`Fq-2ZxyS~y{SpI6RvCGD657&@29wapi5<)Lpm*gQ8fo?!`)t* zHrH-^ju;*?6T_o4@T-)(|umc=Uo}vsHm_l+0g+9_g@A%m;7Ee=>zlR zkkFTv?^#px_97EW=1amF`i+D<5`R=_Dhv`<{C)*bIBM{E ze_5aUvC^;nrE%>q6Tt0n?D%JKr}W$q8q^WttdoKHJT_v}=9;8l($AO%4Mou<=c%MShd?T{HKqMg2AbUbJ+h8^FU7$5+93SM0JuJQX@Wqi z6kQE1ftxMJLtBqVNepGziX$M*zFzAgS0wUPZCBbTE^ogK%ivAEU8h6M5hd2KI>ikv zmGREBkFB^(l0*oW%bev*Ss?-JH3$=j2aXTg3!@R?XuPt39B98z zE*LaLe54wqdkRN;9|K`efG``!DHiU*+6;rc*?3rp!h}5r!%Y|E2i}aI77NWgmu*J>Q>rv8z|0w{NKC&<@xCDBPkbIrGMeB62QKjolZZGGN zP3yW^H9zewe>vBq-F{`9Z5z`VW2MtsRH^tfwvS^(w<)`$D!K$Bn$ntw|VMde^6?a+LI zj}%cp79!G#-O$R;OkZUlcfJXc3qhDigQhnzj}*711?cOY^J#MOBSm1_2B+v zAXMNjZl)3S$rS6T2Vz^o-x@JNw$GYDwdc3S?@`Nye+;z#?@`|}MBs9N>Wi2%M4~+Q z(SQ*Lyv_h_?Emu=>33buie?Lk3yhgoXvQ#mVTP)gXeu!KCo#cy7w4cXF4P)xgR(f7 zMJ^Mx3y`^-F%Sk!BYxiFPWbO+^4ej$xwBzh|83vHFF)dT(h zol&e@pzJsb{@W@1N_CY{I=ZFh-Rrjq{}z3&Yx9;i2FlS{yJ63G7k}#kZtb$Rni36( zs~;nta>9K+4q{Qk|ABlZ%p|PVnn7%q?rbr0tI4Y3qUeIf`nf5_55B{8;B6}LURe*bB*_El?jMR3@jE;3PI!uQ+Jej&2`)LHZw_u93vS!JE{ zj*&KJPg{Ls?&cZCKU~Qw`&vom<4S=p zg9>lLPtl}AiA2~{!j_!F%GTCo&X>Sr-wm|&-hkBwo)6Z`#(IkQpjWPoeNvoSVcL0f z%<(%qe}#MQSzE&k+7dC#Akf>9FkV%#Vg1w?Mo1!nkwv4A_0VI`jBF3 zNRs$&rb}$C=a}SQ@4!%v0&Y;nI4Ph>Ee&_9htd@qS{l2d*ah+2GpJ~zqf8Se2{S55 zZI+dVAWv3sdmQi~HYD-ApJZ#$)v=Z+IGRpIh^H|y%}e1>I4 z8M$eSsj4LhP2*i!KE|GYk3=oKK1|ru;Ke&Nd3Fo)-X#+K-u2rytr)?Yvt%|NVAKy% z*@CoA;Rdr6I$+JQaun;fm-3+DA^=Yl}gnLwpqci`LYCGK-PeyXA7VzbJ2{V3pQmdCu<<P|5i5X?c@1i}i zx}al7YR3Q}_Y9c5A*2}_CD+UeF|R!A=Lm=6Ix9XOWY`1+6g9K)gG}HHZ~zcxa|}Yb zN27bKv4axz=Zb&7$C>42>d0fkAv8(WSL!?9;*HwTk+e@9sc_Y{7P^Z*$i zUa<7_Gu{p3&Mv>~fps=H!kiLij`dr{q)SipTtNV6+K;$dm5CCVDXad>h|#N>iD&QK z54>ErL5I5=oSYf%akG&?2e4?Xyox_^Cp*I%5S17y^-OJ#uy%ER#F80U!n5 zzx-J)<8)a!&P0MIMHC9mi~R+AYxgC#+dc9w8h{TYjhh-I0p18xMfzo))u5dPt57{+ zWaV8*^?clR`MLsM;!4SX+n=HB$_4*FkP|>B-52>E=lb}Ef2C)A)vKrd1?0ABZ=I*q z(|&j!)@_PdsroRjcPF;T&7YiN27COA%I1DFI6OoXl`u-8)0_p3WzK!c1ru>!2u zBG3KkV31KWe(C!UVX8E3(799NTeWswL)L|R#IRT=CF83%Q|A)A9UmvT5t=8e_yHB< zJvQ{yHq33YjEkA`?)z!}1kk2A4<1QK@pitZnEYjKBKcyu`uF4-3U*EF4>p^HL7$X= z7)<^0=2-HCiBzMKYC&g=imK%SR-8E4EPz&FE!QbM=5j z@6;d~%pSpQI?};>$B?H1cQtmw5<%k$kKc@#dg)TMH$W4Q4bn--A}Ds8vW}kFCzUC3 zs%C9WOx9T>@A*Hu{rEs#j?_c-xmZL(<&pEQXW!OV@5jf!-{^S-$qf<^RK}T(sf{1( zb8k;gyb`Nn&V5myEb3^KsKBcmx~u1i)6JO??A7_s_nlmtUVhn#j1bb5CDbk2smtZz zJGaeW|FVSR*##KBh-IjKJ(0n$ zUZ)HKY!CX=yNz|M%U4^QGPKukT~0pA;pB_#cXv|=(#@vX*|H95hiptCY!J5uAMB@m z1Q1i)(+k0EkYo*#I@Sy-QED1xi777nsR6Cz%s**tSTjA7Mq|9tBhZ)uzjr$85i_2P z+ysL`TlVu1@YI$Q5Vi+C4Nz4e40#A00YPeRD{EUB7ZQYqtUW6ExeDZlD97tGP#$WqmWIXq; zz;W4B_CFG7hrWz*ACMk@6NS?f26zS3UMO zwqCMXjlJ(jMk?jkjV5+}K@EO^KX1h|_Bsa&)Fm%(r6p5xcF!%DDH!;M05kAWCi#0Y z-AvtO(zwxwimEon<*h}%vSrz!-IL1A%H_wKCoLCKZK`vtBgsN1-9y%7b;E|MydQWM zI5yXxdAhFW`&Zaa0Q`bwBTw}c6AH?gB_So^ch`i2Kt~$w(JEYbXQy@9$SV^Y#cPEN z3%l%#lOgf@OoMyt&r}2DKKW1U8u(rhCu>M3U*=j1QKn(o#mn5C?PuO)x}5GG{j zMV0pY$@VfI+vn|7fZuuKO=)jTbZAqN^;7ZAK1e=T6c4sdi@HxPGJZ?;mdVf6E-*7#qTj~zF52pX(&bP6W-W_mo8dbtu=M7lE` zmkE;1?5KRl7byPqRMu^Ckjbwo&upGEF)@}_xf8;83PD=$mBri9VMUUVoVp~`2SGT| zU+Ch4l>2>uMq_=c8tQoRGuM^!^W6opK)Ud)$%Bl^RXpuNAE-%b&K@ZjDC07UTl8Q z7U~`^id;|~cCjtk;fCMF=zxg(+G&XUem%W>e{8!J_lXtHMS9Qg&G4PNbIjz%V&&7P zN5gKHc}Dx3zs#NY4NgB%<~19Jx6BaGRHywS21%4?H>Ei9GOb_{#4!F8G;Oi2!q;Ol z{kJNc7kcJ>XE@`wIM+$GK@pZDe|A2p;~gY*T_RRMhtpi|o~7g)tTsE=JwNzO0tMw$ zVEcvF&4QtAW!Hd}D1z5A%W|?^Zbp-bzA$!Sl7+f2I1od{MrLw!i;yHkcljE0`;%zd z&!teS1=?XtjitMvJp|O3cFj{xOxDu9>yuqxi+*%Dn7X1WbP*8oE7(pH*&Ea+k@zm= zlM&^H1jT{Hi;D9%b~9tSMx5ixf`1PQ%=x9>8M7v849^!*%=)Q9w-eF--1%cJ4(spY z3Z!CAknXIChAmHYXc7!K8Cay}@KlT3yGRw(Stf)`^BH`U&5uLW?r+7WQPWAZciuU}c zuF+lJgYbPLrDNwgo6Dpl!2$QXR?p_lCVU+9cgo5OPW@U~AC*0*it07z6LqlfQ-_S3a*_9Fn=4dcu@$?{45QD1ld8T!NU^Od3SLJLyJG0NroUfgA^R zdlynb!3Q=qu?>WVdow|ETV6n(dG$*+wiJbGGru)V(j-m<*yvW&MEO_>b!=Z5Rjm(L z&qYXMF4t^R~(AF)>+aKVkAt`B9q=E8$#Q*2!95 ze^0Jk6c>#+#wX6pV9dLp@Aox0X#DJcmrmq2KP0u&N^5i*eL6?W?Z+wW->O&_nUcr! ziFkr25nf!<+n%?R^Q97;lwF_NIEbm(}{~>LQxwcgn@smoBKs zUcG7-%-}lBC7H7OQ2x%YHG5*RVC^c+1b%XBkUbHnZqWSsiSgSyVF9^KaqW`huFA>8 z?D>UPu)Sp-m9mtdP13P*U6ez;IIP+fc2g^L*5-0Z#RQ0vgURhp_Pr+J-K%vZ{N`Fz zXPgKTCXF1u8)fWF%tLfx6oP(N^v8d`b*^88yo`5uxG7s(Jq{D^mEbB^7Z_Vn4@`)Y z|0%3O(!^vQx5!d6);{}sK`x`8S%fpV@_FtH=b6J4mHSC7^P8eA_xOOURMYtG<%gm_W!NwJsj^69%kT)0Ehw=fkYUoQ^6FMM7NO)GZB%Ciqt`Aa z7LLk=i!KMbE3`lBg4!c~-KP}?tha?khZ$N58%x}`+Zp>RV<5fVt{gb~DFfSiP9F{N z;HBm7&d3%uQ1$h{Z$Oc;2eDS1h!3#SjMc1+7euw`bB`+8Te&jdrbH1cmQkMP8hJCx zMqh5F7I~Z8_L!{rtsB^D%I{{a63Omm{8`|ItWP{FUudbe^!n$P>u$6hTlZXt+4R6~ zX#;BSvq$T)cyW}7T<-4T^g3nN78b$gd8;q_M%L3lmWNdv9=)F&L?SnGrEEPHUnpKM zXn*E++RmNOxE4tEs4-y}Xr{O7T77kINJhyRv=*2y5qw<7s&E7jCPnB@1L3p4fmg-I5tByRO_y0|aj~8TyuH$i zeeFl-<9zOJG^FINlTY9GYuaT~%t&)mw8=`@8H-a#VAP{FzTz>Lp9G3a&D~1}`0BKf&cUT@ZUDZuw~;m2I|FX47|4VL4Ye z$WOVM$}XO%$I5*kkvnSn`^7|FGOMuRmaF`yVp)Q^@u&h;wyD*L(e&b)SeB9#E7qED zmG7hlVr-YWn1?(5pR}c|?>9!AAbRW&pSnzQMHb1$MaZ`08ZkZ~rQ(78pN4-4Kl4&+-X#vKvdmXs-L%Y&@K6fmJ%?UFtl;pUgBSqg2jS$TX&_K5B%J8xRnW z2`U=C;8Ko`ctr;*R>{@6Fr4%G$q=+zH@H=Gbeq|xv00lQaj^MZDH%7N3}1O#&%9GN zx$i{Gwp5tGeCvaB|5c>tWwh?5&vz==CkKIT7rJeBZGKAN6aA#2f2WyUDJdZ?)xnfxG4I>}tM_>H!V~<0I{A>S4{o<7apG&9jwdS=oLhmGHM7 ziD6a!(%ID642%vh8N`k@9C;EV`d}d6;jI-~4IPPV|2Zc1`9CMUTh8L3Uy>mLj%J1cG|{-3sO}gD zT@w^(a6_0NnIxwDPwfJKY7K<`G2Bf$-1u#~08xY~GRPzw_@{Ah1R=tf7a^9)^(!4`rz8I>C4zZTkM9A>{9Pxs(7F^YzP z91IJw|3H#?B^`C!q3WNz>dR^lM0q-T2nvsMvrmRHUgb-%*!z?O3*2Rqu22ac|KTxB z!8$s&Gp%UX31g5r+SJ%#O8bt4uWTwM-)^V!7+Q8)8I&4Mqi*26-P@jdyt z(Xk@AJNKFmrxug5H|Gwp3#Htna|Km)zjw}R8wv=9HL<-_$gHa{pr#t-_8X~9<|^;$ zd8Nx1{+c4{mpB!ox{%`N?1s5?;P&pa-NriI6eW?b21polVOw4&v-7<{;ksZH$9L;n z`LJtsk^!yo#BNZw`@;Kf&NZI=n1uYrV+I+{F8DJjL(g6z@WhnFXt1cFjsut1H@3fX zIiK@mDdLgyxNY%l*t-YX6xE^yM#+z+S1sBt9k&`YTsQ}Ir^ja+Z0dcnO_H>YV70tM z7&;$S1>a^NI_PzAg4Dt`T^^EPxq01HL&z6EOWxATbh&BRw&(N zns1O@5YzfJ0k`QJtBzrSK7wL>yVeW6lU;b=EO zb}K4*y5k|neVxsY7y-WsNuB`EGDN#Xgh+oCf)IV7sJbqUU1b_{IyU69cp!O5Rhwzm zm0jsEZ0yy6Z9slA!SUdwY>K3!GB0E(2~mIv0QrRQo7J;8^zm-=rAt|?8d~8Tu{7AJ zU>AL2=2I9*uWh`Uh0nj+{s=@5uq>yE&_=tq;Lt2Y)b^AJxot#`q=lu6q*N*<*<6fz zjh!^v_7?9)Kh0*7KOJTWUn{vUp~+-G+QvWpW=!aWLhZ~r)3$VYTGK zAS^hw+EKqdqRen6ZGh`8{Qk8|-wN$~Q%9#Txo0hUEOGOv#N0|b;YQLD|9coRg?0J3oC^~0jxSBq)UYyQ;s)Wr@+niy5 zr?Z3D5xOP4wJ-V=gP=54TdwI{8!P!54?e4}s`BT4R^Ha$x-aD|@z!}e5%whHmlet* z-G%JuO7-dP>OZa-)gxhAn?80Lmc`l@yS~98&+;|$&d)P>hqsCosZ~FEKiVsVoqsig z7p%W!K4y1RSzKZo^H`A8q+jZ{{`h5Zg{@WdZAkw&wmFF>tW``8*cen@qhSbmf>D#b zSrV>^?Wj{(Y~iUy$TzKi>O2VwA*v*gWM}X205OwQ=l$u>XCM#rlqv4@bq}fhml^U6 zz0XI=v&$YgP1lyRtn%UUzOfB;kPV&hKnGl(1% zUx6~l%690cB!t}#vVF|dXh0AOB(j=4Q3GL(el^&FJj>rYQ~JPvY6ek(3{>slKc8=q zuTAgG*)?P3GuPQAGOoXauogi2EHNlrcnF6G{>mZP;a{4Tvg=H&$3r?=F7*en)K<1o=SFJv1cSdl1*Q85~1A z@6d!p4=W+e7w_Wtb<`WQ3^7DJnO-}qbWB0$Pc&kbs0|2+YnqHVa?RPP z@;pBVlI?EyRv?zA63_WklE1iaG=H!*K6za9;^KD52$4FlyJ5!*@!Y4iaT~|tBzb&J z*39MR+g`dtr!is9!Zs43BR|TtX7>*fBk@g4#5f_5^6tXwi6hzsJ`Oeo#&Ku5!dlvM zLbb?i;$*qziymqWEy_z)q+G%$nMcw0gN)2PMX6n1m-Dq)K9CLAjB;Q;mS0(E?x&n% z(#dUmk|g$JiBH3Auj!rGcq%BwxG%gV{2QikbfwA`P%+T9BPx5p(B*v%T+}LjJVB4L0bVPiTw&EwE zrrnNmRiH$5JtHlwv{<=r+iawctG@I=Wbp&8UY@POWpA5R{*66PW5y-^*fkR7ext;s z2VA>Ku2rw%_S?vl1mcaxXA(wjv}LSVR%n-=9bfbao6=m+rHm3Dc_WDxtc=oLzosNh zmiZM*jTkf1Qn7p0@Kkav;@Hk8n8Y+IQ~nC;TTpbdFkbSsVX{#EIPI3~R+YoOm0-nc zUS{UY1qGjo0?CQj*L%WIAA`Q~DrywR{CwB3k! zeDA}j#k#Hya~I}ri6jrDUIAZSp5C4f<@&Q|ve2HUR!|AHwa_MdCvJ$(T`O;pp3gxoOvylO|ptsy+Sb!KP9$u4n z_+^U2PJ=4wfkS_`O5hSVBY=p=A3_jg421a(g5Km^`5WJOO`HL&o)~0d&w) zt~#=Lp&(uf4l#o&)Zued_XcV5ge(XR{0FC&d3kG#Rl^*>BLTgVjd>oCH4dTkzgO;q zh+&lwg|r69FyPO>^VUoxj3UANwoJ-n zZ88ID*$LruL&VS-DKXIs{ZzE|jY-iA!msM08FzMKS1YSqNmh-_%GK()avFBR;IMxO>MZLVEQsw*(j$_MAYzx6Af3FNdGy z89&DbGLUcYDYMR>r)brCc^HamPnoWg)B&>6GyopYMB$`Ia+;ButbX zXL5|Xc&@TG4ErI|=lN~KJGmpILk`+_9o4Q*^x5V5$LWSMAR7sdml=jD8 zPCn8+16iR70v)w8=&&&E}anOH>YM7dr3e9iFPeRIRcMC5^9 zeKPTE#`6c4r#xCi%eg1t%Tz_$Y_1q7QaXJNLk@8$$@m5|R=r7lHdtI^JG!jN!0IL{ zw_TFq%3Gc89WfG)qNu{p(4iA?vKVCx8$RdH^Vn4}XF31y_{$1Nhn;YU||D+5@qg9rI&^shiOGF+55j7lBH0v5h zZl@;99JId`587R+v1&%LJfP(AUh^U;3>4?0;8bvk)JPL{&j~?i#3VfnQmtPWE-f*j zQ)1&n=AZgr&}{Lxdo}O((u`N)qhG}`KSsG%gA)8F-%;Kn*q@e)lEw0vKU-og>A&b; zR>NCRpeS$Xv$$S2gTf-oDYd^c3zWhI{NrE9B3O5lDr;Df=S=lgpEcp{fH zmND0WQ48TJF$|12;@#VV@sHrVQ0@pM87o%vj*=;|mQ_Eq{%EtOpBRsrJjkT^^o>FJ zICjH%=-kz!)nvj(%fn-#Jbb|P;e1;w14qI8saXS4_3*j~Dfr zbSswdGarWa+vOnSFT`)~6|#w&4=x}6*gPxuNoM~-U}Ic-Y5k$Yu2_NXbo%!Q#rTBY z>p2sQZ6W(l=@s5JiIgKBv=>_AHxgd&3H4mhZ_&09vUHow`!eYl>n+v=G@eK2eHks9 z)cr1hNM2BrE5$j@VZJe&No1C@XRsxvE4HeXKa}X77q*a5K+TD0z0tazLEA$Zr`;kb zZc-|+r?3yxIaf6rt*bMX6DV$N{W;Aix}EPSGIHfE{GvYg_?Z%J_Df;%eQ#Z24dvkz z>o*npukY_G>rVj8NS>y=U6oNn~;Ta1o7t4}g8?df3ZA(6kR0csVQJ~s~{9)Scrj(<_sfvnrNYtIh#75vvV4du~7wIK7B#M8c0mkS+BP2-|O{+?|1jz3DC1iRbA&Wa~R3f!~1gG~HoPFXcN zn|%polT(rX@|qWBS~=&2g)b#7Xq8W4iC{Syq(gr&uzB6t1UJ#tAT%UrNPgZG`AeEqWgk;4eO(lwi0lnQ@Z|UQF(cEcJZyR?& za625j^L9xOqp2zis+d3xbN^oJg9EnqFLu$N&93`_ZsA} zlW%4glZelhR@G5^^1cvI!JgX|tGHikr`5I>eVD;?zlcq{ZSY+64*SzJ^$}GhYd&3- zOc;x>_D|e+$0P0mhmv|TGW*IXBNqE`;YC+gJlRLv12)X8pnqg|gJWq)M&Df@RLsm+ z!Z7xjMrY$z-jklRRr&20hQ>sPaHgo?uyc3pbS+GB8qAD#1> zrESles{K7y7GNvz1MVgX{Wo}y23Q#I64?Oc6tLrop6WQ^Fw?faV_)`jG`EXNG@$h` zCO}XsFbT5oS_Wm^bS$65=nabx9wsDsww}?TM{&5*^x;dH_TV)#9yphi>NZZ?R4yNa zdNRzk-8U&t za0Z!%nfE=5dRBYWfiOvhfxb|M#Sm2gUiM4_h>HbD3Zo~25#2h3pckmKz=P*OIBHJK zY$2E^NJ(?kocJcjzkB}u+x!fq8~hi(Q3`@+fuu3NI5?n=9Fcht2<28=mXX;07eB_@V< zg~~&eR_)-9Sd}2OMmP?v^%IXXdz^eEJSRDu<{sDq1=Azp@;HUFKvyOj1bKbW8eL@YPx0ub)M-j0^1pV086yJgnr_ZJ{!r1`cgZBU# z20{rIW(Z?JfpxhBBTzAyUAis%VWCu736TS)_7w0RxP}Ijrf0)97=cIbg3xu0}x)rxkhj>1S1jBqL&_ttMl^00LgR-mC zj06O%w7UyuvY!Az8c9etPl_&~tiXoQ zB}K-}`_57m=xZL!JORZ6G!Fs&B#<=6-)a(1bEu}wMj2%vJR%2VXl&ql^}Xbk4|O*(WRrY$5F;@&gu&K1)-A@tJYxidgeD` zb(p-kQfGxF-J}1*#n3DyAtF6+k8r@Y@$(mBhZ-;x=}BA^tAO&2&w$mT;wHS_$l@%V z)1vTb$qnM%Xu++|oQZ*gK!^cARlvd1fj1xpQn4WJ-|z|Faa8P95X+G2oP+}`(Sih& z?fRz)^FADM7pTEv;Iw*TBf~IG!V6m7Kxmp4FWh(<++7&X=emU0T-oVKyNPWU$uw1w z%+t->8nr3BI>}N{4d5#PrVFr20SMxSpk1n$5MBsaX_B>MiZhe)kuaXfP^e!Lbu*mN zRj=tU?&8cBLwz?@w%6dOAHCrUwSG+msVb)oNMy<^ksyA3A7&5dQ-f-Nl#;BlNJtpX zg#nT8##o1$v>l#l!)WBt^@$dBT&3Owv%Tl9bI8V3gG01^o*K$w za+D;g!`$eJEMp+tZ3ufp*OGF-%#419eNJIFROKoRh$J#^I1t=qiVzyN4Luwh$@!a& zf|-P~k@PLYAf&rB??S>;gFZ(%zlqGd8aSi%=o4de6nOsBi}ykCR9|$w70oJXgMJQ)mUI5XGh#fHDV$ zL>ov*e+fZ=rE#$68)4Xrk*!!t$5^OrZ;}MHc2)gzQOxeyWS->kW-MDm8uX_?-b%4* zfKZm50wa%ov(9E%$2locY;qTby$dH~c~%ndJF6j&kWZ)>gX7u*OO*Ity%Bw443;oC zE0tFgxM({4M1X@E=n3XRE;_OIa-51`UJ=Q>(T4>iQ-BglFK9uvNP>-w{V7u{iU4k_ z8)r!Y1dJyg<$E`T9i;&xT=p?I?mQ0-efU&niljL%&M1~vXLrF8%2?hyYH$Uug$~B*D)4|J2D;p@qh1Arc(eb5$p#k;{6=&V)KEOjQRmJN+<9; zu`nXT%fuZJ#izMJBk|znkC3XJ!SQlNJP2A@n;{d&oVN zg4=KZYcJ_G)TrU9b@EiX5>DxEKRn6fHVIelEVp5K7r_$%(MhKEReL6arN8PE4^$t+O9|FlpgGL2j<-h8}KLh>qL9h{Di5UPw z*)-Vkn|&a8<;_h{1`-(1@Km0j1}xgZaHGKh8*FZXKk+o%uj?Id0FXTIQr*m+K@eb+ zg6)6C0W(xa1hFocjUi%6Kj;=}Mvy@FVUo+h(>+lH>1Z@OwHQk~X8k{x#xyAl%Jp09 zubv@t#};q@nCsV#ni7x&J@`p*dmwL~p*ERaMQ7wTy2`E$qoi(|6v5wW40gOJ+#`-z zi_%iaP>~%-R|cAo8j9IkWk;FI(kWlRy~!D2LDqwEsHFXyR(g~nF_`U1IAKVd_ehaY z*7u;43Y0t~DGRuwF(m#87+AuI29D&M%fDtf5(5(AZ<9xyj@L%?8ezZS9@J`rUf0#NFML7#y`L zSp!f^K3xs6E0HirZU17=if^LTOS-_l5ZLZllaG-?phVFBvI+QWaBL0rLxk2y(rZ3? zNRI0;MURblo#~{V0ChMA07RMd4uj(rq|*05yyzcx69xUUNbyakz`CL#Su==buA6G4 z$GFG2n(15(pq&^u2_W1o2pbMTHvt=vBt~g&Lj29V?Ojd48nY3w(tvS>hMERXp_E4K z=CBW75ELxPoAmM8HIz~%zyGXseN$3qQsBXqHGTk3JSS9~1&DJD%SyecohxbD$kC1x%VhpqZ>1#`(ka@Bei` z#4%tATU_DEvJlDfwv_WHB<+R?3U2-fB7Di8rJ1;^5ZI>C-uF<36PSMqDH8zZ;su2L zCwm*HbpIHj4{0EvKX=Vc5dcKBtkntRivU6>t<#DN|2=^u!~8e_I00lX)IpGJ7K8?| z@y~Ap@4#QD$p8D30H~&DiehhgTXy@fYf+XhtFo&S96N42bDmULe5i-mt{4Lrb5}EI zt?76Z=T?eydD&h3h6N&gDG05JAC4vf41pJY5U?N#)9FHq5}RqW@EIrI5EmweBzkpv zGp$lkmI4q)aG=J4`$8*{1cDU9O_z;a@F7$QrV%rc+(RU%{La{W1ZUfmp?p3pIEqqm z6!yEwBB#8`Bimt|L5T`BU%SKL&|PrC`<^QkGm?s_{$CONP<6{Lng$fgBHk773ClD! zS)&~@3nFr=W{NW)c5eNI%JTrZC5}>mzdXN1pk(Rz4U`H2_0RE*<4SGfj03%T{cNQY zNR=&O;AG!sX1smQiu;ysB?jMZF2cQE?Zvax!;Q#V8 z;~Phag|qzoPm6jwK!!*5KTkp-<6sJ#G)ST0-89z#$)>i;YLb5x853Z9=tS?kp%hykVd?drVu*Ng%NNFUmhY! zSh9GUYGDgYnAi+MNt!3jU_GOO3E*7w4p0~oFOv0(s8qnGPvr@ih-}Wu=W7Q~fG!RU zgASdu1szP2r1=I>8&CyCpk@dnjVK#W zgvikd-F4|v|AsJo99+#4WAJy!WB#AAt~{=(Yg_LOAfrN%**+5pVF*M`5%I?@4WooX zAz}ali;y75pdv%74;v^V2xY{dr9t<@tz-y=Yv4M$KU=o~Cbvt6m;VzdcBVc53 zC+2%NqR14XHq@}xPtJ=~9_gW{v&>Uj$N&y=k#M=&Z+gQaUzJZ!rN$w7prDSQIwLNf zJxyh@YX8d4PTHIialJQpus(H!ai}Lni5!HaNNXIScwi#1 z@BVAs4O9jTw~~zlGUxicR9>Y#0vns>pjVe0RBtU4S6L-%gCn9IWcW2ARBCzsog`@E zuVbhyxrbT zDbc50=xL#?53CtMeScFKXlrRs%zd9Z2mU)a^DoC<*tj+4@DyavdNNV<-qhb|SFfgY zIp&Gdx>e_H)CGCg9F*lWDLOv;;J;X#sQAT`5dM_&G3rSp%I}-@HirF@H}C=b(oU`# zrae~;2w2tcq=9iq3a&bg;g{p3{-6)qU@f4aXhXh?)|UxSXPKO0A6es|PsKSf0_E&X z*2Nyf6Y?9=<(#57hPMCFN9n$=YD9&7=>nXu(5rIm?INdwNqrm36rs8vl*eXgEDnoQ zb2gzS_qfxdPc2biJ~IQ^aE7azY!KshO!aj@0!@}e7NbTq2bb*dv67Teo(}%C4_#@X zdh9pxq+ciZi04xNo7qCL0Af49`!%1+#qo9B>y*j)^>}oKas^i+H7<0WcTJuanFax#FD&(!-MvZ*P@uoB=q*Z|xZi*AqJOh~F zSYr`P_ za;-psA+&g4kc?LBiGAs;n&-{4uqy@|)k7RtMF+XhAd1j9Pc%CK`|vjObngz4!ftA+ zheJMnr`LBA|A95ZfZl?juX6@-06JADvAfu%phi zDL2U99evCNb*k0zBLd8ynH5ltuPu81q9l35?C4LDdo&FU`_Zua{ytyXv>p(>iVe_c zx#|sufDr7oeYlAc)XXGsfFskcKjDZ=D*8O@g+U)O>VJxX`g|;pJ?8miZlNA#N{|NM zEt3}Y!)%x+(!?x(%YMjcU+I*QAnd{31lF*_MT~GYB{mtuwLw|Fo>W+6y3v{wtXVmq zqle&NNPTGSLC%lUwLqnl~fA5!PiU+uf#w=qBZ|1?rC!tVu| zx&ioR3ROYUCg+c3nN6^k+N zmX~;stNQ*~KHZtT1qdp8QNzX2(sdW@MypRNuC%y{Yz4^dL0`Q9cUd|Pj}yNAb?lw^ z>Nm0OnijcIg1w(V3ylY-*df%7%(fzA`5eclyO~1N7<0rz)ONO(*UzQXZHIH4avdsEXf123utkT3$PtzfPmdd-x0zsmhm*sG_of3Y&;x_TbXEl za9^vV%g(LzE*8-O2FPmI4XN4P#Le|%7wVMIf9gg|Ap^bIjf|-Ry8Y7{NA7{Ep-|4* zSAeuxhye3w6%BWy+09(*0B7DM=3IsiHKaLxx0_o9qnw9@2?YTEAV6qC?pL7heOf^~ z@LA7>va`XZH^XI77OI5$obWE&`066UssduCbBl*jHb1Ys{fY|X&X0>`2YVuO1)H+c zLL(O_?A%vrJe8azsqtuDh<|SAI?wrx7xmrI>W5CS5`b8YOUfc5;H!kvr<{=>I`bCE z4`JT37$NI46dj$j;oY>wnCWZ@o84P6wcvO0+d*L>3y%xokKS;k6pGkU?y|H z+nVR{a)2tbp(@CF@bi(bo4P+y>OhRLiIe5l4x$$7!RsXuc%@!keaK=ARDPgigEg?IukK~C77>Cn z5{}u<3iRtIfsMJv$==EP^UXhxteh7hfjKg@bRjf{Fsj4QI*IzJ7BmBzCR#TS+5Wi~Lg zcv~j5y!^Ho^cAMKQjN-pug3@w8Pt`~_hhh<&e6+J&}D5^*%l;4SxLFnV4_q8xp@U_ zD*G>8j{gzB+h1NUZs_93 zcw@`!JDwO((no(XQ}%&j54|;8k1<-x}PL396c$h z>T7dDzWh@?lZ zQ`TJ+lL&Rt%4&@~8;N{M_u>ePq8%Rq>elhCpTVIdqC^8IxiYsKb@nk{;;_bpy9CfJ z3Rz3ia5LN>VPV?7M* z8jHW{Qkfa=*o|5h-WI!e5Yop zQsvt({xn*Y%O$?^^S;lT*ogrn#)?U(tORRs4bBhQXmDa!k1Apda$7`h(!JJ`&;Z5S`um|FaocQhXgL6E(+ zcIyoA!s{OyyE2W%uSZt6wH)w%T zO0DgF?yBKd4gT6t@964*D;e=v;huqDxDmbLRJoz{7w2Mj+1-UH3ZJ+^5a`~uX2RW8 zTjV@`id#VYu-VT#coPuX;#^YMypnG+`p}ysE|mwe)~Lsmd@hG-93sjdVc3hbbQ;-i z4ZG}bhGOhwyvOp0yBxW9h{OWAx3G9;Hm0os`}12JCUAC>Mz8w;vk(@uQ#*^%eR7N3 zR{4H_;KuyXYQxN1DjqTqNZ_zwFb8S6Ojf}9pUu$$9&VGJ8W`dKQ5Rp6G9}429!%+& z$7W*3MP6P+jGC?&3$TCD!zg0`Bny_o^D2doeWs7taz0W-ejv*G`Fc^Egon!l41CP7 zgMkaoOp==%Hiz691Kq|dzX=fU>RK?wfKvUt?pes@^kkCR$XoXr8M0j|C!`PU_sOZ zG5Ql$z`U=1aSvIvw>k}EOf+#T=Zhe|b?1KU$@lnjW0JY!We~tYayCWZ)vh@=*DKz) zGHvqw>zdLZs^Xk96~-5jztlCZeCz04_SlQYm$fFk z*F)shr&d|fIf)QM@}fMK+hy?P-@0{@xQa{=B5h5e4AY|X58W6rhn0x~%#Ud{Jr75o zSI8(l9=HB$JAvYy9)rfht;8a6@}+2}ZtW+#?H#K}`-c|VVoTrr8+X22fZN@Ut{F?x z^6oClc!^~P82FpbM1P%Wq{CZ&Ff5c-{Rg5+P?t=M`i>;bvx^bX>_x30hAfg#|6;2g zE9t5m5a=Wzq_2z?i>r9|cNH{QB$G;$P*p!w zCjMXu60CDcodAtU(FVN}G=wL2tNAmebA}083>Od>@P18o`{I$NfVMWOK+{5>Ei?pg zl6q_{;wCORa)4&bsRyp3tRFbTYZ7MLmXWbhE1^+)r{)QBqo8Pft1A4K64C^0$Os>j zyEZC{Q2nDTLPh&m#OPc6M*2W)>75ZDx=k?BD0D;=7HXrM`#-};w-tDXq4832)+VzNEu?QjV+?j`?nQ_<1i$?3e=Gk7tT@gE literal 0 HcmV?d00001 diff --git a/frontend/resources/images/features/2.19-tokens.jpg b/frontend/resources/images/features/2.19-tokens.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6650b335c00cd9f1003bf7edb3a09c0bbacfbd61 GIT binary patch literal 126419 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Žn@6fB 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 mXm+%{y@30YYB#dLq=$E241 button* {:class (stl/css :next-btn) + :on-click next + :variant "primary"} + "Continue"]]]]]] + + 0 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-per-side-strokes.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Per-side strokes. Yes, finally!"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Per-side strokes. Yes, finally!"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "We know, this one kept you waiting. Rectangles and boards can now have a different stroke width on each side (top, right, bottom, left). Switch from uniform to per-side in the Stroke section, just like independent border radius. Need only a bottom divider or a left accent? No more extra shapes or masking tricks."] + + [:p {:class (stl/css :feature-content)} + "It works with design tokens too: apply a token to all sides or a different one to each. Inspect shows the value or token applied to every side, matching how CSS borders behave in code."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 5}] + + [:> button* {:class (stl/css :next-btn) + :on-click next + :variant "primary"} + "Continue"]]]]]] + + 1 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-shortcuts.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Shortcuts that fit your hands"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Shortcuts that fit your hands"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "You can now reassign any keyboard shortcut in Penpot. Search by action or by key combination, record a new binding, and Penpot warns you if it is already taken. Reset one shortcut or all of them whenever you want."] + + [:p {:class (stl/css :feature-content)} + "Great news if you type on AZERTY, QWERTZ, or any other keyboard layout, and for anyone who wants Penpot to match their muscle memory. You can also export and import your setup to take it with you."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 5}] + + [:> button* {:class (stl/css :next-btn) + :on-click next + :variant "primary"} + "Continue"]]]]]] + + 2 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-paths.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Drawing and path editing, refreshed"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Drawing and path editing, refreshed"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "The path editor gets a full refresh: smoother freehand curves that keep sharp corners sharp, the path toolbar available while you draw, node snapping on by default, and the ability to continue drawing from existing nodes and segments. Hold Shift to snap angles in 15° steps."] + + [:p {:class (stl/css :feature-content)} + "The result: everything you need to draw your own icons and bring any illustration you can imagine to life, without leaving Penpot."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 5}] + + [:> button* {:class (stl/css :next-btn) + :on-click next + :variant "primary"} + "Continue"]]]]]] + + 3 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-tokens.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Design tokens, shared from a library"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Design tokens, shared from a library"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "Keep your tokens in one library and use them in every file that links it. Tokens stay in sync when the library changes, just like components and colors. In the consuming file you can apply tokens and switch themes, while edits happen in the source library, so your design system stays the single source of truth."] + + [:p {:class (stl/css :feature-content)} + "The Assets tab shows where your tokens come from."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 5}] + + [:> button* {:class (stl/css :next-btn) + :on-click next + :variant "primary"} + "Continue"]]]]]] + + 4 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.19-performance.jpg" + :class (stl/css :start-image) + :border "0" + :alt "WebGL rendering takes a big step"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "WebGL rendering takes a big step"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "WebGL rendering gets one of its biggest upgrades yet. Improved tile rendering keeps large files smooth while you move around the canvas, and the new text editor built for WebGL is more precise and much faster. Exports now use the same engine, so they are radically faster and higher quality."] + + [:p {:class (stl/css :feature-content)} + "If you have not tried it yet, enable it in Your account → Settings. Your feedback keeps shaping it."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 5}] + + [:> button* {:class (stl/css :next-btn) + :on-click finish + :variant "primary"} + "Let's go"]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v2_19.scss b/frontend/src/app/main/ui/releases/v2_19.scss new file mode 100644 index 0000000000..ca0969b1a1 --- /dev/null +++ b/frontend/src/app/main/ui/releases/v2_19.scss @@ -0,0 +1,7 @@ +// 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 SUBSIDIARY SL + +@use "base"; From 4fe73011ae287fd52b3baba648d0bd4aa4c2f8de Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Mon, 26 Oct 2026 12:21:58 +0100 Subject: [PATCH 04/65] :bug: Reject restoring components of a linked library from plugins (#12611) LibraryComponent.restore() did not check that the component belongs to the current file's library. Called on a deleted component of a connected shared library, it restored that component and saved the change to the library file, while remove(), annotation and duplicate() reject such handles with a validation error. restore() now rejects them the same way: "Can only restore components from the current file's library". Closes #12607 --- frontend/src/app/plugins/library.cljs | 3 +++ .../frontend_tests/plugins/library_test.cljs | 24 +++++++++++++++---- plugins/CHANGELOG.md | 2 +- plugins/libs/plugin-types/index.d.ts | 3 ++- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 803cca9ffd..aff38a654d 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -917,6 +917,9 @@ (not (r/check-permission plugin-id "library:write")) (u/not-valid plugin-id :restore "Plugin doesn't have 'library:write' permission") + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :restore "Can only restore components from the current file's library") + (nil? component) (u/not-valid plugin-id :restore "Component does not exist") diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index e7e9efb646..839c25e7a7 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -55,13 +55,10 @@ (cthf/apply-changes file changes))) (t/deftest ^:async component-restore-preserves-the-users-view - (doseq [scenario [:same-page :other-page :linked-library]] + (doseq [scenario [:same-page :other-page]] (let [file (cond-> (file-with-deleted-component) (= scenario :other-page) (cthf/add-sample-page :other-page)) - store (if (= scenario :linked-library) - (ths/setup-store (cthf/sample-file :local-file) - {:renderer :svg :libraries [file]}) - (ths/setup-store file {:renderer :svg})) + store (ths/setup-store file {:renderer :svg}) view {:selected #{(thi/id :copy)} :zoom 2 :vport {:x 100 :y 200 :width 800 :height 600} @@ -108,6 +105,23 @@ (t/is (empty? @events)) (t/is (true? (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted])))))))) +(t/deftest ^:async component-restore-rejects-linked-libraries + (let [library (file-with-deleted-component) + file (cthf/sample-file :consumer-file) + store (ths/setup-store file {:renderer :svg :libraries [library]}) + before @store + events (atom [])] + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (await + (mock/with-mocks* + {st/state store + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} + (let [component (library/lib-component-proxy plugin-id (:id library) (thi/id :restorable))] + (t/is (thrown-with-msg? js/Error #"current file" (.restore component))) + (await (async/settle)) + (t/is (empty? @events)) + (t/is (= (:files before) (:files @store)))))))) + (t/deftest ^:async component-restore-rejects-a-missing-component (let [file (cthf/sample-file :restore-file) store (ths/setup-store file {:renderer :svg})] diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 3ea8fd12c9..de0f09ed73 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,7 +2,7 @@ ### 🚀 Features -- **plugin-types:** Added `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission. +- **plugin-types:** Added `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission and only applies to the current file's library. - **plugin-types:** Added `Shape.updateMain()`, `LibraryComponent.duplicate()`, writable component `annotation`, and `Library.deletedComponents`. Declared the existing `LibraryComponent.remove()` method. - **plugin-types:** Added `LibraryContext.tokensSource` to read or set the current file's token source to a connected library, or restore local tokens. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 6d5e8c5408..c756cc50b4 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -2791,7 +2791,8 @@ export interface LibraryComponent extends LibraryElement { remove(): void; /** - * Restores this component if it has been deleted, including its main instance. + * Restores this component if it has been deleted, including its main instance, + * in the current file's library. * Leaves the current page, selection, zoom, and viewport unchanged. * Requires `library:write` permission. Does nothing if already restored. * From 6f185c624ff30f220bb27e9d3801026b79325417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Mon, 26 Oct 2026 12:25:00 +0100 Subject: [PATCH 05/65] :bug: Fix swap when the original main component is not available (#12333) * :bug: Fix swap when the original main component is not available * :bug: Change the shape-ref for the id --- common/src/app/common/types/file.cljc | 5 +- .../logic/swap_missing_library_test.cljc | 79 +++++++++++++++++++ common/test/common_tests/runner.cljc | 2 + 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 common/test/common_tests/logic/swap_missing_library_test.cljc diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index df4733c25a..56ac4a747d 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -534,12 +534,13 @@ shape-meta (meta ref-shape) ref-file (:file shape-meta) ref-container (:container shape-meta)] - (when ref-shape + (if ref-shape (if-let [swap-slot (ctk/get-swap-slot ref-shape)] swap-slot (if (ctk/main-instance? ref-shape) (:id shape) - (find-swap-slot ref-shape ref-container ref-file libraries (conj viewed-ids (:id shape))))))))))) + (find-swap-slot ref-shape ref-container ref-file libraries (conj viewed-ids (:id shape))))) + (:id shape))))))) ;; If cannot locate ref-shape (e.g. in an unlinked external library), use what we have (defn match-swap-slot? [shape-main shape-inst container-inst container-main file libraries] diff --git a/common/test/common_tests/logic/swap_missing_library_test.cljc b/common/test/common_tests/logic/swap_missing_library_test.cljc new file mode 100644 index 0000000000..64853a1a74 --- /dev/null +++ b/common/test/common_tests/logic/swap_missing_library_test.cljc @@ -0,0 +1,79 @@ +;; 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 SUBSIDIARY SL + +(ns common-tests.logic.swap-missing-library-test + (:require + [app.common.files.validate :as cfv] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [clojure.test :as t])) + +(t/use-fixtures :each thi/test-fixture) + +(defn- setup-library [] + ;; {:frame-a} [:name Frame1] # [Component :component-a] + ;; :child-a [:name Rect1] + (-> (thf/sample-file :library) + (tho/add-simple-component :component-a :frame-a :child-a + :root-params {:name "component-a"} + :child-params {:name "child-a" + :type :rect + :fills (ths/sample-fills-color :fill-color "#111111")}))) + +(defn- setup-file [library] + ;; {:frame-b} [:name Frame1] # [Component :component-b] + ;; :copy-a [:name component-a] @--> [Component :component-a] (in library) :frame-a + ;; [:name child-a] ---> :child-a + ;; + ;; :copy-b [:name Frame1] #--> [Component :component-b] :frame-b + ;; [:name component-a] @--> [Component :component-a] (in library) :copy-a + ;; [:name child-a] ---> + ;; + ;; {:frame-c} [:name Frame1] # [Component :component-c] + ;; :child-c [:name Rect1] + (-> (thf/sample-file :file) + (tho/add-frame :frame-b) + (thc/instantiate-component :component-a :copy-a + :parent-label :frame-b + :library library) + (thc/make-component :component-b :frame-b) + (thc/instantiate-component :component-b :copy-b) + (tho/add-simple-component :component-c :frame-c :child-c + :root-params {:name "component-c"} + :child-params {:name "child-c" + :type :rect + :fills (ths/sample-fills-color :fill-color "#333333")}))) + +(defn- propagate-all-component-changes [file] + (-> file + (tho/propagate-component-changes :component-c) + (tho/propagate-component-changes :component-b))) + +(defn- fill-colors [file] + [(tho/bottom-fill-color file :frame-b) + (tho/bottom-fill-color file :copy-b)]) + +(t/deftest test-swap-nested-copy-with-main-in-missing-library + (let [library (setup-library) + file (setup-file library) + + ;; The library is not passed to the swap, so the main of A is not + ;; available. + file' (tho/swap-component-in-first-child file :copy-b :component-c)] + + (cfv/validate-file! file' {(:id library) library}) + + (t/testing "after the swap, B and its copy contain the new copy of C" + (t/is (= ["#111111" "#333333"] (fill-colors file')))) + + (t/testing "changes in the main of C propagate to B and its copy" + (let [file'' (tho/update-bottom-color file' :frame-c "#444444" + :propagate-fn propagate-all-component-changes)] + + (t/is (= ["#111111" "#444444"] (fill-colors file''))))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index e78fbf3da9..884c9a7356 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -72,6 +72,7 @@ [common-tests.logic.swap-and-reset-test] [common-tests.logic.swap-as-override-test] [common-tests.logic.swap-keeps-id-test] + [common-tests.logic.swap-missing-library-test] [common-tests.logic.text-sync-test] [common-tests.logic.text-touched-test] [common-tests.logic.token-apply-test] @@ -178,6 +179,7 @@ 'common-tests.logic.swap-and-reset-test 'common-tests.logic.swap-as-override-test 'common-tests.logic.swap-keeps-id-test + 'common-tests.logic.swap-missing-library-test 'common-tests.logic.text-sync-test 'common-tests.logic.text-touched-test 'common-tests.logic.token-apply-test From 3f8aee7933be80024b66e919a8e5ac5c451bc936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Mon, 26 Oct 2026 12:38:02 +0100 Subject: [PATCH 06/65] :sparkles: Record imported file ids in the import-binfile audit event (#12491) The RPC layer emitted the event as soon as the handler returned the SSE response, before the import ran, so the event could not say which files the import created. Emit it from inside the SSE handler once the import ends, with the ids and count of the created files and whether the import completed or failed. Closes #12485 AI-assisted-by: claude-opus-5-5 --- backend/src/app/rpc/commands/binfile.clj | 47 +++++++++--- .../test/backend_tests/rpc_binfile_test.clj | 74 ++++++++++++++++++- 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index fee8a98557..aef0befe42 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -17,7 +17,7 @@ [app.config :as cf] [app.db :as db] [app.http.sse :as sse] - [app.loggers.audit :as-alias audit] + [app.loggers.audit :as audit] [app.loggers.webhooks :as-alias webhooks] [app.media.validation :as media.v] [app.rpc :as-alias rpc] @@ -123,6 +123,34 @@ result)) +(defn- prepare-import-event + "Build the audit event of an import. The RPC layer cannot emit it + because the import runs later, inside the SSE response, and the + event must carry the ids of the files it creates." + [params manifest] + (-> (audit/event-from-rpc-params params) + (assoc :type "command") + (assoc :name "import-binfile") + (assoc :props (-> params + (dissoc :file) + (assoc :generated-by (:generated-by manifest)) + (assoc :referer (:referer manifest)) + (audit/clean-props))) + (assoc ::webhooks/event? true))) + +(defn- import-binfile-and-audit + [cfg params event] + (try + (let [file-ids (import-binfile cfg params)] + (audit/submit cfg (update event :props assoc + :file-ids (vec file-ids) + :file-count (count file-ids) + :status "completed")) + file-ids) + (catch Throwable cause + (audit/submit cfg (assoc-in event [:props :status] "failed")) + (throw cause)))) + (def ^:private schema:import-binfile [:and [:map {:title "import-binfile"} @@ -148,6 +176,7 @@ ["2.15" "Add upload-id param for chunked upload support"]] ::webhooks/event? true + ::audit/skip true ::sse/stream? true ::sm/params schema:import-binfile ::climit/id [[:import-binfile/by-profile ::rpc/profile-id] @@ -164,10 +193,6 @@ :binfile-v1 1 :binfile-v3 3)) - params (-> params - (assoc :profile-id profile-id) - (assoc :version version)) - manifest (case (int version) 1 nil @@ -175,10 +200,10 @@ (throw (ex-info (str "Unsupported binfile version: " version) {:type :validation :code :unsupported-version - :version version})))] + :version version}))) - (with-meta - (sse/response (partial import-binfile cfg params)) - {::audit/props {:file nil - :generated-by (:generated-by manifest) - :referer (:referer manifest)}}))) + params (assoc params :version version) + event (prepare-import-event params manifest) + params (assoc params :profile-id profile-id)] + + (sse/response (partial import-binfile-and-audit cfg params event)))) diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index b1b99762b0..09b8412e8b 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -6,12 +6,18 @@ (ns backend-tests.rpc-binfile-test (:require + [app.binfile.common :as bfc] + [app.binfile.v3 :as v3] [app.common.schema :as sm] [app.common.uuid :as uuid] [app.rpc :as-alias rpc] [app.rpc.commands.binfile :as binfile] + [app.storage.tmp :as tmp] [backend-tests.helpers :as th] - [clojure.test :as t])) + [clojure.test :as t] + [datoteka.fs :as fs] + [datoteka.io :as io] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -63,3 +69,69 @@ ;; Version 4 should be rejected (t/is (false? (validator (assoc base-params :version 4))) "version 4 should be rejected"))) + +(defn- export-file + [profile] + (let [file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/embed-assets false) + (assoc ::bfc/include-libraries false)) + (io/output-stream output)) + output)) + +(defn- import-file + [profile path] + (let [out (th/command! {::th/type :import-binfile + ::rpc/profile-id (:id profile) + :project-id (:default-project-id profile) + :name "imported" + :file {:filename "imported.penpot" + :size (fs/size path) + :path path + :mtype "application/zip"}})] + (t/is (nil? (:error out))) + (th/consume-sse (:result out)))) + +(defn- import-events + [mock] + (->> (:call-args-list @mock) + (map second) + (filter #(= "import-binfile" (:name %))))) + +(t/deftest import-binfile-audit-event-records-file-ids + (with-mocks [mock {:target 'app.loggers.audit/submit :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + events (import-file profile (export-file profile)) + file-ids (-> events last second) + [event :as audit-events] (import-events mock)] + + (t/is (= :end (-> events last first))) + (t/is (= 1 (count audit-events))) + (t/is (= 1 (count file-ids))) + (t/is (= (vec file-ids) (-> event :props :file-ids))) + (t/is (= 1 (-> event :props :file-count))) + (t/is (= "completed" (-> event :props :status))) + (t/is (= (:default-project-id profile) (-> event :props :project-id))) + (t/is (= "penpot" (-> event :props :referer))) + (t/is (= (:id profile) (:profile-id event))) + (t/is (= "command" (:type event))) + (t/is (not (contains? (:props event) :file)))))) + +(t/deftest import-binfile-audit-event-records-failure + (with-mocks [mock {:target 'app.loggers.audit/submit :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + path (export-file profile) + events (with-redefs [v3/import-files! + (fn [_] (throw (ex-info "boom" {:type :internal})))] + (import-file profile path)) + [event :as audit-events] (import-events mock)] + + (t/is (= :error (-> events last first))) + (t/is (= 1 (count audit-events))) + (t/is (= "failed" (-> event :props :status))) + (t/is (not (contains? (:props event) :file-ids)))))) From 86e01d56a2851204c6dcbc1d44894903058104c8 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 15:23:05 +0100 Subject: [PATCH 07/65] :bug: Update canvas and keep local sets when switching tokens source (#12442) Setting a library as tokens source did not update shapes that use tokens, because set-tokens-source never ran the token propagation that every other token operation runs. Run it after the commit. Going back to the local tokens, or unlinking the library that was the source, left no local set active: the file has a single tokens status, and switching to a library replaced it with the library's one. Keep the status of the file's own tokens lib aside in a new optional :own-tokens-status attribute while an external library is the source, and restore it when the file is its own source again. Exporting without libraries and absorbing a deleted source library restore it as well. Closes #12430 AI-assisted-by: claude-opus-5-5 --- backend/src/app/binfile/common.clj | 5 +- common/src/app/common/files/changes.cljc | 13 ++ .../src/app/common/files/changes_builder.cljc | 18 +++ common/src/app/common/files/tokens.cljc | 46 +++++- common/src/app/common/logic/tokens.cljc | 64 ++++++--- .../src/app/common/test_helpers/tokens.cljc | 4 + common/src/app/common/types/file.cljc | 5 +- .../test/common_tests/logic/token_test.cljc | 48 +++++++ .../data/workspace/tokens/library_edit.cljs | 3 +- frontend/test/frontend_tests/runner.cljs | 2 + .../tokens/logic/tokens_source_test.cljs | 136 ++++++++++++++++++ 11 files changed, 314 insertions(+), 30 deletions(-) create mode 100644 frontend/test/frontend_tests/tokens/logic/tokens_source_test.cljs diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index 2c6ff77afc..1a010c265b 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -683,7 +683,8 @@ "Make the file its own tokens source when its tokens source is not one of the given library-ids, as happens when the library was deleted. The tokens lib of the source is copied while the library still exists, even - if deleted; otherwise the tokens source and status are removed." + if deleted; otherwise the tokens source is removed and the file gets back + the tokens status kept aside for its own tokens lib, if any." [cfg data library-ids] (let [source-id (cfo/get-tokens-source data)] (if (and (some? source-id) @@ -692,7 +693,7 @@ :include-deleted? true :throw-if-not-exists? false)] (cfo/absorb-tokens-lib data (:data library)) - (dissoc data :tokens-source :tokens-status)) + (cfo/detach-tokens-source data)) data))) (defn embed-assets diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index ce45edd485..8e4bbb67d9 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -407,6 +407,12 @@ [:theme-ids [:set ::sm/uuid]] [:set-ids [:set ::sm/uuid]]]] + [:set-own-tokens-status + [:map {:title "SetOwnTokensStatus"} + [:type [:= :set-own-tokens-status]] + [:theme-ids [:maybe [:set ::sm/uuid]]] + [:set-ids [:maybe [:set ::sm/uuid]]]]] + [:rename-token-set-group [:map {:title "RenameTokenSetGroup"} [:type [:= :rename-token-set-group]] @@ -1067,6 +1073,13 @@ (-> (cfo/ensure-tokens-lib data) (cfo/update-tokens-status ctos/set-tokens-status theme-ids set-ids))) +(defmethod process-change :set-own-tokens-status + [data {:keys [theme-ids set-ids]}] + (cfo/set-own-tokens-status data + (when (or (some? theme-ids) (some? set-ids)) + (ctos/make-tokens-status :active-theme-ids theme-ids + :active-set-ids set-ids)))) + (defmethod process-change :rename-token-set-group [data {:keys [set-group-path set-group-fname]}] (-> (cfo/ensure-tokens-lib data) diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index 1e71d05b4d..22f7369a19 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -1077,6 +1077,24 @@ (update :undo-changes conj {:type :set-tokens-status :theme-ids prev-theme-ids :set-ids prev-set-ids}) (apply-changes-local))))) +(defn set-own-tokens-status + "Keep aside the tokens status of the file's own tokens-lib, or remove it + when `tokens-status` is nil." + [changes tokens-status] + (assert-library! changes) + (assert (or (ctos/tokens-status? tokens-status) + (nil? tokens-status))) + (let [library-data (::library-data (meta changes)) + prev-tokens-status (cfo/get-own-tokens-status library-data) + ->change (fn [tokens-status] + {:type :set-own-tokens-status + :theme-ids (some-> tokens-status ctos/get-active-theme-ids) + :set-ids (some-> tokens-status ctos/get-active-set-ids)})] + (-> changes + (update :redo-changes conj (->change tokens-status)) + (update :undo-changes conj (->change prev-tokens-status)) + (apply-changes-local)))) + (defn rename-token-set-group [changes set-group-path set-group-fname] (let [undo-path (ctob/replace-last-path-name set-group-path set-group-fname) diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index e8a28e7ca2..f8fb4e32aa 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -549,6 +549,37 @@ [file-data f & args] (d/update-when file-data :tokens-status #(apply f % args))) +(defn get-own-tokens-status + "The tokens status of the file's own tokens-lib, kept aside while the + tokens source is an external library." + [file-data] + (:own-tokens-status file-data)) + +(defn set-own-tokens-status + "Keep aside the tokens status of the file's own tokens-lib. Setting it to + nil removes the attribute." + [file-data tokens-status] + (assert (or (nil? tokens-status) (ctos/tokens-status? tokens-status)) + "expected nil or valid tokens-status") + (if (nil? tokens-status) + (dissoc file-data :own-tokens-status) + (assoc file-data :own-tokens-status tokens-status))) + +(defn detach-tokens-source + "Make the file its own tokens source again, restoring the tokens status + kept aside for its own tokens-lib. If none was kept, the tokens status + is removed." + [file-data] + (let [own-tokens-status (get-own-tokens-status file-data)] + (if (some? own-tokens-status) + (-> file-data + (set-tokens-source nil) + (set-own-tokens-status nil) + (assoc :tokens-status own-tokens-status)) + (-> file-data + (set-tokens-source nil) + (dissoc :tokens-status))))) + (defn absorb-tokens-lib "Copy the tokens-lib of the library into the file, replacing the previous one if any, and unset the tokens-source, so the file becomes @@ -558,19 +589,26 @@ one of the library is copied along with the tokens-lib. If the library has no tokens-lib, only the tokens-source is unset, and - the file keeps its own tokens-lib and tokens-status. If there is no - library-data, there is nothing to absorb and the file is returned - unchanged." + the file keeps its own tokens-lib, with the tokens status kept aside for + it if any. If there is no library-data, there is nothing to absorb and + the file is returned unchanged." [file-data library-data] (if (nil? library-data) file-data (let [tokens-lib (get-tokens-lib library-data)] (if (nil? tokens-lib) - (set-tokens-source file-data nil) + (let [own-tokens-status (get-own-tokens-status file-data)] + (cond-> (-> file-data + (set-tokens-source nil) + (set-own-tokens-status nil)) + (some? own-tokens-status) + (assoc :tokens-status own-tokens-status))) (let [tokens-status (or (get-tokens-status file-data) (get-tokens-status library-data))] (cond-> (-> file-data (set-tokens-source nil) + ;; The kept status refers to the replaced tokens-lib + (set-own-tokens-status nil) (assoc :tokens-lib tokens-lib)) (some? tokens-status) (assoc :tokens-status tokens-status))))))) diff --git a/common/src/app/common/logic/tokens.cljc b/common/src/app/common/logic/tokens.cljc index 53d2ae5396..c1f948e8c7 100644 --- a/common/src/app/common/logic/tokens.cljc +++ b/common/src/app/common/logic/tokens.cljc @@ -16,36 +16,58 @@ ;; Tokens source (defn- own-tokens-status - "The current tokens status of `file-data`, keeping only the themes and sets - that exist in its own tokens lib." - [file-data] - (let [tokens-status (cfo/get-tokens-status file-data) - tokens-lib (cfo/get-tokens-lib file-data)] + "`tokens-status` keeping only the themes and sets that exist in the own + tokens lib of `file-data`." + [file-data tokens-status] + (let [tokens-lib (cfo/get-tokens-lib file-data)] (if (and tokens-status tokens-lib) (cfo/sync-tokens-status-with-lib tokens-status tokens-lib) (ctos/make-tokens-status)))) (defn generate-set-tokens-source "Create changes for setting the tokens source of a file to `library`, - copying the library's tokens status. If the library is the file itself, - the themes and sets that don't exist in its own tokens lib are deactivated. - If the library is nil, the tokens source is removed and the tokens status - is cleared." + copying the library's tokens status. + + While an external library is the source, the tokens status of the file's + own tokens lib is kept aside, and it is restored when the file goes back + to being its own source (the library is the file itself, or nil). With + nothing kept aside, going back to the file itself deactivates the themes + and sets that don't exist in its own tokens lib, and a nil library clears + the tokens status." [changes library] - (let [library-id (:id library) - file-data (pcb/get-library-data changes) - library-tokens-status (cond - (nil? library) - (ctos/make-tokens-status) + (let [file-data (pcb/get-library-data changes) + file-id (:id file-data) + library-id (:id library) + to-own? (or (nil? library) (= library-id file-id)) + from-own? (cfo/effective-tokens-source? file-data file-id) + current-status (cfo/get-tokens-status file-data) + kept-status (cfo/get-own-tokens-status file-data) - (= library-id (:id file-data)) - (own-tokens-status file-data) + tokens-status + (cond + (and to-own? (some? kept-status)) + (own-tokens-status file-data kept-status) - :else - (-> library ctf/file-data cfo/get-tokens-status))] - (-> changes - (pcb/set-tokens-source library-id) - (pcb/set-tokens-status library-tokens-status)))) + (nil? library) + (ctos/make-tokens-status) + + to-own? + (own-tokens-status file-data current-status) + + :else + (-> library ctf/file-data cfo/get-tokens-status)) + + kept-status' + (cond + to-own? nil + from-own? (own-tokens-status file-data current-status) + :else kept-status)] + + (cond-> (-> changes + (pcb/set-tokens-source library-id) + (pcb/set-tokens-status tokens-status)) + (not (identical? kept-status kept-status')) + (pcb/set-own-tokens-status kept-status')))) ;; Tokens lib diff --git a/common/src/app/common/test_helpers/tokens.cljc b/common/src/app/common/test_helpers/tokens.cljc index 23a0550a4a..88923c691b 100644 --- a/common/src/app/common/test_helpers/tokens.cljc +++ b/common/src/app/common/test_helpers/tokens.cljc @@ -30,6 +30,10 @@ [file] (-> file (ctf/file-data) (cfo/get-tokens-status))) +(defn get-own-tokens-status + [file] + (-> file (ctf/file-data) (cfo/get-own-tokens-status))) + (defn add-tokens-lib "Ensure the file has a tokens-lib and a tokens-status in its data, creating empty ones if not" [file] diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index 56ac4a747d..0d589eba41 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -90,7 +90,8 @@ [:plugin-data {:optional true} schema:plugin-data] [:tokens-source {:optional true} ::sm/uuid] ;; The tokens-lib may be in this file or in an external library [:tokens-lib {:optional true} ctob/schema:tokens-lib] - [:tokens-status {:optional true} ctos/schema:tokens-status]]) + [:tokens-status {:optional true} ctos/schema:tokens-status] + [:own-tokens-status {:optional true} ctos/schema:tokens-status]]) ;; Kept aside while the tokens source is an external library (def schema:file-metadata [:map {:title "Metadata"} @@ -1242,7 +1243,7 @@ (cond-> (update-in file [:data :pages-index] detach-pages) (not (cfo/effective-tokens-source? (:data file) file-id)) - (update :data dissoc :tokens-source :tokens-status)))) + (update :data cfo/detach-tokens-source)))) ;; Base font size diff --git a/common/test/common_tests/logic/token_test.cljc b/common/test/common_tests/logic/token_test.cljc index 799d895f44..f9b11e2fb3 100644 --- a/common/test/common_tests/logic/token_test.cljc +++ b/common/test/common_tests/logic/token_test.cljc @@ -115,6 +115,54 @@ (t/is (= (ctos/get-active-set-ids file-tokens-status) (ctos/get-active-set-ids undo-tokens-status))))) + (t/testing "the file's own tokens status is kept aside while a library is the source, and restored when going back" + (let [own-set-id (uuid/next) + lib-set-id (uuid/next) + lib2-set-id (uuid/next) + library-file (tht/sample-file-with-tokens + :file-id :library + :lib-fn #(ctob/add-set % (ctob/make-token-set :id lib-set-id :name "library set")) + :status-fn #(ctos/set-tokens-status % #{} #{lib-set-id})) + library2-file (tht/sample-file-with-tokens + :file-id :library2 + :lib-fn #(ctob/add-set % (ctob/make-token-set :id lib2-set-id :name "library2 set")) + :status-fn #(ctos/set-tokens-status % #{} #{lib2-set-id})) + file (tht/sample-file-with-tokens + :file-id :file1 + :lib-fn #(ctob/add-set % (ctob/make-token-set :id own-set-id :name "own set")) + :status-fn #(ctos/set-tokens-status % #{} #{own-set-id})) + set-source (fn [file library] + (let [changes (-> (pcb/empty-changes) + (pcb/with-library-data (ctf/file-data file)) + (clt/generate-set-tokens-source library))] + [(thf/apply-changes file changes) changes])) + active-sets #(some-> % ctos/get-active-set-ids) + + [to-library to-library-changes] (set-source file library-file) + [to-library2 _] (set-source to-library library2-file) + [back-to-own back-changes] (set-source to-library2 to-library2) + undo-to-library (thf/apply-undo-changes to-library to-library-changes) + undo-back-to-own (thf/apply-undo-changes back-to-own back-changes)] + + ;; A library as source: its status is used and the own status is kept aside + (t/is (= #{lib-set-id} (active-sets (tht/get-tokens-status to-library)))) + (t/is (= #{own-set-id} (active-sets (tht/get-own-tokens-status to-library)))) + + ;; Another library as source: the own status is still kept aside + (t/is (= #{lib2-set-id} (active-sets (tht/get-tokens-status to-library2)))) + (t/is (= #{own-set-id} (active-sets (tht/get-own-tokens-status to-library2)))) + + ;; Back to the file itself: the own status is restored, nothing is kept aside + (t/is (nil? (tht/get-tokens-source back-to-own))) + (t/is (= #{own-set-id} (active-sets (tht/get-tokens-status back-to-own)))) + (t/is (nil? (tht/get-own-tokens-status back-to-own))) + + ;; Undo works both ways + (t/is (= #{own-set-id} (active-sets (tht/get-tokens-status undo-to-library)))) + (t/is (nil? (tht/get-own-tokens-status undo-to-library))) + (t/is (= #{lib2-set-id} (active-sets (tht/get-tokens-status undo-back-to-own)))) + (t/is (= #{own-set-id} (active-sets (tht/get-own-tokens-status undo-back-to-own)))))) + (t/testing "if the library is nil, the tokens source is cleared and the tokens status is reset to empty" (let [set-id (uuid/next) file (tht/sample-file-with-tokens diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 6b22169719..cb0f4ea573 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -510,7 +510,8 @@ (pcb/with-library-data data) (clo/generate-set-tokens-source library) (pcb/set-tokens-source library-id))] - (rx/of (dch/commit-changes changes)))))) + (rx/of (dch/commit-changes changes) + (dwtp/propagate-workspace-tokens)))))) (defn delete-token-set [id] diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 8cea58e9b6..1471547c4d 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -97,6 +97,7 @@ [frontend-tests.tokens.logic.token-actions-test] [frontend-tests.tokens.logic.token-data-test] [frontend-tests.tokens.logic.token-remapping-test] + [frontend-tests.tokens.logic.tokens-source-test] [frontend-tests.tokens.logic.tokens-status-test] [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] @@ -246,6 +247,7 @@ 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.token-path-test + 'frontend-tests.tokens.logic.tokens-source-test 'frontend-tests.tokens.logic.tokens-status-test 'frontend-tests.tokens.workspace-tokens-remap-test 'frontend-tests.ui.check-updates-test diff --git a/frontend/test/frontend_tests/tokens/logic/tokens_source_test.cljs b/frontend/test/frontend_tests/tokens/logic/tokens_source_test.cljs new file mode 100644 index 0000000000..6813fd1da4 --- /dev/null +++ b/frontend/test/frontend_tests/tokens/logic/tokens_source_test.cljs @@ -0,0 +1,136 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.tokens.logic.tokens-source-test + (:require + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.test-helpers.tokens :as ctht] + [app.common.types.tokens-lib :as ctob] + [app.common.types.tokens-status :as ctos] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.repo :as rp] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] + [frontend-tests.helpers.pages :as thp] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each + {:before (fn [] + (thp/reset-idmap!) + (thw/setup-wasm-mocks!)) + :after thw/teardown-wasm-mocks!}) + +(defn- setup-library + "A library whose active set defines `red` as green." + [] + (ctht/sample-file-with-tokens + :file-id :library + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :library-set) + :name "library-set")) + (ctob/add-token (cthi/id :library-set) + (ctob/make-token :id (cthi/new-id! :library-red) + :name "red" + :type :color + :value "#00ff00"))) + :status-fn #(ctos/set-tokens-status % #{} #{(cthi/id :library-set)}))) + +(defn- setup-file + "A file whose own active set defines `red` as dark red, applied to the + fill of a rectangle." + [] + (-> (ctht/sample-file-with-tokens + :file-id :local + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :local-set) + :name "local-set")) + (ctob/add-token (cthi/id :local-set) + (ctob/make-token :id (cthi/new-id! :local-red) + :name "red" + :type :color + :value "rgb(123, 34, 34)"))) + :status-fn #(ctos/set-tokens-status % #{} #{(cthi/id :local-set)})) + (ctho/add-rect :rect1) + (ctht/apply-token-to-shape :rect1 "red" [:fill] [:fill] "#7b2222"))) + +(defn- fill-color + [store] + (let [file (ths/get-file-from-state @store)] + (-> (cths/get-shape file :rect1) :fills first :fill-color))) + +(defn- active-set-ids + [store] + (-> (ths/get-file-from-state @store) ctht/get-tokens-status ctos/get-active-set-ids)) + +(defn- tokens-source + [store] + (-> (ths/get-file-from-state @store) ctht/get-tokens-source)) + +(defn- ^:async use-library-tokens + "Set `library` as tokens source and wait for the canvas to use it." + [store library] + (ptk/emit! store (dwtl/set-tokens-source (:id library))) + (await (async/wait-for #(= "#00ff00" (fill-color store)) + "the fill uses the library value of `red`" + {:max-ticks 200}))) + +(t/deftest ^:async switching-tokens-source-updates-the-canvas + (let [library (setup-library) + file (setup-file) + store (ths/setup-store file {:libraries [library]})] + + (t/is (= "#7b2222" (fill-color store))) + + (ptk/emit! store (dwtl/set-tokens-source (:id library))) + (await (async/wait-for #(= "#00ff00" (fill-color store)) + "the fill uses the library value of `red`" + {:max-ticks 200})) + (t/is (= "#00ff00" (fill-color store)) + "Setting the library as tokens source resolves `red` from the library") + + (ptk/emit! store (dwtl/set-tokens-source (:id file))) + (await (async/settle)) + (t/is (contains? (active-set-ids store) (cthi/id :local-set)) + "Going back to the local tokens keeps the local set active") + (await (async/wait-for #(= "#7b2222" (fill-color store)) + "the fill uses the local value of `red` again" + {:max-ticks 200})) + (t/is (= "#7b2222" (fill-color store)) + "Going back to the local tokens resolves `red` from the file again") + + (rx/dispose! store))) + +(t/deftest ^:async unlinking-the-tokens-source-library-restores-the-local-tokens + (let [library (setup-library) + file (setup-file) + store (ths/setup-store file {:libraries [library]})] + (await (use-library-tokens store library)) + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/observe-on :async (rx/of []))))} + ;; The same events the libraries dialog emits on unlink + (ptk/emit! store + (dwl/unlink-file-from-library (:id file) (:id library)) + (dwtl/set-tokens-source (:id file))) + (await (async/wait-for #(= "#7b2222" (fill-color store)) + "the fill uses the local value of `red` again" + {:max-ticks 200})))) + + (t/is (nil? (tokens-source store))) + (t/is (contains? (active-set-ids store) (cthi/id :local-set)) + "Unlinking the library keeps the local set active") + (t/is (= "#7b2222" (fill-color store))) + + (rx/dispose! store))) + From 01e0b3285017c080b18ff02b1a2c5938e561b74a Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 17:56:31 +0100 Subject: [PATCH 08/65] :bug: Remap library refs inside deleted components on absorb (#12232) * :bug: Remap library refs inside deleted components on absorb Deleted components keep their shapes in :objects, to be restored and to resolve their remaining copies. When a library was absorbed into a file (export with libraries merged, unsharing or deleting the library), absorb-assets only looked for usages in pages and in live components. The shapes inside deleted components kept pointing to the library, so they no longer matched their copies in the pages: the file failed validation with :component-id-mismatch and could not be saved. Also look for usages in deleted components, so their library components, colors and typographies are remapped to the local file. Relates to #10806 AI-assisted-by: claude-opus-5-5 * :bug: Persist pointer-map fragments when absorbing a library absorb-library-by-file saved the file without its :features. Without "fdata/pointer-map" there, encode-file does not persist the changed fragments, so in files with pointer maps the pages and components that absorb-assets remapped were never written. After unpublishing or deleting a library, its consumers failed to load with "fragment not found". Pass :features to update-file!. Exporting with shared libraries does not go through this path, so it was not affected. AI-assisted-by: claude-opus-5-5 --- backend/src/app/rpc/commands/files.clj | 1 + backend/test/backend_tests/rpc_file_test.clj | 55 +++++++++++++++++++ common/src/app/common/types/file.cljc | 14 +++-- .../types/absorb_assets_test.cljc | 47 ++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 5c17effec4..9b4aa777d9 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -961,6 +961,7 @@ :file-id (str file-id)) (bfc/update-file! cfg {:id file-id + :features (:features file) :migrations (:migrations file) :revn (inc (:revn file)) :data (:data file) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index c885219fc2..db82002200 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2261,6 +2261,61 @@ (t/is (nil? (:error out))) (check-tokens-absorbed prepared))) +(t/deftest unpublishing-a-library-keeps-its-consumers-readable + ;; The test helpers enable all supported features, so these files use + ;; fdata/pointer-map and the absorbed page is stored as a fragment + (let [profile (th/create-profile* 1 {:is-active true}) + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + consumer (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + _ (th/link-file-to-library* {:file-id (:id consumer) :library-id (:id library)}) + color-id (uuid/random) + page-id (first (get-in consumer [:data :pages])) + shape-id (uuid/random)] + + (update-file! :file-id (:id library) + :profile-id (:id profile) + :revn 0 + :changes [{:type :add-color + :color {:id color-id :name "Brand" :color "#abcdef" :opacity 1}}]) + (update-file! + :file-id (:id consumer) + :profile-id (:id profile) + :revn 0 + :changes + [{:type :add-obj + :page-id page-id + :id shape-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape {:id shape-id :name "Rect" :frame-id uuid/zero + :parent-id uuid/zero :type :rect + :fills [{:fill-color "#abcdef" + :fill-opacity 1 + :fill-color-ref-id color-id + :fill-color-ref-file (:id library)}]})}]) + + (let [out (th/command! {::th/type :set-file-shared + ::rpc/profile-id (:id profile) + :id (:id library) + :is-shared false})] + (t/is (nil? (:error out)))) + + ;; The absorption changed the page of the shape: the consumer reads + ;; it back, with the fill now linked to its own color + (let [out (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id (:id consumer)}) + data (-> out :result :data) + fill (get-in data [:pages-index page-id :objects shape-id :fills 0])] + (t/is (nil? (:error out))) + (t/is (contains? (:colors data) color-id)) + (t/is (= (:id consumer) (:fill-color-ref-file fill)))))) + (t/deftest deleted-files-permanently-delete (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index 0d589eba41..444484b68f 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -683,7 +683,8 @@ (defn find-asset-type-usages "Find all usages of an asset in a file (may be in pages or in the components - of the local library). + of the local library, deleted ones included, since they keep their shapes + to be restored and to resolve their remaining copies). Returns a list ((asset ((container shapes) (container shapes)...))...)" [file-data library-data asset-type] @@ -699,12 +700,17 @@ (when (d/not-empty? instances) [[container instances]]))) + containers + (concat (containers-seq file-data) + (map #(ctn/make-container % :component) + (ctkl/deleted-components-seq file-data))) + find-asset-usages - (fn [file-data asset] - (mapcat #(find-usages-in-container % asset) (containers-seq file-data)))] + (fn [asset] + (mapcat #(find-usages-in-container % asset) containers))] (mapcat (fn [asset] - (let [instances (find-asset-usages file-data asset)] + (let [instances (find-asset-usages asset)] (when (d/not-empty? instances) [[asset instances]]))) assets))) diff --git a/common/test/common_tests/types/absorb_assets_test.cljc b/common/test/common_tests/types/absorb_assets_test.cljc index 8e8ee2ee36..6d2e46e0db 100644 --- a/common/test/common_tests/types/absorb_assets_test.cljc +++ b/common/test/common_tests/types/absorb_assets_test.cljc @@ -7,6 +7,9 @@ (ns common-tests.types.absorb-assets-test (:require [app.common.data :as d] + [app.common.files.changes-builder :as pcb] + [app.common.files.validate :as cfv] + [app.common.logic.shapes :as cls] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] @@ -57,6 +60,50 @@ (t/is (ctk/is-main-of? main-root' copy-root')) (t/is (ctk/main-instance-of? (:id main-root') (:id (second pages')) component')))) +(t/deftest absorb-components-used-in-deleted-components + ;; A deleted component keeps its shapes in `:objects`, to be able to + ;; restore it and to resolve its remaining copies. When those shapes + ;; use library components, they must also be remapped to the local + ;; file, or they will not match the copies in the pages. + (let [;; Setup + library (-> (thf/sample-file :library :is-shared true) + (tho/add-simple-component :icon :icon-main :icon-rect)) + + file (-> (thf/sample-file :file) + (tho/add-frame :card-main :name "Card") + (thc/instantiate-component :icon :card-icon + :library library + :parent-label :card-main) + (thc/make-component :card :card-main) + (thc/instantiate-component :card :card-copy)) + + page (thf/current-page file) + changes (-> (pcb/empty-changes nil (:id page)) + (cls/generate-delete-shapes file page (:objects page) + #{(thi/id :card-main)} {}) + (second)) + file (thf/apply-changes file changes :validate? false) + errors (cfv/validate-file file {(:id library) library}) + + ;; Action + file' (ctf/update-file-data + file + #(ctf/absorb-assets % (:data library))) + + ;; Get + data' (ctf/file-data file') + card' (ctkl/get-deleted-component data' (thi/id :card)) + shapes' (concat (mapcat (comp vals :objects) (ctpl/pages-seq data')) + (mapcat (comp vals :objects) (vals (:components data')))) + library-refs' (filter #(= (thi/id :library) (:component-file %)) shapes') + errors' (cfv/validate-file file' {})] + + ;; Check + (t/is (nil? errors) "the file is valid before absorbing the library") + (t/is (some? (:objects card')) "the deleted component keeps its shapes") + (t/is (empty? library-refs') "no shape points to the library") + (t/is (nil? errors')))) + (t/deftest absorb-colors (let [;; Setup library (-> (thf/sample-file :library :is-shared true) From dbcd1e0d03cfa3d606d984459271750b430d2b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 26 Oct 2026 19:26:43 +0100 Subject: [PATCH 09/65] :construction_worker: Include the version in the bundle and image build key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle stamps the version into version.txt, so a commit that gets a new tag (2.18.1-RC1 -> 2.18.1) needs a new bundle and image set. Keying them by commit sha alone reused the RC artifacts for the final release. Signed-off-by: David Barragán Merino --- .github/workflows/build-bundle.yml | 32 ++++++++++------- .github/workflows/build-docker.yml | 53 ++++++++++++++++------------ .github/workflows/build-renovate.yml | 22 +++++++----- 3 files changed, 62 insertions(+), 45 deletions(-) diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index 88c04a2634..eb4b335313 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -61,6 +61,7 @@ jobs: outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} bundle_version: ${{ steps.vars.outputs.bundle_version }} + build_key: ${{ steps.vars.outputs.build_key }} short_sha: ${{ steps.vars.outputs.short_sha }} sha: ${{ steps.vars.outputs.sha }} commit_title: ${{ steps.vars.outputs.commit_title }} @@ -79,16 +80,22 @@ jobs: env: GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | + # Same command the build uses to stamp version.txt, so the key + # always matches what ends up inside the bundle. + BUNDLE_VERSION=$(./manage.sh version) + SHORT_SHA=$(git rev-parse --short=12 HEAD) echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT - echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT + echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT + echo "build_key=sha-${SHORT_SHA}-${BUNDLE_VERSION}" >> $GITHUB_OUTPUT + echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT - # Keyed by commit sha, same as build-docker.yml's marker check, so - # both pipelines agree on what "already built" means: any ref that - # points at an already-bundled commit skips the build, regardless of - # which ref built it first. `force` bypasses this check entirely. + # Keyed by commit sha AND version, same as build-docker.yml's marker + # check, so both pipelines agree on what "already built" means. The + # version is stamped into the bundle, so a new tag on an already + # bundled commit (2.18.1-RC1 -> 2.18.1) must produce a new bundle. + # `force` bypasses this check entirely. - name: Check if this bundle is already built id: check env: @@ -97,8 +104,7 @@ jobs: AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} FORCE: ${{ inputs.force }} - SHORT_SHA: ${{ steps.vars.outputs.short_sha }} - BUNDLE_VERSION: ${{ steps.vars.outputs.bundle_version }} + BUILD_KEY: ${{ steps.vars.outputs.build_key }} run: | if [ "$FORCE" = "true" ]; then echo "exists=false" >> $GITHUB_OUTPUT @@ -112,7 +118,7 @@ jobs: BUNDLE_EXISTS=$(aws s3api head-object \ --bucket "$S3_BUCKET" \ - --key "penpot-sha-${SHORT_SHA}.zip" \ + --key "penpot-${BUILD_KEY}.zip" \ > /dev/null 2>&1 && echo "true" || echo "false") if [ "$BUNDLE_EXISTS" = "true" ]; then @@ -120,7 +126,7 @@ jobs: { echo "### ⏭️ Bundle build skipped" echo "" - echo "The bundle in S3 was already built from \`sha-${SHORT_SHA}\` (\`${BUNDLE_VERSION}\`)." + echo "The bundle in S3 was already built for \`${BUILD_KEY}\`." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -164,11 +170,11 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} - SHORT_SHA: ${{ needs.check.outputs.short_sha }} + BUILD_KEY: ${{ needs.check.outputs.build_key }} BUNDLE_VERSION: ${{ needs.check.outputs.bundle_version }} run: | aws s3 cp zips/penpot.zip \ - "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" \ + "s3://${S3_BUCKET}/penpot-${BUILD_KEY}.zip" \ --metadata "bundle-version=${BUNDLE_VERSION}" - name: Write step summary @@ -180,7 +186,7 @@ jobs: { echo "### ✅ Bundle built" echo "" - echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + echo "- Version: \`${BUNDLE_VERSION}\` (\`./manage.sh version\`)" echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index a8d6a1976d..9c674eba33 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -85,6 +85,7 @@ jobs: bundle_version: ${{ steps.vars.outputs.bundle_version }} sha: ${{ steps.vars.outputs.sha }} short_sha: ${{ steps.vars.outputs.short_sha }} + build_key: ${{ steps.vars.outputs.build_key }} commit_title: ${{ steps.vars.outputs.commit_title }} exists: ${{ steps.check.outputs.exists }} @@ -105,10 +106,14 @@ jobs: REF_SLUG=$(printf '%s' "$GH_REF" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-100) echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT echo "ref_slug=$REF_SLUG" >> $GITHUB_OUTPUT + # Must match the key computed by build-bundle.yml. + BUNDLE_VERSION=$(./manage.sh version) + SHORT_SHA=$(git rev-parse --short=12 HEAD) echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT + echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT - echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT + echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT + echo "build_key=sha-${SHORT_SHA}-${BUNDLE_VERSION}" >> $GITHUB_OUTPUT # The image set is a single block, so a single set-level check is # enough: `promote` drops a marker object in S3 only after every @@ -123,14 +128,14 @@ jobs: AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} FORCE: ${{ inputs.force }} - SHORT_SHA: ${{ steps.vars.outputs.short_sha }} + BUILD_KEY: ${{ steps.vars.outputs.build_key }} run: | if [ "$FORCE" = "true" ]; then echo "exists=false" >> $GITHUB_OUTPUT mkdir -p "$BUNDLE_CACHE" find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true - ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" - aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" + ZIP="$BUNDLE_CACHE/penpot-${BUILD_KEY}.zip" + aws s3 cp "s3://${S3_BUCKET}/penpot-${BUILD_KEY}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" { echo "### 🔁 Image set build forced" @@ -142,13 +147,13 @@ jobs: if aws s3api head-object \ --bucket "$S3_BUCKET" \ - --key "markers/images-sha-${SHORT_SHA}" \ + --key "markers/images-${BUILD_KEY}" \ > /dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT { echo "### ⏭️ Image set build skipped" echo "" - echo "The whole set was already built and promoted for \`sha-${SHORT_SHA}\`." + echo "The whole set was already built and promoted for \`${BUILD_KEY}\`." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -158,9 +163,9 @@ jobs: # prune stale bundles while at it. mkdir -p "$BUNDLE_CACHE" find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true - ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" + ZIP="$BUNDLE_CACHE/penpot-${BUILD_KEY}.zip" if [ ! -f "$ZIP" ]; then - aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" + aws s3 cp "s3://${S3_BUCKET}/penpot-${BUILD_KEY}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi fi @@ -231,14 +236,14 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} - SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} IMAGE: ${{ matrix.image }} run: | - ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" + ZIP="$BUNDLE_CACHE/penpot-${BUILD_KEY}.zip" if [ ! -f "$ZIP" ]; then echo "Bundle not found in host cache; falling back to S3." mkdir -p "$BUNDLE_CACHE" - aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" + aws s3 cp "s3://${S3_BUCKET}/penpot-${BUILD_KEY}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi # Extract only the bundle this job needs. @@ -274,7 +279,7 @@ jobs: sbom: true # Immutable tag only; branch tags are moved atomically for the # whole image set by the `promote` job. - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.short_sha }} + tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:${{ needs.prepare.outputs.build_key }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max @@ -305,13 +310,13 @@ jobs: env: DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} - SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ -t "${DOCKER_REGISTRY}/${image}:${REF_SLUG}" \ - "${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}" + "${DOCKER_REGISTRY}/${image}:${BUILD_KEY}" done # The marker is written LAST: its presence certifies that all five @@ -322,26 +327,27 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} - SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} run: | echo "$GITHUB_RUN_ID" | aws s3 cp - \ - "s3://${S3_BUCKET}/markers/images-sha-${SHORT_SHA}" + "s3://${S3_BUCKET}/markers/images-${BUILD_KEY}" - name: Write step summary env: REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }} COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }} run: | { echo "### ✅ Image set promoted" echo "" - echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + echo "- Version: \`${BUNDLE_VERSION}\` (\`./manage.sh version\`)" echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" echo "" - echo "All \`:${REF_SLUG}\` tags now point to \`sha-${SHORT_SHA}\`." + echo "All \`:${REF_SLUG}\` tags now point to \`${BUILD_KEY}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 3b. Skip path: make sure THIS ref's tags point to the existing @@ -374,30 +380,31 @@ jobs: env: DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} - SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ -t "${DOCKER_REGISTRY}/${image}:${REF_SLUG}" \ - "${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}" + "${DOCKER_REGISTRY}/${image}:${BUILD_KEY}" done - name: Write step summary env: REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }} COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }} run: | { echo "### ✅ Image set already built (branch tags ensured)" echo "" - echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + echo "- Version: \`${BUNDLE_VERSION}\` (\`./manage.sh version\`)" echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Checked at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" echo "" - echo "All \`:${REF_SLUG}\` tags now point to \`sha-${SHORT_SHA}\`." + echo "All \`:${REF_SLUG}\` tags now point to \`${BUILD_KEY}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 4. Single failure notification for the whole workflow ───────────── diff --git a/.github/workflows/build-renovate.yml b/.github/workflows/build-renovate.yml index 6156aed086..09f8a51aa0 100644 --- a/.github/workflows/build-renovate.yml +++ b/.github/workflows/build-renovate.yml @@ -27,7 +27,7 @@ jobs: permissions: contents: read outputs: - short_sha: ${{ steps.bundle.outputs.short_sha }} + bundle_key: ${{ steps.bundle.outputs.bundle_key }} images: ${{ steps.changes.outputs.images }} devenv: ${{ steps.changes.outputs.devenv }} imagemagick: ${{ steps.changes.outputs.imagemagick }} @@ -60,7 +60,9 @@ jobs: } >> "$GITHUB_OUTPUT" # Renovate only changes Dockerfiles and package manager versions, so - # the most recent develop bundle is enough to build the images. + # the most recent develop bundle is enough to build the images. The + # version part of the key does not matter here: take any bundle of + # the commit. - name: Find the latest develop bundle id: bundle env: @@ -72,10 +74,12 @@ jobs: run: | for SHA in $(git rev-list --first-parent --max-count=50 "$BASE_SHA"); do SHORT_SHA=$(git rev-parse --short=12 "$SHA") - if aws s3api head-object --bucket "$S3_BUCKET" \ - --key "penpot-sha-${SHORT_SHA}.zip" > /dev/null 2>&1; then - echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" - echo "Using bundle \`penpot-sha-${SHORT_SHA}.zip\`" >> "$GITHUB_STEP_SUMMARY" + BUNDLE_KEY=$(aws s3api list-objects-v2 --bucket "$S3_BUCKET" \ + --prefix "penpot-sha-${SHORT_SHA}-" \ + --query 'Contents[0].Key' --output text) + if [ -n "$BUNDLE_KEY" ] && [ "$BUNDLE_KEY" != "None" ]; then + echo "bundle_key=$BUNDLE_KEY" >> "$GITHUB_OUTPUT" + echo "Using bundle \`${BUNDLE_KEY}\`" >> "$GITHUB_STEP_SUMMARY" exit 0 fi done @@ -135,13 +139,13 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} - SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUNDLE_KEY: ${{ needs.prepare.outputs.bundle_key }} IMAGE: ${{ matrix.image }} run: | - ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" + ZIP="$BUNDLE_CACHE/${BUNDLE_KEY}" if [ ! -f "$ZIP" ]; then mkdir -p "$BUNDLE_CACHE" - aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" + aws s3 cp "s3://${S3_BUCKET}/${BUNDLE_KEY}" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi pushd docker/images From e480d1b66991a3c83fe2b7ac6cc8127277b6a9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 26 Oct 2026 20:25:48 +0100 Subject: [PATCH 10/65] :construction_worker: Use only the closest tag in the build key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full `git describe` output is not stable across checkouts: the length of its -g suffix depends on the local object count, so build-bundle and build-docker could compute different keys for the same commit on different runner workspaces. The sha already pins the commit, so the closest tag is enough to tell versions apart. Signed-off-by: David Barragán Merino --- .github/workflows/build-bundle.yml | 10 +++++++--- .github/workflows/build-docker.yml | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index eb4b335313..7c66eb9fea 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -80,13 +80,17 @@ jobs: env: GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - # Same command the build uses to stamp version.txt, so the key - # always matches what ends up inside the bundle. + # Same command the build uses to stamp version.txt. BUNDLE_VERSION=$(./manage.sh version) SHORT_SHA=$(git rev-parse --short=12 HEAD) + # The key takes only the closest tag (--abbrev=0): the sha already + # pins the commit, and the full describe output is not stable + # across checkouts (its -g suffix length depends on the + # local object count). Must match build-docker.yml. + VERSION_TAG=$(git describe --tags --match '*.*.*' --abbrev=0) echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT - echo "build_key=sha-${SHORT_SHA}-${BUNDLE_VERSION}" >> $GITHUB_OUTPUT + echo "build_key=sha-${SHORT_SHA}-${VERSION_TAG}" >> $GITHUB_OUTPUT echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 9c674eba33..613f806571 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -109,11 +109,12 @@ jobs: # Must match the key computed by build-bundle.yml. BUNDLE_VERSION=$(./manage.sh version) SHORT_SHA=$(git rev-parse --short=12 HEAD) + VERSION_TAG=$(git describe --tags --match '*.*.*' --abbrev=0) echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT - echo "build_key=sha-${SHORT_SHA}-${BUNDLE_VERSION}" >> $GITHUB_OUTPUT + echo "build_key=sha-${SHORT_SHA}-${VERSION_TAG}" >> $GITHUB_OUTPUT # The image set is a single block, so a single set-level check is # enough: `promote` drops a marker object in S3 only after every From c65a1a3c5ecc72ec9df48463dbf65d2b1ac33dbe Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Thu, 22 Oct 2026 16:34:26 +0200 Subject: [PATCH 11/65] :bug: Fix swapped nested component loses absolute position after a lib update --- common/src/app/common/logic/libraries.cljc | 15 ++- .../logic/comp_flex_interactions_test.cljc | 117 ++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index 5d003a64b2..ac5a516156 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2379,6 +2379,15 @@ (assoc attr (get origin attr))))) dest)))) +(defn- get-flex-child-main + "The near-main child that owns this copy child's flex-item attrs. + Non-swapped copies match by shape-ref; swapped copies match by swap slot + (the original occupant of the parent layout slot)." + [main-container main-component child-copy] + (or (ctf/get-ref-shape main-container main-component child-copy) + (some->> (ctk/get-swap-slot child-copy) + (ctf/get-component-shape main-container main-component)))) + (defn- update-flex-child-copy-attrs "Synchronizes the attributes inside the flex-child items (main->copy)" [changes shape-main shape-copy main-container main-component copy-container omit-touched?] @@ -2404,9 +2413,9 @@ (pcb/update-shapes (:shapes shape-copy) (fn [child-copy] - (let [child-main (ctf/get-ref-shape main-container main-component child-copy)] - (-> child-copy - (propagate-attrs child-main flex-child-attrs omit-touched?)))) + (if-let [child-main (get-flex-child-main main-container main-component child-copy)] + (propagate-attrs child-copy child-main flex-child-attrs omit-touched?) + child-copy)) {:ignore-touched true}))] (pcb/concat-changes changes new-changes))) diff --git a/common/test/common_tests/logic/comp_flex_interactions_test.cljc b/common/test/common_tests/logic/comp_flex_interactions_test.cljc index 5ded09e9a3..0fa3644c6f 100644 --- a/common/test/common_tests/logic/comp_flex_interactions_test.cljc +++ b/common/test/common_tests/logic/comp_flex_interactions_test.cljc @@ -17,6 +17,7 @@ [app.common.test-helpers.files :as thf] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] + [app.common.types.component :as ctk] [app.common.types.container :as ctn] [clojure.test :as t])) @@ -153,3 +154,119 @@ ;; ==== Check (t/is (mth/close? (inc copy-x) (:x (tho/bottom-shape file' :copy2-root)))) (t/is (= [(thi/id :copy-popup)] (destinations copy-nested-head'))))) + +(defn- swap-to-library-component + "Swap a shape in `file` to a component that lives in `library`, keeping + flex-item attrs the way production `component-swap` does." + [file library shape-label new-component-label] + (let [shape (ths/get-shape file shape-label) + page (thf/current-page file) + keep-props-values (select-keys shape ctk/swap-keep-attrs) + libraries {(:id library) library + (:id file) file} + id-new (:id (thc/get-component library new-component-label)) + [_ _ changes] (cll/generate-component-swap + (pcb/empty-changes nil (:id page)) + (:objects page) + shape + (:data library) + page + libraries + id-new + 0 + nil + keep-props-values + false)] + (thf/apply-changes file changes))) + +(defn- setup-library-with-absolute-nested-icon + "Library: icon A, icon B, and a flex board whose nested child is an + instance of icon A with absolute position." + [] + (-> (thf/sample-file :library) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child) + (tho/add-frame :board-root {:name "Board" + :layout :flex + :layout-flex-dir :row}) + (thc/instantiate-component :icon-a :nested-head {:parent-label :board-root}) + (update-shape :nested-head #(assoc % :layout-item-absolute true)) + (thc/make-component :board :board-root))) + +(t/deftest test-sync-library-keeps-swapped-nested-absolute + (let [;; ==== Setup + library (setup-library-with-absolute-nested-icon) + file (-> (thf/sample-file :file) + (thc/instantiate-component :board + :copy-board + :library library + :children-labels [:copy-nested-head])) + file (swap-to-library-component file library :copy-nested-head :icon-b) + swapped (ths/get-shape file :copy-nested-head) + + ;; ==== Action + library' (update-shape library :board-root + #(assoc % :fills (ths/sample-fills-color :fill-color "#fabada"))) + file' (thf/apply-changes file (sync-file-from-library file library')) + swapped' (ths/get-shape file' :copy-nested-head)] + + ;; ==== Check + (t/is (true? (:layout-item-absolute swapped))) + (t/is (some? (ctk/get-swap-slot swapped))) + (t/is (= (thi/id :icon-b) (:component-id swapped))) + (t/is (some? (ctk/get-swap-slot swapped'))) + (t/is (= (thi/id :icon-b) (:component-id swapped'))) + (t/is (true? (:layout-item-absolute swapped'))))) + +(t/deftest test-sync-library-propagates-slot-absolute-to-swapped-nested + (let [;; ==== Setup + library (setup-library-with-absolute-nested-icon) + file (-> (thf/sample-file :file) + (thc/instantiate-component :board + :copy-board + :library library + :children-labels [:copy-nested-head])) + file (swap-to-library-component file library :copy-nested-head :icon-b) + + ;; ==== Action + library' (update-shape library :nested-head + #(assoc % :layout-item-absolute false)) + file' (thf/apply-changes file (sync-file-from-library file library')) + swapped' (ths/get-shape file' :copy-nested-head)] + + ;; ==== Check + (t/is (some? (ctk/get-swap-slot swapped'))) + (t/is (= (thi/id :icon-b) (:component-id swapped'))) + (t/is (not (true? (:layout-item-absolute swapped')))))) + +(t/deftest test-sync-library-flex-child-attrs-to-unswapped-sibling + (let [;; ==== Setup + margin {:m1 5 :m2 5 :m3 5 :m4 5} + library (-> (thf/sample-file :library) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child) + (tho/add-frame :board-root {:name "Board" + :layout :flex + :layout-flex-dir :row}) + (thc/instantiate-component :icon-a :nested-head {:parent-label :board-root}) + (ths/add-sample-shape :sibling :type :rect :name "Sibling" :parent-label :board-root) + (update-shape :nested-head #(assoc % :layout-item-absolute true)) + (thc/make-component :board :board-root)) + file (-> (thf/sample-file :file) + (thc/instantiate-component :board + :copy-board + :library library + :children-labels [:copy-nested-head :copy-sibling])) + file (swap-to-library-component file library :copy-nested-head :icon-b) + + ;; ==== Action + library' (update-shape library :sibling + #(assoc % :layout-item-margin margin)) + file' (thf/apply-changes file (sync-file-from-library file library')) + sibling' (ths/get-shape file' :copy-sibling) + swapped' (ths/get-shape file' :copy-nested-head)] + + ;; ==== Check + (t/is (= margin (:layout-item-margin sibling'))) + (t/is (true? (:layout-item-absolute swapped'))) + (t/is (some? (ctk/get-swap-slot swapped'))))) From a70712fce44589b6bffeefaba75437e737895a89 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 27 Oct 2026 15:44:30 +0100 Subject: [PATCH 12/65] :bug: Fix issue with compononent not sync on move (#11981) --- .../app/main/data/workspace/libraries.cljs | 30 ++++-- .../logic/comp_watcher_test.cljs | 100 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + .../src/composable-tests/cases.ts | 2 + .../cases/caseMainChildMovedOutLeavesCopy.ts | 59 +++++++++++ .../operations/OpCreateBoard.ts | 39 +++++++ .../operations/OpMoveShape.ts | 53 ++++++++++ 7 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 frontend/test/frontend_tests/logic/comp_watcher_test.cljs create mode 100644 plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainChildMovedOutLeavesCopy.ts create mode 100644 plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateBoard.ts create mode 100644 plugins/apps/composable-test-suite/src/composable-tests/operations/OpMoveShape.ts diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index b84faf4356..3044a05f6c 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -1462,6 +1462,12 @@ (wrf/finish! task) (swap! pending-sync-barriers* disj task))) + ;; Each batch entry is [[event task] old-data]. + finish-batch-barriers! + (fn [batch] + (doseq [[[_ task] _] batch] + (finish-sync-barrier! task))) + commits-s (->> stream (rx/filter dch/commit?) @@ -1474,6 +1480,9 @@ (rx/filter (complement :skip-component-sync?)) ;; Keep waits pending while component changes are checked. (rx/map start-sync-barrier) + ;; Paired as it arrives, so later commits in the same batch + ;; cannot hide the shapes this one moved or deleted. + (rx/with-latest-from workspace-buffer-s) (rx/share)) ;; Buffers commits until a timer turn passes with no new ones. The @@ -1499,32 +1508,33 @@ (log/info :hint "detected component changes" :ids (map str changed-components) :undo-group undo-group) - (map #(vector ::component-changed % undo-group) + (map #(vector ::component-changed % (:id old-data) undo-group) changed-components)) :else ;; Undos only bump :modified-at. (map #(vector ::touch-component %) changed-components)))) - ;; One event per distinct component change in the batch + ;; One event per distinct component change in the batch, each + ;; commit checked against the file data right before it get-component-events - (fn [batch old-data] + (fn [batch] (->> batch - (mapcat #(get-component-changes old-data (first %))) + (mapcat (fn [[[event _] old-data]] + (get-component-changes old-data event))) (distinct) - (map (fn [[type component-id undo-group]] + (map (fn [[type component-id file-id undo-group]] (if (= type ::component-changed) - (component-changed component-id (:id old-data) undo-group) + (component-changed component-id file-id undo-group) (touch-component component-id)))) (rx/from))) component-events-s (->> commit-batches-s - (rx/with-latest-from workspace-buffer-s) (rx/mapcat - (fn [[batch old-data]] - (->> (get-component-events batch old-data) - (rx/finalize #(run! (comp finish-sync-barrier! second) batch))))) + (fn [batch] + (->> (get-component-events batch) + (rx/finalize #(finish-batch-barriers! batch))))) ;; Close barriers left behind when the page shuts down. (rx/finalize #(wrf/finish-tasks! @pending-sync-barriers*)) (rx/share)) diff --git a/frontend/test/frontend_tests/logic/comp_watcher_test.cljs b/frontend/test/frontend_tests/logic/comp_watcher_test.cljs new file mode 100644 index 0000000000..f8d3bc8783 --- /dev/null +++ b/frontend/test/frontend_tests/logic/comp_watcher_test.cljs @@ -0,0 +1,100 @@ +;; 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 SUBSIDIARY SL +(ns frontend-tests.logic.comp-watcher-test + "Automatic propagation of main edits to copies, driven by the real + `dwl/watch-component-changes` on an isolated store." + (:require + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.main.data.helpers :as dsh] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.pages :as dwpg] + [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.thumbnails-wasm :as dwt.wasm] + [app.main.refs :as refs] + [app.main.store :as st] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] + [frontend-tests.helpers.pages :as thp] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] + [okulary.core :as l] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each + {:before (fn [] + (thp/reset-idmap!) + (thw/setup-wasm-mocks!)) + :after thw/teardown-wasm-mocks!}) + +(defn- setup-file + [] + ;; {:main-root} [:name Frame1] # [Component :component1] + ;; :main-child [:name Rect1] + ;; + ;; :copy-root [:name Frame1] #--> [Component :component1] :main-root + ;; :copy-child [:name Rect1] ---> :main-child + ;; + ;; :outside [:name Frame1] + (-> (cthf/sample-file :file1) + (ctho/add-simple-component-with-copy :component1 + :main-root + :main-child + :copy-root + :copy-root-params {:children-labels [:copy-child]}) + (ctho/add-frame :outside))) + +(defn- noop-render-thumbnail + [& _] + (ptk/reify ::noop-render-thumbnail + ptk/WatchEvent + (watch [_ _ _] (rx/empty)))) + +(defn- copy-children + [store] + (let [file (ths/get-file-from-state @store)] + (:shapes (cths/get-shape file :copy-root)))) + +(t/deftest ^:async child-moved-out-of-main-leaves-copy-when-another-edit-follows + ;; The move and the fill change commit in the same tick, so the watcher + ;; inspects both in one batch. The move must be checked against the file + ;; data right before it, where the rect still sits in the main. + (let [file (setup-file) + store (ths/setup-store file)] + (await + (mock/with-mocks* + {;; The watcher reads the file data through this lens. + refs/workspace-data (l/derived dsh/lookup-file-data store) + ;; Thumbnail renders read the global store. + st/state store + dwt.wasm/render-thumbnail noop-render-thumbnail} + + (ptk/emit! store (dwl/watch-component-changes)) + (t/is (= 1 (count (copy-children store))) + "the copy starts with its rect") + + (ptk/emit! store + (dwsh/relocate-shapes #{(cthi/id :main-child)} (cthi/id :outside) 0) + (dwsh/update-shapes [(cthi/id :outside)] + #(assoc % :fills [{:fill-color "#ff0000" + :fill-opacity 1}]))) + + (await (async/wait-for #(empty? (copy-children store)) + "the copy to lose its rect")) + + (let [file' (ths/get-file-from-state @store)] + (t/is (= (cthi/id :outside) + (:parent-id (cths/get-shape file' :main-child))) + "the rect moved out of the main") + (t/is (empty? (copy-children store)) + "the copy lost its rect")) + + (ptk/emit! store (dwpg/finalize-page (:id file) (cthf/current-page-id file))) + (await (async/settle)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 1471547c4d..aefb617e23 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -44,6 +44,7 @@ [frontend-tests.fonts-test] [frontend-tests.helpers-shapes-test] [frontend-tests.logic.comp-remove-swap-slots-test] + [frontend-tests.logic.comp-watcher-test] [frontend-tests.logic.components-and-tokens] [frontend-tests.logic.copy-paste-typography-test] [frontend-tests.logic.copying-and-duplicating-test] @@ -192,6 +193,7 @@ 'frontend-tests.fonts-test 'frontend-tests.helpers-shapes-test 'frontend-tests.logic.comp-remove-swap-slots-test + 'frontend-tests.logic.comp-watcher-test 'frontend-tests.logic.components-and-tokens 'frontend-tests.logic.copy-paste-typography-test 'frontend-tests.logic.copying-and-duplicating-test diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases.ts index e9150ec16c..793903e15d 100644 --- a/plugins/apps/composable-test-suite/src/composable-tests/cases.ts +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases.ts @@ -5,6 +5,7 @@ import { createTestCaseRemoteMainCopySyncNested } from "./cases/caseRemoteMainCo import { createTestCaseVariantSwitchPropagates } from "./cases/caseVariantSwitchPropagates.ts"; import { createTestCaseCopySubheadDeletePreservesSlots } from "./cases/caseCopySubheadDeletePreservesSlots.ts"; import { createTestCaseMainReorderKeepsCopySlots } from "./cases/caseMainReorderKeepsCopySlots.ts"; +import { createTestCaseMainChildMovedOutLeavesCopy } from "./cases/caseMainChildMovedOutLeavesCopy.ts"; /** * All composable test cases currently defined. A factory (not a constant): each @@ -19,5 +20,6 @@ export function allCases(): readonly TestCase[] { createTestCaseVariantSwitchPropagates(), createTestCaseCopySubheadDeletePreservesSlots(), createTestCaseMainReorderKeepsCopySlots(), + createTestCaseMainChildMovedOutLeavesCopy(), ]; } diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainChildMovedOutLeavesCopy.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainChildMovedOutLeavesCopy.ts new file mode 100644 index 0000000000..04df9632fd --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseMainChildMovedOutLeavesCopy.ts @@ -0,0 +1,59 @@ +import { Board } from "@penpot/plugin-types"; +import { TestCase } from "../test-suite/TestCase.ts"; +import { Color } from "../model/Color"; +import { ShapePropFillColor } from "../model/ShapeProp.ts"; +import { Assert } from "../util/Assert.ts"; +import { OpAssert } from "../operations/OpAssert"; +import { OpSequence } from "../operations/OpSequence.ts"; +import { OpOptional } from "../operations/OpOptional.ts"; +import { OpChangeProperty } from "../operations/OpChangeProperty"; +import { OpCreateBoard } from "../operations/OpCreateBoard"; +import { OpCreateSimpleComponentWithCopy } from "../operations/OpCreateSimpleComponentWithCopy"; +import { OpMoveShape } from "../operations/OpMoveShape"; + +const BASELINE = new Color("#aaaaaa"); +const EDIT_COLOR = new Color("#ff0000"); + +/** + * A shape moved out of a main leaves its copies, also when another edit lands + * right after the move, before propagation has run. + * + * The baseline assertion waits for the setup to settle; the setup's own commits + * would otherwise sync the copy in the same pass and hide the result. The move + * and the optional edit then run back to back, so their commits arrive together; + * without the edit, the move is the last commit. + */ +export function createTestCaseMainChildMovedOutLeavesCopy(): TestCase { + const foundation = new OpCreateSimpleComponentWithCopy(BASELINE); + const { mainChild, copyRoot } = foundation.roles; + const outside = new OpCreateBoard("Outside"); + const outsideBoard = outside.roles.board; + + const move = new OpMoveShape(mainChild, outsideBoard, "main rect", "outside board"); + + return new TestCase( + "MainChildMovedOutLeavesCopy", + "A component containing a single rectangle is created, plus a copy of it and a board " + + "outside the component. The main's rectangle is moved into that board, optionally " + + "followed at once by a fill change on the board. The copy must lose its rectangle " + + "either way.", + new OpSequence( + foundation, + outside, + new OpAssert("the copy starts with its rectangle", (s) => { + const copyChildren = (s.get(copyRoot) as Board).children; + Assert.that(copyChildren.length === 1, `the copy should have one child, found ${copyChildren.length}`); + }), + move, + new OpOptional(new OpChangeProperty(outsideBoard, new ShapePropFillColor(), EDIT_COLOR, "outside board")), + new OpAssert("the rectangle left the main and the copy", (s) => { + move.assertHasMoved(s, mainChild); + const copyChildren = (s.get(copyRoot) as Board).children; + Assert.that( + copyChildren.length === 0, + `the copy should have no children, found ${copyChildren.map((c) => c.name).join(", ")}` + ); + }) + ) + ); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateBoard.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateBoard.ts new file mode 100644 index 0000000000..eeb7bb64ff --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpCreateBoard.ts @@ -0,0 +1,39 @@ +import { Board } from "@penpot/plugin-types"; +import { Operation } from "../core/Operation"; +import { Role } from "../core/Role"; +import { RoleBundle } from "../core/RoleBundle"; +import { Situation } from "../core/Situation"; + +/** The roles exposed by {@link OpCreateBoard}. */ +class RolesBoard extends RoleBundle { + /** The free board the operation created. */ + readonly board = new Role("board"); +} + +/** + * Creates a free board on the page, outside any component, and binds it to its + * `board` role. Gives a case somewhere to move shapes to, or to edit, apart from + * the configuration under test. + */ +export class OpCreateBoard extends Operation { + readonly roles = new RolesBoard(); + + /** + * @param boardName - the name given to the new board + */ + constructor(private readonly boardName: string) { + super(); + } + + async applyTo(situation: Situation): Promise { + const board = penpot.createBoard(); + board.name = this.boardName; + board.resize(100, 100); + situation.applyPosAdvanceX(board); + situation.bind(this.roles.board, board); + } + + toString(): string { + return `create board ${this.boardName}`; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpMoveShape.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpMoveShape.ts new file mode 100644 index 0000000000..6795dc121b --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpMoveShape.ts @@ -0,0 +1,53 @@ +import { Board } from "@penpot/plugin-types"; +import { Operation } from "../core/Operation"; +import { Situation } from "../core/Situation"; +import { ShapeTarget, resolveTarget } from "../core/ShapeTarget"; +import { Assert } from "../util/Assert"; + +/** + * Moves the shape a target resolves to into another parent, via the Plugin API's + * `appendChild`. Used to move a shape out of the main of a component, which must + * remove the corresponding shape from its copies. + */ +export class OpMoveShape extends Operation { + /** + * @param target - resolves the shape to move (role or situation-derived) + * @param newParent - resolves the board that receives the shape + * @param targetLabel - a human-readable name for the moved shape, for the log + * @param parentLabel - a human-readable name for the new parent, for the log + */ + constructor( + private readonly target: ShapeTarget, + private readonly newParent: ShapeTarget, + private readonly targetLabel: string = String(target), + private readonly parentLabel: string = String(newParent) + ) { + super(); + } + + async applyTo(situation: Situation): Promise { + const shape = resolveTarget(this.target, situation); + const parent = resolveTarget(this.newParent, situation) as Board; + parent.appendChild(shape); + } + + /** + * Asserts that the shape `target` resolves to now sits in this operation's + * new parent. + * + * @param situation - the situation to resolve the targets against + * @param target - resolves the shape expected to have moved + */ + assertHasMoved(situation: Situation, target: ShapeTarget): void { + const shape = resolveTarget(target, situation); + const parent = resolveTarget(this.newParent, situation); + Assert.that( + shape.parent?.id === parent.id, + `expected "${shape.name}" inside "${parent.name}" but its parent is "${shape.parent?.name}"` + ); + } + + toString(): string { + return `move ${this.targetLabel} into ${this.parentLabel}`; + } +} From f1d15aee4b2be45f1da7466b1cca0886ad519a90 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 27 Oct 2026 19:47:10 +0100 Subject: [PATCH 13/65] :sparkles: Keep toast for file reparation open until user closes it (#12662) --- frontend/src/app/main/data/workspace/notifications.cljs | 5 ++++- .../frontend_tests/data/workspace_notifications_test.cljs | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index c733337e2a..6945fb00a9 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -279,7 +279,10 @@ :redo-changes (vec changes) :undo-changes []})) (if (:repaired msg) - (rx/of (ntf/info (tr "workspace.notifications.file-repaired"))) + (rx/of (ntf/show {:content (tr "workspace.notifications.file-repaired") + :type :toast + :level :info + :timeout nil})) (rx/empty)))))) (defn handle-file-deleted diff --git a/frontend/test/frontend_tests/data/workspace_notifications_test.cljs b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs index 95872a0034..aceafe6bed 100644 --- a/frontend/test/frontend_tests/data/workspace_notifications_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs @@ -42,6 +42,8 @@ state (ptk/update show {})] (t/is (= [::dch/commit ::ntf/show] types)) (t/is (= :info (get-in state [:notification :level]))) + (t/is (nil? (get-in state [:notification :timeout])) + "the toast stays until the user closes it") (t/is (= (i18n/tr "workspace.notifications.file-repaired") (get-in state [:notification :content]))))) From bad673eb8da126d577e7be65761b97a19fed4e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Wed, 28 Oct 2026 08:32:12 +0100 Subject: [PATCH 14/65] :books: Fix the migration guide PDF link (#12656) The Nextcloud link to the enterprise migration guide no longer works. Point it to the new share. The box also had a bold "Open the full guide (PDF)" line that looked clickable but was not, with the real link below it. Keep a single link and mark it as a PDF at the end of the text. AI-assisted-by: claude-opus-5-5 --- docs/user-guide/first-steps/migration-guide.njk | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/user-guide/first-steps/migration-guide.njk b/docs/user-guide/first-steps/migration-guide.njk index cd8600ff68..23450d3f1a 100644 --- a/docs/user-guide/first-steps/migration-guide.njk +++ b/docs/user-guide/first-steps/migration-guide.njk @@ -9,8 +9,7 @@ desc: Move a design system from Figma to Penpot. Read a short summary of the ent

If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.

What the guide covers

From ac52c419036a5226738c2594aa904fe4b106c683 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 09:20:22 +0100 Subject: [PATCH 15/65] :recycle: Use DS icons in the comments panel filter (#12561) * :recycle: Use DS icons in the comments panel filter The comments panel filter (workspace and viewer sidebar) draws its trigger arrow and menu ticks with the DS icons, as the viewer comments filter does. The trigger button says whether its menu is open (aria-expanded, aria-haspopup). The menu items stay: they move with the menus group. The dropdown item placeholder styles any svg inside a span with a 12px box and a stroke color, so the tick icon class sets its 16px box and currentColor stroke. Nothing changes on screen. A visual test covers the trigger and the open menu. AI-assisted-by: claude-opus-5-5 * :recycle: Move comments panel styles closer to the DS The comments panel stylesheet resolves its legacy sizes, spacing, mixins and color aliases to DS values and tokens, with logical properties, and inlines the asset element placeholder of the filter trigger. refactor/ stays only for the dropdown placeholders, which move with the menus group. The legacy body-small typography becomes the DS one, which differs only in its line height (1.3 instead of 1.4): the menu labels sit 1px higher. The trigger looks the same at rest and on hover; the visual test now also covers the hover. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 26 +++++ .../src/app/main/ui/workspace/comments.cljs | 21 ++-- .../src/app/main/ui/workspace/comments.scss | 109 ++++++++++-------- 3 files changed, 104 insertions(+), 52 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 5893ae0d80..693d10ecff 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -1223,3 +1223,29 @@ test.describe("Assets tab controls", () => { await expect(modal).toHaveScreenshot(shot); }); }); + +test.describe("Comments panel filter", () => { + test("Shows the filter trigger and its menu", async ({ page }) => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.showComments(); + + const trigger = workspace.rightSidebar.getByRole("button", { + name: "Show all comments", + }); + await expect(trigger).toBeVisible(); + await page.mouse.move(640, 600); + await expect(trigger).toHaveScreenshot(shot); + + await trigger.hover(); + await expect(trigger).toHaveScreenshot(shot); + + await trigger.click(); + const menu = page.locator("[class*='comment-mode-dropdown']"); + await expect(menu).toBeVisible(); + await page.mouse.move(640, 600); + await expect(menu).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/comments.cljs b/frontend/src/app/main/ui/workspace/comments.cljs index a6596bf3b6..7ab6ca7dda 100644 --- a/frontend/src/app/main/ui/workspace/comments.cljs +++ b/frontend/src/app/main/ui/workspace/comments.cljs @@ -17,10 +17,9 @@ [app.main.ui.comments :as cmt] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.context :as ctx] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.product.empty-state :refer [empty-state*]] [app.main.ui.ds.product.panel-title :refer [panel-title*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -56,25 +55,29 @@ :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-all-comments")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]] + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]] [:li {:class (stl/css-case :dropdown-item true :selected (= :yours cmode)) :data-value "yours" :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-your-comments")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]] + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]] [:li {:class (stl/css-case :dropdown-item true :selected (= :mentions cmode)) :data-value "mentions" :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-mentions")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]] + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]] [:li {:class (stl/css :separator)}] [:li {:class (stl/css-case :dropdown-item true :selected (= :pending cshow)) :on-click update-show} [:span {:class (stl/css :label)} (tr "labels.hide-resolved-comments")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]]])) + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]]])) (mf/defc comments-sidebar* [{:keys [profiles threads page-id from-viewer]}] @@ -131,6 +134,9 @@ :on-close close-section}] [:button {:class (stl/css :mode-dropdown-wrapper) + :type "button" + :aria-haspopup "menu" + :aria-expanded options? :on-click toggle-mode-selector} [:span {:class (stl/css :mode-label)} @@ -138,7 +144,8 @@ (nil :all) (tr "labels.show-all-comments") :yours (tr "labels.show-your-comments") :mentions (tr "labels.show-mentions"))] - [:div {:class (stl/css :arrow-icon)} deprecated-icon/arrow]] + [:div {:class (stl/css :arrow-icon)} + [:> icon* {:icon-id i/arrow-down :size "s" :class (stl/css :arrow-svg)}]]] [:& dropdown {:show options? :on-close #(reset! state* false)} diff --git a/frontend/src/app/main/ui/workspace/comments.scss b/frontend/src/app/main/ui/workspace/comments.scss index b94930789d..9e7471e394 100644 --- a/frontend/src/app/main/ui/workspace/comments.scss +++ b/frontend/src/app/main/ui/workspace/comments.scss @@ -6,17 +6,22 @@ @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; + +// refactor/ stays for the dropdown placeholders (%dropdown-wrapper, +// %dropdown-element-base), which move with the menus group. @use "refactor/common-refactor.scss" as deprecated; .comments-section { position: relative; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); display: grid; - grid-template-rows: deprecated.$s-40 deprecated.$s-48 1fr; + grid-template-rows: $sz-40 $sz-48 1fr; } .from-viewer { - padding: 0 deprecated.$s-8; + padding: 0 var(--sp-s); } .comments-title { @@ -24,53 +29,61 @@ } .mode-dropdown-wrapper { - @include deprecated.button-style; - @extend %asset-element; + @include use-typography("body-small"); - background-color: var(--color-background-tertiary); + --mode-dropdown-bg-color: var(--color-background-tertiary); + + border: none; + align-items: center; + color: var(--color-foreground-primary); + background-color: var(--mode-dropdown-bg-color); display: flex; - width: 100%; - max-width: deprecated.$s-256; - height: deprecated.$s-32; - padding: deprecated.$s-8; - border-radius: deprecated.$br-8; - margin: deprecated.$s-16 auto 0 auto; + inline-size: 100%; + max-inline-size: px2rem(256); + block-size: $sz-32; + padding: var(--sp-s); + border-radius: $br-8; + margin: var(--sp-l) auto 0 auto; cursor: pointer; position: relative; + + &:hover { + --mode-dropdown-bg-color: var(--color-background-quaternary); + } } .mode-label { - padding-right: 8px; + padding-inline-end: var(--sp-s); flex-grow: 1; display: flex; justify-content: flex-start; } .arrow-icon { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; - height: deprecated.$s-24; - width: deprecated.$s-24; - - svg { - @extend %button-icon-small; - - transform: rotate(90deg); - stroke: var(--icon-foreground); + .arrow-svg { + flex-shrink: 0; + stroke-width: 1.33px; + color: var(--color-foreground-secondary); } } .comment-mode-dropdown { @extend %dropdown-wrapper; - top: deprecated.$s-92; - left: deprecated.$s-12; - max-width: deprecated.$s-256; - width: 100%; + inset-block-start: px2rem(92); + inset-inline-start: var(--sp-m); + max-inline-size: px2rem(256); + inline-size: 100%; } .viewer-dropdown { - left: deprecated.$s-8; + inset-inline-start: var(--sp-s); } .dropdown-item { @@ -79,35 +92,41 @@ justify-content: space-between; .icon { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; - height: deprecated.$s-24; - width: deprecated.$s-24; - - svg { - @extend %button-icon-small; - - stroke: transparent; + // %dropdown-element-base styles any svg inside a span (12px box and a + // stroke color); the DS icon needs its 16px box and currentColor. + .tick-icon { + flex-shrink: 0; + inline-size: $sz-16; + block-size: $sz-16; + stroke: currentcolor; + stroke-width: 1.33px; + color: transparent; } } .label { - @include deprecated.body-small-typography; + @include use-typography("body-small"); } &:hover { - .icon svg { - stroke: transparent; + .tick-icon { + color: transparent; } } &.selected { .label { - color: var(--menu-foreground-color); + color: var(--color-foreground-primary); } - .icon svg { - stroke: var(--icon-foreground-hover); + .tick-icon { + color: var(--color-foreground-primary); } } } @@ -116,13 +135,13 @@ position: relative; block-size: var(--sp-xs); inline-size: calc(100% + var(--sp-s)); - border-top: $b-1 solid var(--color-background-quaternary); - left: calc(-1 * var(--sp-xs)); - margin-top: var(--sp-s); + border-block-start: $b-1 solid var(--color-background-quaternary); + inset-inline-start: calc(-1 * var(--sp-xs)); + margin-block-start: var(--sp-s); } .comments-section-content { - height: 100%; + block-size: 100%; overflow-y: auto; } From c05b2e2ead3b3a8173c2a43d296f12dab01e9bc3 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 09:20:41 +0100 Subject: [PATCH 16/65] :recycle: Use DS buttons and icons in the share link modal (#12562) * :recycle: Use DS buttons and icons in the share link modal - The close button becomes a ghost DS icon button named "Close"; the class that places it goes on the tooltip wrapper. - "Get link", "Destroy link" and the confirmation buttons were raw inputs; they become DS primary, destructive and secondary buttons. - The copy link button stays a raw segment of the link field; its icon becomes the DS clipboard icon, a bit larger than the legacy drawing. - "Manage permissions" stays a link-style toggle; its arrow becomes the DS icon (the rotation still shows the state) and it says whether the permissions are expanded. The page checkboxes and the permission selects stay for their own groups. A new create share link fixture and a visual test cover the modal before and after getting a link, with the permissions expanded, and the destroy confirmation. AI-assisted-by: claude-opus-5-5 * :recycle: Move share link modal styles closer to the DS The share link stylesheet resolves its legacy sizes, spacing, mixins, z-index and color aliases to DS values and tokens, with logical properties. The copy button inlines %button-secondary with local color variables, so it looks the same at rest, on hover, on focus and while pressed; the visual test now also covers its hover. The legacy typographies become the DS ones, each differing in one detail: the title and "Manage permissions" use headline-small (12px instead of 11px) and the texts body-small (line height 1.3 instead of 1.4), so the modal changes height by about 1px. refactor/ stays only for the modal container, the link input and the page checkboxes. AI-assisted-by: claude-opus-5-5 * :recycle: Use a DS icon button for the share link copy button The copy button next to the link was a raw button with the secondary button styles inlined in share_link.scss. It is now a secondary icon-button* named "Copy link", so the .copy-button block goes away (review feedback). Visible changes: the DS secondary look (grey icon at rest, no lighter background on hover) and a 32px wide button instead of 28px, so the link text ends 4px earlier. AI-assisted-by: claude-opus-5-5 --- .../data/viewer/create-share-link.json | 8 + .../ui/visual-specs/visual-viewer.spec.js | 47 +++++ .../src/app/main/ui/viewer/share_link.cljs | 59 +++--- .../src/app/main/ui/viewer/share_link.scss | 178 +++++++++--------- 4 files changed, 173 insertions(+), 119 deletions(-) create mode 100644 frontend/playwright/data/viewer/create-share-link.json diff --git a/frontend/playwright/data/viewer/create-share-link.json b/frontend/playwright/data/viewer/create-share-link.json new file mode 100644 index 0000000000..2d92a75545 --- /dev/null +++ b/frontend/playwright/data/viewer/create-share-link.json @@ -0,0 +1,8 @@ +{ + "~:id": "~u0c7a1f2e-1b2c-8180-8007-0000000000a1", + "~:file-id": "~udd5cc0bb-91ff-81b9-8004-77df9cd3edb1", + "~:who-comment": "team", + "~:who-inspect": "team", + "~:pages": { "~#set": ["~udd5cc0bb-91ff-81b9-8004-77df9cd3edb2"] }, + "~:owner-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b" +} diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 5dac8b7377..450bba4a2e 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -442,3 +442,50 @@ test.describe("Copy buttons", () => { await expect(copy).toHaveScreenshot(shot); }); }); + +test.describe("Share link modal controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Get, copy and destroy link", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithSingleBoard(); + await viewerPage.mockRPC( + "create-share-link", + "viewer/create-share-link.json", + ); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + + await page.getByRole("button", { name: "Share" }).click(); + const getLink = page.getByRole("button", { name: "Get link" }); + await expect(getLink).toBeVisible(); + const modal = page.locator("[class*='share-link-dialog']"); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + await getLink.click(); + const destroy = page.getByRole("button", { name: "Destroy link" }); + await expect(destroy).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + // A raw button with a title before, a DS icon button named "Copy link" after. + await modal + .getByTitle("Copy link") + .or(modal.getByRole("button", { name: "Copy link" })) + .hover(); + await expect(modal).toHaveScreenshot(shot); + + await page.getByText("Manage permissions").click(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + await destroy.click(); + await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/viewer/share_link.cljs b/frontend/src/app/main/ui/viewer/share_link.cljs index f0a4c3de22..32e9117908 100644 --- a/frontend/src/app/main/ui/viewer/share_link.cljs +++ b/frontend/src/app/main/ui/viewer/share_link.cljs @@ -19,6 +19,9 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.components.select :refer [select]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] [app.util.clipboard :as clipboard] [app.util.dom :as dom] @@ -169,10 +172,11 @@ [:div {:class (stl/css :share-link-header)} [:h2 {:class (stl/css :share-link-title)} (tr "common.share-link.title")] - [:button {:class (stl/css :modal-close-button) - :on-click on-close - :title (tr "labels.close")} - deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-button) + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close}]] [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :share-link-section)} (when (and (not confirm?) (some? current-link)) @@ -183,10 +187,10 @@ :placeholder (tr "common.share-link.placeholder") :read-only true}] - [:button {:class (stl/css :copy-button) - :title (tr "viewer.header.share.copy-link") - :on-click copy-link} - deprecated-icon/clipboard]]) + [:> icon-button* {:variant "secondary" + :aria-label (tr "viewer.header.share.copy-link") + :icon i/clipboard + :on-click copy-link}]]) [:div {:class (stl/css :hint-wrapper)} (when (not ^boolean confirm?) @@ -197,37 +201,38 @@ [:div {:class (stl/css :description)} (tr "common.share-link.confirm-deletion-link-description")] [:div {:class (stl/css :actions)} - [:input {:type "button" - :class (stl/css :button-cancel) - :on-click #(reset! confirm* false) - :value (tr "labels.cancel")}] - [:input {:type "button" - :class (stl/css :button-danger) - :on-click delete-link - :value (tr "common.share-link.destroy-link")}]]] + [:> button* {:variant "secondary" + :type "button" + :on-click #(reset! confirm* false)} + (tr "labels.cancel")] + [:> button* {:variant "destructive" + :type "button" + :on-click delete-link} + (tr "common.share-link.destroy-link")]]] (some? current-link) - [:input - {:type "button" - :class (stl/css :button-danger) - :on-click try-delete-link - :value (tr "common.share-link.destroy-link")}] + [:> button* {:variant "destructive" + :type "button" + :on-click try-delete-link} + (tr "common.share-link.destroy-link")] :else - [:input - {:type "button" - :class (stl/css :button-active) - :on-click create-link - :value (tr "common.share-link.get-link")}])]] + [:> button* {:variant "primary" + :type "button" + :on-click create-link} + (tr "common.share-link.get-link")])]] (when (not ^boolean confirm?) [:div {:class (stl/css :permissions-section)} [:button {:class (stl/css :manage-permissions) + :type "button" + :aria-expanded perms-visible? :on-click toggle-perms-visibility} [:span {:class (stl/css-case :icon true :rotated perms-visible?)} - deprecated-icon/arrow] + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]] (tr "common.share-link.manage-ops")] (when ^boolean perms-visible? diff --git a/frontend/src/app/main/ui/viewer/share_link.scss b/frontend/src/app/main/ui/viewer/share_link.scss index 43e2ba8910..336ad5ede3 100644 --- a/frontend/src/app/main/ui/viewer/share_link.scss +++ b/frontend/src/app/main/ui/viewer/share_link.scss @@ -4,149 +4,140 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; + +// refactor/ stays for the modal placeholder (%modal-container-base), the +// link input (%input-element) and the page checkboxes (%input-checkbox, +// %button-icon-small for their tick), which move with their own groups. @use "refactor/common-refactor.scss" as deprecated; .share-modal { display: block; position: absolute; - top: deprecated.$s-52; - right: deprecated.$s-12; - left: calc(100vw - deprecated.$s-512); - z-index: deprecated.$z-index-modal; + inset-block-start: px2rem(52); + inset-inline: calc(100vw - #{$sz-512}) var(--sp-m); + z-index: var(--z-index-set); } .share-link-dialog { @extend %modal-container-base; - min-height: unset; + min-block-size: unset; } .share-link-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .share-link-title { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-button { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include deprecated.body-small-typography; - @include deprecated.flex-column; + @include use-typography("body-small"); - gap: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); } .share-link-section { - @include deprecated.flex-column; - - gap: deprecated.$s-8; + display: flex; + flex-direction: column; + gap: var(--sp-s); } .hint-wrapper { - @include deprecated.flex-row; + display: flex; + align-items: center; + gap: var(--sp-xs); } .hint { flex-grow: 1; - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } .custon-input-wrapper { - @include deprecated.flex-row; - - border-radius: deprecated.$br-8; - height: deprecated.$s-32; - width: 100%; - background-color: var(--input-background-color); + display: flex; + align-items: center; + gap: var(--sp-xs); + border-radius: $br-8; + block-size: $sz-32; + inline-size: 100%; + background-color: var(--color-background-tertiary); } .input-text { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--input-foreground-color-active); - padding-left: deprecated.$s-8; + color: var(--color-foreground-primary); + padding-inline-start: var(--sp-s); margin: 0; flex-grow: 1; &:focus { outline: none; - border: deprecated.$s-1 solid var(--input-border-color-active); - } -} - -.copy-button { - @extend %button-secondary; - @include deprecated.flex-row; - - gap: deprecated.$s-8; - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground-hover); + border: $b-1 solid var(--color-accent-primary); } } .description { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--modal-text-foreground-color); - margin-bottom: deprecated.$s-24; + color: var(--color-foreground-secondary); + margin-block-end: var(--sp-xxl); } .actions { - @include deprecated.flex-row; - + display: flex; + align-items: center; + gap: var(--sp-xs); justify-content: flex-end; } -.button-active { - @extend %modal-accept-btn; -} - -.button-cancel { - @extend %modal-cancel-btn; -} - -.button-danger { - @extend %modal-danger-btn; -} - .permissions-section { - @include deprecated.flex-column; - - gap: deprecated.$s-8; + display: flex; + flex-direction: column; + gap: var(--sp-s); } .manage-permissions { - @include deprecated.button-style; - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--menu-foreground-color-rest); - height: deprecated.$s-32; + border: none; + background: none; + cursor: pointer; + color: var(--color-foreground-secondary); + block-size: $sz-32; display: flex; align-items: center; padding: 0; } .icon { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + margin-inline-end: px2rem(6); - margin-right: deprecated.$s-6; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); + .arrow-icon { + flex-shrink: 0; + stroke-width: 1px; + color: var(--color-foreground-secondary); } &.rotated { @@ -158,74 +149,77 @@ .access-mode, .inspect-mode { display: flex; - width: 100%; + inline-size: 100%; } .view-mode { - max-height: deprecated.$s-216; + max-block-size: px2rem(216); overflow: hidden auto; scrollbar-gutter: stable; } .subtitle { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); display: flex; align-items: center; justify-content: flex-start; - width: deprecated.$s-136; - height: deprecated.$s-32; + inline-size: px2rem(136); + block-size: $sz-32; } .items { flex-grow: 1; - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } .select-all-row { - @include deprecated.flex-row; - + display: flex; + align-items: center; + gap: var(--sp-xs); justify-content: space-between; - height: deprecated.$s-32; - border-bottom: deprecated.$s-1 solid var(--input-border-color-disabled); + block-size: $sz-32; + border-block-end: $b-1 solid var(--color-background-quaternary); } .select-all-label { - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } .pages-selection { margin: 0; + // The page rows have no class of their own. li { - border-bottom: deprecated.$s-1 solid var(--input-border-color-disabled); + border-block-end: $b-1 solid var(--color-background-quaternary); } li:last-child { - border-bottom: none; + border-block-end: none; } } .count-pages, .current-tag { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--input-foreground-color); + color: var(--color-foreground-secondary); } .checkbox-wrapper { @extend %input-checkbox; - height: deprecated.$s-32; + block-size: $sz-32; padding: 0; span.checked { - background-color: var(--input-checkbox-background-color-active); - border: deprecated.$s-1 solid var(--input-checkbox-background-color-active); + background-color: var(--color-accent-primary); + border: $b-1 solid var(--color-accent-primary); + // The legacy tick icon has no class; it moves with the checkboxes. svg { @extend %button-icon-small; - stroke: var(--input-checkbox-foreground-color-active); + stroke: var(--color-background-primary); } } } From 08228324db14df2c0b8a57408af37a6e3af550e5 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 09:21:00 +0100 Subject: [PATCH 17/65] :recycle: Use DS icons in the dashboard templates section (#12535) The arrow and add icons of the dashboard libraries and templates section use the DS icons and get their color from the color property. The collapse button says whether the section is expanded, and the left scroll button, which had no name, is now named "Previous". The scroll buttons stay raw: they are round 36px buttons with no DS equivalent. The template cards are anchors without href; they go to the anchors backlog. A new fixture lists eight builtin templates, and a visual test covers the cards, a card hover, the scroll buttons and the collapsed title. No visual change. AI-assisted-by: claude-opus-5-5 --- .../data/dashboard/get-builtin-templates.json | 34 +++++++++++++++ .../ui/visual-specs/visual-dashboard.spec.js | 43 +++++++++++++++++++ .../src/app/main/ui/dashboard/templates.cljs | 27 ++++++------ .../src/app/main/ui/dashboard/templates.scss | 12 ++---- 4 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 frontend/playwright/data/dashboard/get-builtin-templates.json diff --git a/frontend/playwright/data/dashboard/get-builtin-templates.json b/frontend/playwright/data/dashboard/get-builtin-templates.json new file mode 100644 index 0000000000..ab08f3b31d --- /dev/null +++ b/frontend/playwright/data/dashboard/get-builtin-templates.json @@ -0,0 +1,34 @@ +[ + { + "~:id": "tokens-starter-kit", + "~:name": "Design tokens starter kit" + }, + { + "~:id": "penpot-design-system", + "~:name": "Penpot Design System | Pencil" + }, + { + "~:id": "wireframing-kit", + "~:name": "Wireframe library" + }, + { + "~:id": "prototype-examples", + "~:name": "Prototype template" + }, + { + "~:id": "plants-app", + "~:name": "UI mockup example" + }, + { + "~:id": "tutorial-for-beginners", + "~:name": "Tutorial for beginners" + }, + { + "~:id": "lucide-icons", + "~:name": "Lucide Icons" + }, + { + "~:id": "font-awesome", + "~:name": "Font Awesome" + } +] diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 06e3cd2639..d4563b69d2 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -629,3 +629,46 @@ test.describe("Onboarding questions controls", () => { await expect(actions).toHaveScreenshot(shot); }); }); + +test.describe("Templates section controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Template cards, scroll buttons and collapse", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.mockRPC( + "get-builtin-templates", + "dashboard/get-builtin-templates.json", + ); + await dashboardPage.goToDashboard(); + + const section = page.locator("[class*='dashboard-templates-section']"); + const card = section.locator("[class*='template-card']").first(); + await expect(card).toBeVisible(); + await expect(section.locator("img").first()).toHaveJSProperty( + "complete", + true, + ); + await page.mouse.move(640, 100); + await expect(section).toHaveScreenshot(shot); + + // Hovering a card shows its add icon in the accent color. + await card.hover(); + await expect(card).toHaveScreenshot(shot); + + const moveButtons = section.locator("[class*='move-button']"); + await moveButtons.last().hover(); + await expect(moveButtons.last()).toHaveScreenshot(shot); + + await moveButtons.last().click(); + await expect(moveButtons).toHaveCount(2); + await page.mouse.move(640, 100); + await expect(moveButtons.first()).toHaveScreenshot(shot); + + await section.locator("[class*='title-btn']").click(); + await expect(section).toHaveClass(/collapsed/); + await page.waitForTimeout(500); + await expect(section.locator("[class*='title-btn']")).toHaveScreenshot( + shot, + ); + }); +}); diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index cecb4db1aa..55e0176651 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -16,7 +16,7 @@ [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.dom :as dom] [app.util.dom.normalize-wheel :as nw] [app.util.i18n :refer [tr]] @@ -25,12 +25,6 @@ [okulary.core :as l] [rumext.v2 :as mf])) -(def ^:private arrow-icon - (deprecated-icon/icon-xref :arrow (stl/css :arrow-icon))) - -(def ^:private download-icon - (deprecated-icon/icon-xref :add (stl/css :download-icon))) - (def builtin-templates (l/derived :builtin-templates st/state)) @@ -77,6 +71,7 @@ [:div {:class (stl/css :title)} [:button {:tab-index "0" :class (stl/css :title-btn) + :aria-expanded (not is-collapsed) :on-click on-click :on-key-down on-key-down} [:span {:class (stl/css :title-text)} @@ -85,11 +80,13 @@ [:span {:class (stl/css :title-icon-container)} [:span {:class (stl/css :title-icon-text)} (tr "labels.show")] [:span {:class (stl/css :title-icon :title-icon-collapsed)} - arrow-icon]] + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]]] [:span {:class (stl/css :title-icon-container)} [:span {:class (stl/css :title-icon-text)} (tr "labels.hide")] [:span {:class (stl/css :title-icon)} - arrow-icon]])]])) + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]]])]])) (mf/defc card-item* [{:keys [item index is-visible collapsed on-import]}] @@ -131,7 +128,8 @@ (if @hover? (tr "dashboard.template.add-to-project") (:name item))] - download-icon]]])) + [:> icon* {:icon-id i/add + :class (stl/css :download-icon)}]]]])) (mf/defc card-item-link* [{:keys [total is-visible collapsed section]}] @@ -286,8 +284,10 @@ [:button {:class (stl/css :move-button :move-left) :tab-index (if ^boolean collapsed "-1" "0") :on-click on-move-left - :on-key-down on-move-left} - arrow-icon]) + :on-key-down on-move-left + :aria-label (tr "labels.previous")} + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]]) (when (:right @can-move) [:button {:class (stl/css :move-button :move-right) @@ -295,4 +295,5 @@ :on-click on-move-right :on-key-down on-move-right :aria-label (tr "labels.next")} - arrow-icon])])) + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]])])) diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index eabb3c8976..1be61d4465 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -105,10 +105,8 @@ align-items: center; height: $sz-16; width: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--color-foreground-secondary); + color: var(--color-foreground-secondary); } .move-button { @@ -131,7 +129,7 @@ background-color: var(--color-accent-primary); .arrow-icon { - stroke: var(--color-background-tertiary); + color: var(--color-background-tertiary); } } } @@ -196,7 +194,7 @@ border-color: var(--color-accent-primary); .download-icon { - stroke: var(--color-accent-primary); + color: var(--color-accent-primary); } .card-text { @@ -243,10 +241,8 @@ align-items: center; height: $sz-16; width: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--icon-foreground); + color: var(--color-foreground-secondary); } .template-link { From 00dec9143d3be8b9c8988f7d21928e3257b50d23 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 28 Oct 2026 10:25:43 +0100 Subject: [PATCH 18/65] :tada: Add SREPL helpers to update emails and clear bounce reports (#12668) Support currently fixes wrong profile emails and false bounce marks with raw SQL. Add update-profile-email! (rejects collisions) and clear-email-complaint-reports! (deletes global and profile reports and unmutes the matching profile) so these ops run through the REPL. Closes #12663 --- backend/src/app/srepl/main.clj | 83 ++++++++++++++ .../test/backend_tests/srepl_main_test.clj | 101 ++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 8a475e2cae..10656d0c28 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -169,6 +169,89 @@ (db/get-update-count) (pos?))))))) +(defn update-profile-email! + "Support helper: change a profile's email address. + + Looks up the profile by its current email. Rejects with + `:email-already-exists` when the new address is already used by + another non-deleted profile. Does not clear bounce/complaint + reports; call `clear-email-complaint-reports!` for that." + [& {:keys [email new-email]}] + (assert (string? email) "expected email") + (assert (string? new-email) "expected new-email") + + (let [email (profile/clean-email email) + new-email (profile/clean-email new-email)] + (db/tx-run! sys/system + (fn [{:keys [::db/conn] :as system}] + (let [profile (profile/get-profile-by-email conn email)] + (when-not profile + (ex/raise :type :not-found + :code :profile-not-found + :hint "profile not found for email" + :email email)) + + (if (= email new-email) + :unchanged + (do + (when (profile/get-profile-by-email conn new-email) + (ex/raise :type :validation + :code :email-already-exists)) + + (db/update! conn :profile + {:email new-email} + {:id (:id profile)} + {::db/return-keys false}) + + (audit/insert system + {:name "update-profile-email" + :type "action" + :props {:id (:id profile) + :email email + :new-email new-email} + :context {:triggered-by "srepl" + :cause "explicit call to update-profile-email!"}}) + :updated))))))) + +(defn clear-email-complaint-reports! + "Support helper: delete bounce and complaint reports for an email + from `global_complaint_report` and, when a profile owns that email, + from `profile_complaint_report`. Also unmutes that profile so mail + can be sent again after a false-positive bounce or complaint." + [email] + (assert (string? email) "expected email") + + (let [email (profile/clean-email email)] + (db/tx-run! sys/system + (fn [{:keys [::db/conn] :as system}] + (let [profile (db/get* conn :profile {:email email}) + global-deleted + (-> (db/delete! conn :global-complaint-report {:email email}) + (db/get-update-count)) + profile-deleted + (if profile + (-> (db/delete! conn :profile-complaint-report + {:profile-id (:id profile)}) + (db/get-update-count)) + 0)] + (when profile + (db/update! conn :profile + {:is-muted false} + {:id (:id profile)} + {::db/return-keys false})) + + (when (or profile (pos? global-deleted)) + (audit/insert system + {:name "clear-email-complaint-reports" + :type "action" + :props (cond-> {:email email + :global-deleted global-deleted + :profile-deleted profile-deleted} + profile (assoc :id (:id profile))) + :context {:triggered-by "srepl" + :cause "explicit call to clear-email-complaint-reports!"}}) + :cleared)))))) + (defn parse-emails "Parse the emails into a seq of cleaned emails. Accepts a single email, a comma separated list of emails or a coll of emails. diff --git a/backend/test/backend_tests/srepl_main_test.clj b/backend/test/backend_tests/srepl_main_test.clj index aebc99c9bf..3b44e7352b 100644 --- a/backend/test/backend_tests/srepl_main_test.clj +++ b/backend/test/backend_tests/srepl_main_test.clj @@ -205,3 +205,104 @@ ;; to the zero one. (t/is (nil? (:profile-id event))) (t/is (= (:id profile) (get-in event [:props :id]))))))))) + +(t/deftest update-profile-email-changes-email + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}] + (with-redefs [sys/system th/*system*] + (let [profile (th/create-profile* 1 {:is-active true}) + result (srepl/update-profile-email! + :email (:email profile) + :new-email "corrected@example.com")] + (t/is (= :updated result)) + (t/is (= "corrected@example.com" + (:email (th/db-get :profile {:id (:id profile)})))) + (let [event (->> (:call-args-list @audit-mock) + (map second) + first)] + (t/is (= "update-profile-email" (:name event))) + (t/is (= "action" (:type event))) + (t/is (= (:id profile) (get-in event [:props :id]))) + (t/is (= (:email profile) (get-in event [:props :email]))) + (t/is (= "corrected@example.com" + (get-in event [:props :new-email])))))))) + +(t/deftest update-profile-email-rejects-collision + (with-redefs [sys/system th/*system*] + (let [profile1 (th/create-profile* 1 {:is-active true}) + profile2 (th/create-profile* 2 {:is-active true}) + error (try + (srepl/update-profile-email! + :email (:email profile1) + :new-email (:email profile2)) + (catch Throwable e e))] + (t/is (th/ex-of-code? error :email-already-exists)) + (t/is (= (:email profile1) + (:email (th/db-get :profile {:id (:id profile1)}))))))) + +(t/deftest update-profile-email-unchanged-when-same + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}] + (with-redefs [sys/system th/*system*] + (let [profile (th/create-profile* 1 {:is-active true}) + result (srepl/update-profile-email! + :email (:email profile) + :new-email (:email profile))] + (t/is (= :unchanged result)) + (t/is (empty? (:call-args-list @audit-mock))))))) + +(t/deftest update-profile-email-not-found + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}] + (with-redefs [sys/system th/*system*] + (let [error (try + (srepl/update-profile-email! + :email "missing@example.com" + :new-email "corrected@example.com") + (catch Throwable e e))] + (t/is (th/ex-of-code? error :profile-not-found)) + (t/is (= :not-found (:type (ex-data error)))) + (t/is (empty? (:call-args-list @audit-mock))))))) + +(t/deftest clear-email-complaint-reports-deletes-and-unmutes + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}] + (with-redefs [sys/system th/*system*] + (let [profile (th/create-profile* 1 {:is-active true :is-muted true + :email "bounced@example.com"}) + _ (th/create-complaint-for th/*system* + {:id (:id profile) :type :bounce}) + _ (th/create-complaint-for th/*system* + {:id (:id profile) :type :complaint}) + _ (th/create-global-complaint-for th/*system* + {:email (:email profile) + :type :bounce}) + _ (th/create-global-complaint-for th/*system* + {:email (:email profile) + :type :complaint}) + result (srepl/clear-email-complaint-reports! (:email profile))] + (t/is (= :cleared result)) + (t/is (empty? (th/db-query :global-complaint-report + {:email (:email profile)}))) + (t/is (empty? (th/db-query :profile-complaint-report + {:profile-id (:id profile)}))) + (t/is (false? (:is-muted (th/db-get :profile {:id (:id profile)})))) + (let [event (->> (:call-args-list @audit-mock) + (map second) + first)] + (t/is (= "clear-email-complaint-reports" (:name event))) + (t/is (= "action" (:type event))) + (t/is (= (:id profile) (get-in event [:props :id]))) + (t/is (= (:email profile) (get-in event [:props :email])))))))) + +(t/deftest clear-email-complaint-reports-without-profile + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}] + (with-redefs [sys/system th/*system*] + (let [email "orphan-bounce@example.com" + _ (th/create-global-complaint-for th/*system* + {:email email :type :bounce}) + result (srepl/clear-email-complaint-reports! email)] + (t/is (= :cleared result)) + (t/is (empty? (th/db-query :global-complaint-report {:email email}))) + (let [event (->> (:call-args-list @audit-mock) + (map second) + first)] + (t/is (= "clear-email-complaint-reports" (:name event))) + (t/is (= email (get-in event [:props :email]))) + (t/is (nil? (get-in event [:props :id])))))))) From b1018c77b7fd742f6e693aba05cca349fe12b87b Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 10:39:20 +0100 Subject: [PATCH 19/65] :recycle: Use DS icons in the legacy context notification (#12542) * :recycle: Use DS icons in the legacy context notification The legacy context notification (still used by the global context notifications, the fonts page, the team, change email, delete account and export modals) draws its level icon with the DS icons instead of the deprecated icon set, colored through the color property. The icons are the same drawings, so nothing changes on screen. The component stays: the DS context notification has no links, which the global context notifications use. A visual test covers the info and warning levels and a warning with html content. AI-assisted-by: claude-opus-5-5 * :recycle: Move the legacy context notification styles to the DS The legacy context notification no longer imports refactor/: its sizes, spacing, borders and alert color aliases resolve to DS tokens, with logical properties. The legacy body-small typography becomes the DS one, which differs only in its line height (1.3 instead of 1.4): one line notifications do not change, and multi-line ones are about 1.5px shorter per extra line. The unused .default level is commented out with a TODO. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the legacy context notification The SCSS refactor of the legacy context notification left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .default. No visual change. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 36 ++++++++ .../notifications/context_notification.cljs | 28 ++----- .../notifications/context_notification.scss | 82 +++++++++---------- 3 files changed, 82 insertions(+), 64 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index d4563b69d2..007092884f 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -672,3 +672,39 @@ test.describe("Templates section controls", () => { ); }); }); + +test.describe("Context notification", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Info and warning levels", async ({ page }) => { + await page.goto("/?screen=settings-profile"); + + // Info: the change email modal. + await page.getByText("Change email", { exact: true }).click(); + const info = page.locator("[class*='modal-container'] aside"); + await expect(info).toBeVisible(); + await expect(info).toHaveScreenshot(shot); + await page.keyboard.press("Escape"); + await expect(info).toBeHidden(); + + // Warning: the delete account modal. + await page.getByText("Want to remove your account?").click(); + const warning = page.locator("[class*='modal-container'] aside"); + await expect(warning).toBeVisible(); + await expect(warning).toHaveScreenshot(shot); + }); + + test("Warning with html content", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await page + .locator("#font-upload") + .setInputFiles("playwright/data/render-wasm/assets/ebgaramond.ttf"); + const warning = page + .locator("[class*='dashboard-fonts-hero'] aside") + .last(); + await expect(warning).toBeVisible(); + await expect(warning).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/notifications/context_notification.cljs b/frontend/src/app/main/ui/notifications/context_notification.cljs index 923a4958a3..9455f284c2 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.cljs +++ b/frontend/src/app/main/ui/notifications/context_notification.cljs @@ -10,29 +10,16 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.main.ui.components.link-button :as lb] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [rumext.v2 :as mf])) -(def ^:private neutral-icon - (deprecated-icon/icon-xref :msg-neutral (stl/css :icon))) - -(def ^:private error-icon - (deprecated-icon/icon-xref :delete-text (stl/css :icon))) - -(def ^:private success-icon - (deprecated-icon/icon-xref :status-tick (stl/css :icon))) - -(def ^:private info-icon - (deprecated-icon/icon-xref :help (stl/css :icon))) - -(defn get-icon-by-level +(defn- level->icon-id [level] (case level - :warning neutral-icon - :error error-icon - :success success-icon - :info info-icon - neutral-icon)) + :error i/delete-text + :success i/status-tick + :info i/help + i/msg-neutral)) (mf/defc context-notification "They are persistent, informative and non-actionable. @@ -49,7 +36,8 @@ :success (= level :success) :info (= level :info)))} - (get-icon-by-level level) + [:> icon* {:icon-id (level->icon-id level) + :class (stl/css :icon)}] ;; The content can arrive in markdown format, in these cases ;; we will use the prop is-html to true to indicate it and diff --git a/frontend/src/app/main/ui/notifications/context_notification.scss b/frontend/src/app/main/ui/notifications/context_notification.scss index e4cb80b794..cd8de495c1 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.scss +++ b/frontend/src/app/main/ui/notifications/context_notification.scss @@ -4,71 +4,65 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as *; .context-notification { - --context-notification-bg-color: var(--alert-background-color-default); - --context-notification-fg-color: var(--alert-text-foreground-color-default); - --context-notification-icon-color: var(--alert-icon-foreground-color-default); - --context-notification-border-color: var(--alert-border-color-default); + --context-notification-bg-color: var(--color-background-primary); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-foreground-primary); + --context-notification-border-color: var(--color-background-quaternary); box-sizing: border-box; display: grid; - grid-template-columns: deprecated.$s-16 1fr; - gap: deprecated.$s-8; - min-height: deprecated.$s-32; - height: fit-content; - width: 100%; - padding: deprecated.$s-8; - border: deprecated.$s-1 solid var(--context-notification-border-color); - border-radius: deprecated.$br-8; + grid-template-columns: $sz-16 1fr; + gap: var(--sp-s); + min-block-size: $sz-32; + block-size: fit-content; + inline-size: 100%; + padding: var(--sp-s); + border: $b-1 solid var(--context-notification-border-color); + border-radius: $br-8; background-color: var(--context-notification-bg-color); } .warning { - --context-notification-bg-color: var(--alert-background-color-warning); - --context-notification-fg-color: var(--alert-text-foreground-color-warning); - --context-notification-icon-color: var(--alert-icon-foreground-color-warning); - --context-notification-border-color: var(--alert-border-color-warning); + --context-notification-bg-color: var(--color-background-warning); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-warning); + --context-notification-border-color: var(--color-accent-warning); } .success { - --context-notification-bg-color: var(--alert-background-color-success); - --context-notification-fg-color: var(--alert-text-foreground-color-success); - --context-notification-icon-color: var(--alert-icon-foreground-color-success); - --context-notification-border-color: var(--alert-border-color-success); + --context-notification-bg-color: var(--color-background-success); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-success); + --context-notification-border-color: var(--color-accent-success); } .info { - --context-notification-bg-color: var(--alert-background-color-info); - --context-notification-fg-color: var(--alert-text-foreground-color-info); - --context-notification-icon-color: var(--alert-icon-foreground-color-info); - --context-notification-border-color: var(--alert-border-color-info); -} - -.default { - --context-notification-bg-color: var(--alert-background-color-default); - --context-notification-fg-color: var(--alert-text-foreground-color-default); - --context-notification-icon-color: var(--alert-icon-foreground-color-default); - --context-notification-border-color: var(--alert-border-color-default); + --context-notification-bg-color: var(--color-background-info); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-info); + --context-notification-border-color: var(--color-accent-info); } .error { - --context-notification-bg-color: var(--alert-background-color-error); - --context-notification-fg-color: var(--alert-text-foreground-color-error); - --context-notification-icon-color: var(--alert-icon-foreground-color-error); - --context-notification-border-color: var(--alert-border-color-error); + --context-notification-bg-color: var(--color-background-error); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-error); + --context-notification-border-color: var(--color-accent-error); } .icon { - @extend %button-icon; - + flex-shrink: 0; align-self: self-start; - stroke: var(--context-notification-icon-color); + color: var(--context-notification-icon-color); } .context-text { - @include deprecated.body-small-typography; + @include use-typography("body-small"); align-self: center; color: var(--context-notification-fg-color); @@ -81,12 +75,12 @@ .link, .contain-html .context-text a { - @include deprecated.body-small-typography; + @include use-typography("body-small"); align-self: center; display: inline; - text-align: left; - height: deprecated.$s-16; + text-align: start; + block-size: $sz-16; margin: 0; - color: var(--modal-link-foreground-color); + color: var(--color-accent-primary); } From 4d416bd3f21bf6d7ce2330facbfdba54c5149f44 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 10:39:35 +0100 Subject: [PATCH 20/65] :recycle: Use DS icons and buttons in the settings sidebar and email modal (#12541) * :recycle: Use DS icons and buttons in the settings sidebar and email modal - The settings sidebar back arrow and "Contact us" icon use the DS icons. The arrow keeps its place; the small DS icon draws 12px inside a 16px box, so the feedback icon sits 2px to the right and its margin shrinks to keep the label in place. - The change email modal close button becomes a ghost DS icon button named "Close"; the class that places it goes on the tooltip wrapper. The form submit buttons of the settings pages stay: they are the shared forms submit button. A visual test covers the settings sidebar and the change email modal. AI-assisted-by: claude-opus-5-5 * :recycle: Move settings sidebar and email modal styles to the DS The settings sidebar no longer imports refactor/: its legacy mixins, sizes and color aliases resolve to DS values and tokens, with logical properties. Its z-index (1) has no DS token and keeps a TODO. The change email modal does the same and keeps refactor/ only for the modal placeholders (%modal-*), which move with the modals refactor. Its legacy uppercase title becomes the DS headline-small typography, which differs only in its size (12px instead of 11px): the modal grows 2px. Two unused selectors are commented out with a TODO. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the change email modal The SCSS refactor of the change email modal left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .select-title, .cancel-button. No visual change. AI-assisted-by: claude-opus-5-5 * :recycle: Tidy the settings sidebar and change email styles The sidebar uses --z-index-panels instead of auto, the change email submit button gets its own class instead of a bare button selector, and the sidebar icons keep the DS stroke width (only the feedback icon line gets thinner). AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 26 ++++++++ .../app/main/ui/settings/change_email.cljs | 13 ++-- .../app/main/ui/settings/change_email.scss | 44 ++++++------- .../src/app/main/ui/settings/sidebar.cljs | 14 ++--- .../src/app/main/ui/settings/sidebar.scss | 61 ++++++++++--------- 5 files changed, 95 insertions(+), 63 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 007092884f..6e5a868789 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -602,6 +602,32 @@ test.describe("Team pages controls", () => { }); }); +test.describe("Settings controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Sidebar and change email modal", async ({ page }) => { + await DashboardPage.mockConfigFlags(page, ["enable-user-feedback"]); + await page.goto("/?screen=settings-profile"); + const sidebar = page.locator( + "[class*='settings_sidebar__dashboard-sidebar']", + ); + await expect(page.getByText("Contact us")).toBeVisible(); + await page.mouse.move(640, 700); + await expect(sidebar).toHaveScreenshot(shot); + + await page.getByText("Dashboard", { exact: true }).hover(); + await expect(sidebar).toHaveScreenshot(shot); + + await page.getByText("Change email", { exact: true }).click(); + const modal = page.locator("[class*='modal-container']"); + await expect(modal).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + await modal.locator("[class*='modal-header'] button").hover(); + await expect(modal).toHaveScreenshot(shot); + }); +}); + test.describe("Onboarding questions controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; diff --git a/frontend/src/app/main/ui/settings/change_email.cljs b/frontend/src/app/main/ui/settings/change_email.cljs index 98e15e2c58..613ae3a9d6 100644 --- a/frontend/src/app/main/ui/settings/change_email.cljs +++ b/frontend/src/app/main/ui/settings/change_email.cljs @@ -14,7 +14,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.forms :as fm] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] @@ -88,8 +89,11 @@ [:h2 {:class (stl/css :modal-title) :data-testid "change-email-title"} (tr "modals.change-email.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} [:& context-notification @@ -114,7 +118,8 @@ [:div {:class (stl/css :action-buttons) :data-testid "change-email-submit"} [:> fm/submit-button* - {:label (tr "modals.change-email.submit")}]]]]]])) + {:label (tr "modals.change-email.submit") + :class (stl/css :submit-button)}]]]]]])) diff --git a/frontend/src/app/main/ui/settings/change_email.scss b/frontend/src/app/main/ui/settings/change_email.scss index 4795cf6ac6..afff303004 100644 --- a/frontend/src/app/main/ui/settings/change_email.scss +++ b/frontend/src/app/main/ui/settings/change_email.scss @@ -4,7 +4,11 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -13,49 +17,45 @@ .modal-container { @extend %modal-container-base; - min-width: deprecated.$s-408; + min-inline-size: px2rem(408); } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include deprecated.flex-column; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - gap: deprecated.$s-24; - margin-bottom: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .fields-row { - @include deprecated.flex-column; -} - -.select-title { - @include deprecated.body-small-typography; - - color: var(--modal-title-foreground-color); + display: flex; + flex-direction: column; + gap: var(--sp-xs); } .action-buttons { @extend %modal-action-btns; - - button { - @extend %modal-accept-btn; - } } -.cancel-button { - @extend %modal-cancel-btn; +.submit-button { + @extend %modal-accept-btn; } diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 652842ff26..3bd4545bf6 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -15,16 +15,11 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.dashboard.sidebar :refer [profile-section*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [rumext.v2 :as mf])) -(def ^:private arrow-icon - (deprecated-icon/icon-xref :arrow (stl/css :arrow-icon))) - -(def ^:private feedback-icon - (deprecated-icon/icon-xref :feedback (stl/css :feedback-icon))) ;; FIXME: move to common (def ^:private go-settings-profile @@ -82,7 +77,8 @@ [:div {:class (stl/css :sidebar-content-section)} [:button {:class (stl/css :back-to-dashboard) :on-click go-dashboard} - arrow-icon + [:> icon* {:icon-id i/arrow-left + :class (stl/css :arrow-icon)}] [:span {:class (stl/css :back-text)} (tr "labels.dashboard")]]] [:hr {:class (stl/css :sidebar-separator)}] @@ -143,7 +139,9 @@ [:li {:class (stl/css-case :current feedback? :settings-item true) :on-click go-settings-feedback} - feedback-icon + [:> icon* {:icon-id i/feedback + :size "s" + :class (stl/css :feedback-icon)}] [:span {:class (stl/css :element-title)} (tr "labels.contact-us")]])]]])) (mf/defc sidebar* diff --git a/frontend/src/app/main/ui/settings/sidebar.scss b/frontend/src/app/main/ui/settings/sidebar.scss index e63882a113..dc905a9911 100644 --- a/frontend/src/app/main/ui/settings/sidebar.scss +++ b/frontend/src/app/main/ui/settings/sidebar.scss @@ -4,8 +4,11 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; @use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; +@use "ds/z-index.scss" as *; .dashboard-sidebar { @include custom-scrollbar; @@ -14,24 +17,24 @@ grid-row: 1 / span 2; display: grid; grid-template-rows: 1fr auto; - height: 100%; - padding-block-start: deprecated.$s-16; - border-right: deprecated.$s-1 solid var(--panel-border-color); - z-index: deprecated.$z-index-1; - background-color: var(--panel-background-color); + block-size: 100%; + padding-block-start: var(--sp-l); + border-inline-end: $b-1 solid var(--color-background-quaternary); + z-index: var(--z-index-panels); + background-color: var(--color-background-primary); } .sidebar-content { display: grid; grid-template-rows: auto auto 1fr; - height: 100%; + block-size: 100%; padding: 0; overflow-y: auto; } .sidebar-separator { border-color: transparent; - margin: deprecated.$s-12 deprecated.$s-16; + margin: var(--sp-m) var(--sp-l); } .sidebar-nav-settings { @@ -43,46 +46,48 @@ } .settings-item { - --settings-foreground-color: var(--menu-foreground-color-rest); + --settings-foreground-color: var(--color-foreground-secondary); --settings-background-color: transparent; display: flex; align-items: center; - padding: deprecated.$s-8 deprecated.$s-8 deprecated.$s-8 deprecated.$s-24; + padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-xxl); color: var(--settings-foreground-color); background-color: var(--settings-background-color); cursor: pointer; &:hover { - --settings-foreground-color: var(--sidebar-element-foreground-color-hover); - --settings-background-color: var(--sidebar-element-background-color-hover); + --settings-foreground-color: var(--color-accent-primary); + --settings-background-color: var(--color-background-secondary); } &.current { - --settings-foreground-color: var(--sidebar-element-foreground-color-selected); - --settings-background-color: var(--sidebar-element-background-color-selected); + --settings-foreground-color: var(--color-accent-primary); + --settings-background-color: var(--color-background-quaternary); } } +// The DS small icon draws 12px inside a 16px box: 4px less margin keeps +// the label where it was (the drawing sits 2px to the right). .feedback-icon { - @extend %button-icon-small; - - stroke: var(--settings-foreground-color); - margin-right: deprecated.$s-8; + flex-shrink: 0; + color: var(--settings-foreground-color); + margin-inline-end: var(--sp-xs); } .element-title { - @include deprecated.text-ellipsis; - @include deprecated.body-medium-typography; + @include text-ellipsis; + @include use-typography("body-medium"); } .back-to-dashboard { - @include deprecated.button-style; - + border: none; + background: none; + cursor: pointer; display: flex; align-items: center; - padding: deprecated.$s-12 deprecated.$s-16; - font-size: deprecated.$fs-14; + padding: var(--sp-m) var(--sp-l); + font-size: px2rem(14); } .back-text { @@ -90,9 +95,7 @@ } .arrow-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); - transform: rotate(180deg); - margin-right: deprecated.$s-12; + flex-shrink: 0; + color: var(--color-foreground-secondary); + margin-inline-end: var(--sp-m); } From ff9506c551475e03be1ad9ab96f718c006a3a53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 28 Oct 2026 11:02:42 +0100 Subject: [PATCH 21/65] :sparkles: Add member profile support for organization attributes (#12652) --- .../src/app/loggers/audit/consumer_task.clj | 2 + backend/src/app/rpc/management/nitrate.clj | 51 ++++++++++++++++++- .../rpc_management_nitrate_test.clj | 47 +++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/backend/src/app/loggers/audit/consumer_task.clj b/backend/src/app/loggers/audit/consumer_task.clj index 7270cfae94..3180f936f0 100644 --- a/backend/src/app/loggers/audit/consumer_task.clj +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -213,6 +213,7 @@ "accept-team-invitation-from" "add-member-to-organization" "add-team-to-organization" + "assign-organization-attribute" "auditlog-exported" "auditlog-guide-downloaded" "cancel-organization-invitation" @@ -253,6 +254,7 @@ "sso-config-tested" "success-activate-organization-sso" "success-apply-organization-sso-changes" + "unassign-organization-attribute" "update-organization-invitation" "update-organization-permissions" "update-team-invitation" diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index c8c4f83e8c..20cd899ae7 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -17,7 +17,7 @@ [app.common.time :as ct] [app.common.types.organization :as cto] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] - [app.common.types.team :refer [schema:team]] + [app.common.types.team :as ctt :refer [schema:team]] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] @@ -562,6 +562,55 @@ RETURNING id, deleted_at;") (db/exec! conn [sql:get-organization-member-team-counts ids-array]))))))) +;; ---- API: get-organization-member-teams + +(def ^:private sql:get-organization-member-teams + "SELECT t.id, t.name, t.photo_id, + CASE WHEN tpr.is_owner THEN 'owner' + WHEN tpr.is_admin THEN 'admin' + WHEN tpr.can_edit THEN 'editor' + ELSE 'viewer' + END AS role + FROM team_profile_rel AS tpr + JOIN team AS t ON t.id = tpr.team_id + WHERE tpr.profile_id = ? + AND t.id = ANY(?) + AND t.deleted_at IS NULL + AND t.is_default IS FALSE;") + +(def ^:private schema:get-organization-member-teams-params + [:map + [:profile-id ::sm/uuid] + [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]]) + +(def ^:private schema:get-organization-member-teams-result + [:vector [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:photo-url {:optional true} ::sm/uri] + [:role ctt/schema:role]]]) + +(sv/defmethod ::get-organization-member-teams + "Get the non-default teams a profile belongs to within a set of teams, + with the profile's role in each one." + {::doc/added "2.18" + ::sm/params schema:get-organization-member-teams-params + ::sm/result schema:get-organization-member-teams-result + ::nitrate/sso false} + [cfg {:keys [profile-id team-ids]}] + (let [team-ids (if (uuid? team-ids) [team-ids] team-ids)] + (if (empty? team-ids) + [] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (->> (db/exec! conn [sql:get-organization-member-teams profile-id ids-array]) + (mapv (fn [{:keys [photo-id role] :as row}] + (cond-> (-> row + (dissoc :photo-id) + (assoc :role (keyword role))) + photo-id (assoc :photo-url (files/resolve-public-uri photo-id)))))))))))) + + ;; API: invite-to-organization (defn- get-invitation-organization diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 500b241279..2d411bf816 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -251,6 +251,53 @@ (t/is (false? (get-in teams-by-id [(:id owned-team) :is-default]))) (t/is (false? (get-in teams-by-id [(:id member-team) :is-default]))))))) +(t/deftest get-organization-member-teams-returns-roles-within-given-teams + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + owned-team (th/create-team* 1 {:profile-id (:id profile)}) + admin-team (th/create-team* 2 {:profile-id (:id other)}) + editor-team (th/create-team* 3 {:profile-id (:id other)}) + viewer-team (th/create-team* 4 {:profile-id (:id other)}) + foreign-team (th/create-team* 5 {:profile-id (:id other)}) + not-member (th/create-team* 6 {:profile-id (:id other)}) + deleted-team (th/create-team* 7 {:profile-id (:id profile)}) + _ (th/db-update! :team + {:deleted-at (ct/now)} + {:id (:id deleted-team)}) + _ (doseq [[team role] [[admin-team :admin] + [editor-team :editor] + [viewer-team :viewer] + [foreign-team :editor]]] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id profile) + :role role})) + team-ids (mapv :id [owned-team admin-team editor-team viewer-team + not-member deleted-team + {:id (:default-team-id profile)}]) + out (th/management-command! {::th/type :get-organization-member-teams + ::rpc/profile-id (:id other) + :profile-id (:id profile) + :team-ids team-ids})] + (t/is (th/success? out)) + (t/is (= {(:id owned-team) :owner + (:id admin-team) :admin + (:id editor-team) :editor + (:id viewer-team) :viewer} + (->> out :result (map (juxt :id :role)) (into {})))) + (t/is (= (:name owned-team) + (->> out :result (d/seek #(= (:id owned-team) (:id %))) :name)))))) + +(t/deftest get-organization-member-teams-empty-team-ids + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/management-command! {::th/type :get-organization-member-teams + ::rpc/profile-id (:id profile) + :profile-id (:id profile) + :team-ids []})] + (t/is (th/success? out)) + (t/is (= [] (:result out)))))) + (t/deftest update-profile-theme-updates-only-theme (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] (let [profile (th/create-profile* 1 {:is-active true From 0532f15cee94653b7acf863772b7e39e0f515186 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 28 Oct 2026 11:44:38 +0100 Subject: [PATCH 22/65] :bug: Fix tab on variant name doesn't validate formula (#12655) * :bug: Fix tab on variant name doesn't validate formula * :sparkles: Move helpers to common * :sparkles: Mr proposed changes --- common/src/app/common/path_names.cljc | 11 + common/src/app/common/types/component.cljc | 18 ++ common/src/app/common/types/shape_tree.cljc | 2 +- common/test/common_tests/path_names_test.cljc | 9 + .../common_tests/types/components_test.cljc | 21 ++ frontend/src/app/main/data/changes.cljs | 4 +- frontend/src/app/main/data/workspace.cljs | 78 +++--- .../app/main/data/workspace/libraries.cljs | 8 +- .../main/ui/workspace/sidebar/layer_name.cljs | 2 +- .../main/ui/workspace/viewport/widgets.cljs | 2 +- frontend/src/app/plugins/shape.cljs | 12 +- .../data/workspace_variant_rename_test.cljs | 232 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 13 files changed, 351 insertions(+), 50 deletions(-) create mode 100644 frontend/test/frontend_tests/data/workspace_variant_rename_test.cljs diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc index cc1526eae0..3f5fd28603 100644 --- a/common/src/app/common/path_names.cljc +++ b/common/src/app/common/path_names.cljc @@ -55,6 +55,17 @@ Some naming conventions: (->> (split-path path) (join-path))) +(defn normalize-path-name + "Trim and clean a path-style name. Returns nil when the name is not usable." + [name] + (let [name (some-> name str/trim) + clean-name (when (string? name) (clean-path name))] + (when (and (string? name) + (not (str/ends-with? name "/")) + (string? clean-name) + (not (str/blank? clean-name))) + clean-name))) + (defn merge-path-item "Put the item at the end of the path." [path name] diff --git a/common/src/app/common/types/component.cljc b/common/src/app/common/types/component.cljc index deb5379e72..c45b59ada0 100644 --- a/common/src/app/common/types/component.cljc +++ b/common/src/app/common/types/component.cljc @@ -251,6 +251,24 @@ [shape] (boolean (:is-variant-container shape))) +(defn is-variant-main? + "True when this shape is a variant component main and not the variant + container." + [shape] + (and (is-variant? shape) + (not (is-variant-container? shape)))) + +(defn variant-formula-rename? + "True when renaming this shape with `name` should update the variant + formula instead of the layer or component name. + + `name` must already be trimmed by the caller." + [shape name] + (and (is-variant-main? shape) + (some? (:variant-name shape)) + (string? name) + (not (str/blank? name)))) + (defn set-touched-group [touched group] (when group diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc index 90109d5b46..0080aae00d 100644 --- a/common/src/app/common/types/shape_tree.cljc +++ b/common/src/app/common/types/shape_tree.cljc @@ -376,7 +376,7 @@ (defn update-object-indices [file page-id] - (update-in file [:pages-index page-id :objects] update-page-index)) + (d/update-in-when file [:pages-index page-id :objects] update-page-index)) (defn rotated-frame? [frame] diff --git a/common/test/common_tests/path_names_test.cljc b/common/test/common_tests/path_names_test.cljc index 9e0d3fb873..04cf0650b8 100644 --- a/common/test/common_tests/path_names_test.cljc +++ b/common/test/common_tests/path_names_test.cljc @@ -31,3 +31,12 @@ (t/is (= (first path) "group")) (t/is (= (second path) "subgroup")) (t/is (= (nth path 2) "name")))) + +(t/deftest normalize-path-name + (t/is (= "Button" (cpn/normalize-path-name " Button "))) + (t/is (= "Group / Button" (cpn/normalize-path-name "Group / Button"))) + (t/is (nil? (cpn/normalize-path-name nil))) + (t/is (nil? (cpn/normalize-path-name ""))) + (t/is (nil? (cpn/normalize-path-name " "))) + (t/is (nil? (cpn/normalize-path-name "Button/"))) + (t/is (nil? (cpn/normalize-path-name "Group / ")))) diff --git a/common/test/common_tests/types/components_test.cljc b/common/test/common_tests/types/components_test.cljc index 4d42df92f8..6098db1e80 100644 --- a/common/test/common_tests/types/components_test.cljc +++ b/common/test/common_tests/types/components_test.cljc @@ -397,3 +397,24 @@ (t/is (= (:id near-copy2-nested-head) (thi/id :nested2-head))) (t/is (= (:id near-copy2-nested4-head) (thi/id :nested4-head))) (t/is (= (:id near-copy2-nested4-child) (thi/id :nested4-child)))))) + +(t/deftest variant-main-and-formula-rename + (let [container {:id (thi/new-id! :container) + :is-variant-container true} + main {:id (thi/new-id! :main) + :variant-id (thi/id :container) + :variant-name "Value1"} + main-bare {:id (thi/new-id! :main-bare) + :variant-id (thi/id :container)} + plain {:id (thi/new-id! :plain)}] + (t/is (false? (ctk/is-variant-main? container))) + (t/is (true? (ctk/is-variant-main? main))) + (t/is (true? (ctk/is-variant-main? main-bare))) + (t/is (false? (ctk/is-variant-main? plain))) + + (t/is (true? (ctk/variant-formula-rename? main "Property 1=another 21"))) + (t/is (false? (ctk/variant-formula-rename? main ""))) + (t/is (false? (ctk/variant-formula-rename? main nil))) + (t/is (false? (ctk/variant-formula-rename? main-bare "Property 1=another 21"))) + (t/is (false? (ctk/variant-formula-rename? container "Property 1=another 21"))) + (t/is (false? (ctk/variant-formula-rename? plain "Button"))))) diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index 865779e5dc..0baea3a2ab 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -75,7 +75,9 @@ (not-empty))) (def ^:private xf:map-page-id - (map :page-id)) + ;; Component-only changes have no :page-id; keep drops those nils so + ;; update-object-indices does not create a nil key in :pages-index. + (keep :page-id)) (def ^:private wasm-structural-change-types #{:add-obj :mov-objects}) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 8390a3ca37..74fe1252be 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -746,6 +746,37 @@ (update [_ state] (assoc-in state [:workspace-local :shape-for-rename] id)))) +(defn- rename-variant-main + "Apply a layers-panel formula to one variant main. + + A valid formula updates the property names and values. Any other + text keeps the current malformed path: values are cleared and the + text is stored as the variant error." + [file-id shape name] + (ptk/reify ::rename-variant-main + ptk/WatchEvent + (watch [_ state _] + (let [file-data (dsh/lookup-file-data state file-id) + component-id (:component-id shape) + component (ctkl/get-component file-data component-id) + variant-id (:variant-id shape) + variant-properties (:variant-properties component)] + (if (ctv/valid-properties-formula? name) + (rx/of (dwva/update-properties-names-and-values + component-id + variant-id + variant-properties + (ctv/properties-formula->map name)) + (dwva/remove-empty-properties variant-id) + (dwva/update-error component-id)) + (rx/of (dwva/update-properties-names-and-values + component-id + variant-id + variant-properties + {}) + (dwva/remove-empty-properties variant-id) + (dwva/update-error component-id name))))))) + (defn end-rename-shape "End the ongoing shape rename process" ([] (end-rename-shape nil nil)) @@ -760,14 +791,17 @@ (when-let [shape-id (d/nilv shape-id (dm/get-in state [:workspace-local :shape-for-rename]))] (let [shape (dsh/lookup-shape state shape-id) name (str/trim name) - clean-name (cpn/clean-path name) - valid? (and (not (str/ends-with? name "/")) - (string? clean-name) - (not (str/blank? clean-name))) + clean-name (cpn/normalize-path-name name) component-id (:component-id shape) - undo-id (js/Symbol)] + undo-id (js/Symbol)] - (when valid? + (cond + ;; Variant formula edits go through rename-shape-or-variant. + ;; A variant main must not fall through to a plain rename. + (ctc/is-variant-main? shape) + (rx/empty) + + (some? clean-name) (if (ctc/is-variant-container? shape) ;; Rename the full variant when it is a variant container (rx/of (dwva/rename-variant shape-id clean-name)) @@ -791,32 +825,12 @@ (watch [_ state _] (let [file-id (d/nilv file-id (:current-file-id state)) page-id (d/nilv page-id (:current-page-id state)) - - file-data (dsh/lookup-file-data state file-id) - shape - (-> (dsh/lookup-page-objects state file-id page-id) - (get id)) - - is-variant? (ctc/is-variant? shape) - variant-id (when is-variant? (:variant-id shape)) - variant-name (when is-variant? (:variant-name shape)) - component-id (:component-id shape) - component (ctkl/get-component file-data (:component-id shape)) - variant-properties (:variant-properties component)] - (cond - (and variant-name (ctv/valid-properties-formula? name)) - (rx/of (dwva/update-properties-names-and-values - component-id variant-id variant-properties (ctv/properties-formula->map name)) - (dwva/remove-empty-properties variant-id) - (dwva/update-error component-id)) - - variant-name - (rx/of (dwva/update-properties-names-and-values - component-id variant-id variant-properties {}) - (dwva/remove-empty-properties variant-id) - (dwva/update-error component-id name)) - - :else + shape (-> (dsh/lookup-page-objects state file-id page-id) + (get id)) + name (str/trim name)] + (if (ctc/variant-formula-rename? shape name) + ;; update-properties-names-and-values clears :shape-for-rename. + (rx/of (rename-variant-main file-id shape name)) (rx/of (end-rename-shape id name)))))))) ;; --- Update Selected Shapes attrs diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 3044a05f6c..bc52e63736 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -498,15 +498,11 @@ (ptk/reify ::rename-component-and-main-instance ptk/WatchEvent (watch [_ state _] - (let [name (str/trim name) - clean-name (cpn/clean-path name) - valid? (and (not (str/ends-with? name "/")) - (string? clean-name) - (not (str/blank? clean-name))) + (let [clean-name (cpn/normalize-path-name name) data (dsh/lookup-file-data state) component (dm/get-in data [:components component-id])] - (when (and valid? component) + (when (and (some? clean-name) component) (let [shape-id (:main-instance-id component) page-id (:main-instance-page component)] diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs index db497cf242..95afbd2922 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs @@ -93,7 +93,7 @@ name (str/trim (dom/get-value name-input))] (on-stop-edit) (reset! edition* false) - (st/emit! (dw/end-rename-shape shape-id name)) + (st/emit! (dw/rename-shape-or-variant shape-id name)) (when (fn? on-tab-press) (on-tab-press event))))))) diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index aa83faf963..b9806fddda 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -181,7 +181,7 @@ (let [name-input (mf/ref-val ref) name (str/trim (dom/get-value name-input))] (reset! edition* false) - (st/emit! (dw/end-rename-shape frame-id name)) + (st/emit! (dw/rename-shape-or-variant frame-id name)) (on-frame-leave frame-id)))) cancel-edit diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 817492536c..aea764dc28 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -70,8 +70,7 @@ [app.plugins.utils :as u] [app.util.http :as http] [app.util.object :as obj] - [beicon.v2.core :as rx] - [cuerdas.core :as str])) + [beicon.v2.core :as rx])) (declare shape-proxy) (declare shape-proxy?) @@ -385,11 +384,8 @@ :get #(-> % u/proxy->shape :name) :set (fn [self value] - (let [id (obj/get self "$id") - value (when (string? value) (-> value str/trim cpn/clean-path)) - valid? (and (some? value) - (not (str/ends-with? value "/")) - (not (str/blank? value)))] + (let [id (obj/get self "$id") + value (cpn/normalize-path-name value)] (cond (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") @@ -397,7 +393,7 @@ (not (u/page-active? page-id)) (u/not-valid plugin-id :name "Cannot modify a page that is not currently active") - (not valid?) + (nil? value) (u/not-valid plugin-id :name value) :else diff --git a/frontend/test/frontend_tests/data/workspace_variant_rename_test.cljs b/frontend/test/frontend_tests/data/workspace_variant_rename_test.cljs new file mode 100644 index 0000000000..5e177b1b21 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_variant_rename_test.cljs @@ -0,0 +1,232 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.data.workspace-variant-rename-test + (:require + [app.common.test-helpers.components :as cthc] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.test-helpers.variants :as thv] + [app.common.types.file :as ctf] + [app.common.types.pages-list :as ctpl] + [app.main.data.workspace :as dw] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths])) + +(t/use-fixtures :each + {:before cthi/reset-idmap!}) + +(defn- variant-file + [] + (-> (cthf/sample-file :file1) + (thv/add-variant :variant :comp1 :root1 :comp2 :root2 + :variant1-params {:name "Button"} + :variant2-params {:name "Button"}) + (cths/update-shape :variant :name "Button"))) + +(defn- without-variant-name + [file label] + (let [page-id (cthf/current-page-id file) + shape-id (cthi/id label)] + (ctf/update-file-data + file + (fn [data] + (ctpl/update-page data + page-id + #(update-in % [:objects shape-id] dissoc :variant-name)))))) + +(defn- prop-value + [component] + (-> component :variant-properties first :value)) + +(defn- run + "Run workspace events, then validate the file before assertions. + + Pass `:validate? false` only when the fixture is intentionally incomplete + (e.g. a variant main without `:variant-name`)." + [file events done assert-fn & {:keys [validate?] :or {validate? true}}] + (ths/run-store + (ths/setup-store file) + done + events + (fn [state] + (when validate? + (cthf/validate-file! (ths/get-file-from-state state))) + (assert-fn state)))) + +(t/deftest end-rename-shape-does-not-apply-variant-formula + (t/async + done + (let [file (variant-file) + component (cthc/get-component file :comp1) + original-name (:name component) + original-path (:path component) + root2-id (cthi/id :root2)] + (run file + [(dw/start-rename-shape (cthi/id :root1)) + (dw/end-rename-shape (cthi/id :root1) "Property 1=another 21") + (dw/start-rename-shape root2-id)] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1) + component (cthc/get-component file :comp1)] + ;; Formula updates belong to rename-shape-or-variant. + (t/is (= "Value1" (prop-value component))) + (t/is (= "Value1" (:variant-name shape))) + (t/is (= "Button" (:name shape))) + (t/is (= "Button" (:name (cths/get-shape file :variant)))) + (t/is (= original-name (:name component))) + (t/is (= original-path (:path component))) + (t/is (= "Value2" (prop-value (cthc/get-component file :comp2)))) + (t/is (= root2-id (:shape-for-rename (:workspace-local state)))))))))) + +(t/deftest rename-shape-or-variant-updates-variant-value-and-keeps-layer-name + (t/async + done + (let [file (variant-file) + component (cthc/get-component file :comp1) + original-name (:name component) + original-path (:path component)] + (run file + [(dw/start-rename-shape (cthi/id :root1)) + (dw/rename-shape-or-variant (cthi/id :root1) "Property 1=another 21")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1) + component (cthc/get-component file :comp1)] + (t/is (= "another 21" (prop-value component))) + (t/is (= "another 21" (:variant-name shape))) + (t/is (= "Button" (:name shape))) + (t/is (= original-name (:name component))) + (t/is (= original-path (:path component))) + (t/is (nil? (:shape-for-rename (:workspace-local state)))))))))) + +(t/deftest rename-shape-or-variant-clears-shape-for-rename-on-malformed-formula + (t/async + done + (run (variant-file) + [(dw/start-rename-shape (cthi/id :root1)) + (dw/rename-shape-or-variant (cthi/id :root1) "not-a-formula")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1)] + (t/is (= "Button" (:name shape))) + (t/is (= "not-a-formula" (:variant-error shape))) + (t/is (= "" (prop-value (cthc/get-component file :comp1)))) + (t/is (nil? (:shape-for-rename (:workspace-local state))))))))) + +(t/deftest rename-shape-or-variant-blank-name-does-not-clear-variant-values + (t/async + done + (run (variant-file) + [(dw/start-rename-shape (cthi/id :root1)) + (dw/rename-shape-or-variant (cthi/id :root1) "")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1)] + (t/is (= "Button" (:name shape))) + (t/is (= "Value1" (:variant-name shape))) + (t/is (= "Value1" (prop-value (cthc/get-component file :comp1)))) + (t/is (nil? (:variant-error shape))) + (t/is (nil? (:shape-for-rename (:workspace-local state))))))))) + +(t/deftest end-rename-shape-nil-leaves-variant-main-unchanged + (t/async + done + (run (variant-file) + [(dw/start-rename-shape (cthi/id :root1)) + (dw/end-rename-shape (cthi/id :root1) nil)] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1)] + (t/is (= "Button" (:name shape))) + (t/is (= "Value1" (:variant-name shape))) + (t/is (= "Value1" (prop-value (cthc/get-component file :comp1)))) + (t/is (nil? (:shape-for-rename (:workspace-local state))))))))) + +(t/deftest end-rename-shape-ignores-variant-main-without-variant-name + (t/async + done + (let [file (-> (variant-file) + (without-variant-name :root1)) + component (cthc/get-component file :comp1) + original-name (:name component) + original-path (:path component) + root2-id (cthi/id :root2)] + (run file + [(dw/start-rename-shape (cthi/id :root1)) + (dw/end-rename-shape (cthi/id :root1) "Property 1=another 21") + (dw/start-rename-shape root2-id)] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1) + component (cthc/get-component file :comp1)] + (t/is (= "Button" (:name shape))) + (t/is (nil? (:variant-name shape))) + (t/is (= "Value1" (prop-value component))) + (t/is (= original-name (:name component))) + (t/is (= original-path (:path component))) + (t/is (= root2-id (:shape-for-rename (:workspace-local state)))))) + ;; Fixture starts without :variant-name, so the file is incomplete. + :validate? false)))) + +(t/deftest end-rename-shape-keeps-formula-text-off-the-layer-name + (t/async + done + (run (variant-file) + [(dw/start-rename-shape (cthi/id :root1)) + (dw/end-rename-shape (cthi/id :root1) "not-a-formula")] + done + (fn [state] + (let [file (ths/get-file-from-state state) + shape (cths/get-shape file :root1)] + (t/is (= "Button" (:name shape))) + (t/is (= "Value1" (:variant-name shape))) + (t/is (= "Value1" (prop-value (cthc/get-component file :comp1)))) + (t/is (nil? (:variant-error shape))) + (t/is (nil? (:shape-for-rename (:workspace-local state))))))))) + +(t/deftest end-rename-shape-renames-variant-container + (t/async + done + (run (variant-file) + [(dw/end-rename-shape (cthi/id :variant) "Renamed set")] + done + (fn [state] + (let [file (ths/get-file-from-state state)] + (t/is (= "Renamed set" (:name (cths/get-shape file :variant)))) + (t/is (= "Renamed set" (:name (cths/get-shape file :root1)))) + (t/is (= "Renamed set" (:name (cths/get-shape file :root2)))) + (t/is (= "Renamed set" (:name (cthc/get-component file :comp1)))) + (t/is (= "Renamed set" (:name (cthc/get-component file :comp2))))))))) + +(t/deftest end-rename-shape-renames-plain-shape-and-component-main + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (cths/add-sample-shape :frame :type :frame :name "Frame") + (cthc/make-component :comp :frame) + (cths/add-sample-shape :rect :name "Rect")) + rect-id (cthi/id :rect)] + (run file + [(dw/start-rename-shape (cthi/id :frame)) + (dw/end-rename-shape (cthi/id :frame) "Renamed") + (dw/start-rename-shape rect-id) + (dw/end-rename-shape rect-id "Box")] + done + (fn [state] + (let [file (ths/get-file-from-state state)] + (t/is (= "Renamed" (:name (cths/get-shape file :frame)))) + (t/is (= "Renamed" (:name (cthc/get-component file :comp)))) + (t/is (= "Box" (:name (cths/get-shape file :rect)))) + (t/is (nil? (:shape-for-rename (:workspace-local state)))))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index aefb617e23..c7e2d547fa 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -37,6 +37,7 @@ [frontend-tests.data.workspace-shortcuts-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] + [frontend-tests.data.workspace-variant-rename-test] [frontend-tests.data.workspace-versions-test] [frontend-tests.debug-logging-test] [frontend-tests.errors-governor-test] @@ -186,6 +187,7 @@ 'frontend-tests.data.workspace-shortcuts-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test + 'frontend-tests.data.workspace-variant-rename-test 'frontend-tests.data.workspace-versions-test 'frontend-tests.debug-logging-test 'frontend-tests.errors-governor-test From 1109e388523031ed77cf29b772df5fcb5bc806ba Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 12:10:47 +0100 Subject: [PATCH 23/65] :recycle: Use DS icons in the dashboard lists and placeholders (#12536) * :recycle: Use DS icons in the dashboard lists and placeholders The trash project options button becomes a ghost DS icon button, as in the projects list, and says whether its menu is open. The search placeholders, the empty project "create new file" button, the comments placeholder and the legacy import library tag use the DS icons. The legacy search icon had no fill rule, so its lens was filled black; the DS one is not. Two fixtures make the trash testable: a deleted project, and deleted files due in 2100 (the existing ones expired in 2024, so the trash dropped them). A visual test covers the trash row, the empty project button, the project rename field and the comments placeholder. AI-assisted-by: claude-opus-5-5 * :bug: Remove the hidden close control from the dashboard rename field The rename field of dashboard projects and files had a close control next to the input that never showed: its legacy icon is drawn with a stroke, but the styles only set a fill. It could not be reached with the keyboard or a screen reader, and a click on it did what leaving the field does: the blur saves the name first, and its handler saved it too. It only left a pointer cursor over an empty spot. Enter, Escape and leaving the field keep working as before. No visual change. AI-assisted-by: claude-opus-5-5 * :recycle: Move dashboard trash, comments and rename styles to the DS The trash, comments panel and rename field stylesheets resolve their legacy color aliases, sizes, font sizes, mixins and the menu shadow to DS tokens and values, and use logical properties. The comments panel and the rename field no longer import refactor/; the trash keeps only the shared dashboard header styles (common-dashboard), used by ten dashboard modules. Four selectors that the modules do not use are commented out with a TODO. The comments dropdown z-index (4) has no DS token and keeps a TODO. No visual change. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the dashboard trash and comments The SCSS refactor of the dashboard trash and comments left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .thread-group, .comments-icon-small, .add-file-btn, .add-icon. No visual change. AI-assisted-by: claude-opus-5-5 * :bug: Use px2rem for the unread dot size in dashboard comments The unread dot used $sz-8, which the DS sizes do not define, so the integration bundle build failed. It now uses px2rem(8), the same 8px. AI-assisted-by: claude-opus-5-5 * :recycle: Use DS typography and z-index in the dashboard comments Review fixes for the dashboard lists styles: - The comments dropdown title uses the headline-small typography. It was 11px regular, so it is now 12px medium (larger and bolder). - The comments dropdown uses --z-index-dropdown instead of a raw 4. - The rename field selector is the class alone; the input type is not needed to win over the input defaults (0px change). The visual test now also captures the whole comments dropdown, so the title change shows. AI-assisted-by: claude-opus-5-5 * :fire: Remove dead rules from the dashboard comments styles The comments dropdown has no separators, so its hr rule matched nothing, and the comments icon set the default stroke width. No visual change. AI-assisted-by: claude-opus-5-5 --- .../data/dashboard/get-projects-deleted.json | 32 +++++++ .../get-team-deleted-files-pending.json | 47 ++++++++++ .../ui/visual-specs/visual-dashboard.spec.js | 72 +++++++++++++++ .../src/app/main/ui/dashboard/comments.cljs | 10 +-- .../src/app/main/ui/dashboard/comments.scss | 90 ++++++++----------- .../src/app/main/ui/dashboard/deleted.cljs | 21 +++-- .../src/app/main/ui/dashboard/deleted.scss | 51 +++++------ .../src/app/main/ui/dashboard/import.cljs | 3 +- .../app/main/ui/dashboard/inline_edition.cljs | 5 +- .../app/main/ui/dashboard/inline_edition.scss | 47 +++------- .../app/main/ui/dashboard/placeholder.cljs | 4 +- .../src/app/main/ui/dashboard/search.cljs | 11 ++- 12 files changed, 246 insertions(+), 147 deletions(-) create mode 100644 frontend/playwright/data/dashboard/get-projects-deleted.json create mode 100644 frontend/playwright/data/dashboard/get-team-deleted-files-pending.json diff --git a/frontend/playwright/data/dashboard/get-projects-deleted.json b/frontend/playwright/data/dashboard/get-projects-deleted.json new file mode 100644 index 0000000000..bb1fe484ee --- /dev/null +++ b/frontend/playwright/data/dashboard/get-projects-deleted.json @@ -0,0 +1,32 @@ +[ + { + "~:id": "~ue5a24d1b-ef1e-812f-8004-52bab84be6f7", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1715266551088", + "~:modified-at": "~m1715266551088", + "~:is-default": false, + "~:name": "New Project 1", + "~:is-pinned": false, + "~:count": 1 + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713533116382", + "~:modified-at": "~m1713873823633", + "~:is-default": true, + "~:name": "Drafts", + "~:count": 1 + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f7920c", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1715266551088", + "~:modified-at": "~m1715266551088", + "~:is-default": false, + "~:name": "Old Project", + "~:is-pinned": false, + "~:count": 1, + "~:deleted-at": "~m1732111500000" + } +] diff --git a/frontend/playwright/data/dashboard/get-team-deleted-files-pending.json b/frontend/playwright/data/dashboard/get-team-deleted-files-pending.json new file mode 100644 index 0000000000..d85f16b500 --- /dev/null +++ b/frontend/playwright/data/dashboard/get-team-deleted-files-pending.json @@ -0,0 +1,47 @@ +[ + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f41234", + "~:revn": 1, + "~:vern": 1, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1705307400000", + "~:modified-at": "~m1732111500000", + "~:deleted-at": "~m1732111500000", + "~:name": "Deleted Design File 1", + "~:is-shared": false, + "~:will-be-deleted-at": "~m4102444800000", + "~:thumbnail-id": null, + "~:row-num": 1, + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d" + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f41235", + "~:revn": 2, + "~:vern": 2, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1704875700000", + "~:modified-at": "~m1732025400000", + "~:deleted-at": "~m1732025400000", + "~:name": "Deleted Design File 2", + "~:is-shared": true, + "~:will-be-deleted-at": "~m4102444800000", + "~:thumbnail-id": null, + "~:row-num": 2, + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d" + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f41236", + "~:revn": 3, + "~:vern": 3, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920c", + "~:created-at": "~m1706792400000", + "~:modified-at": "~m1731939600000", + "~:deleted-at": "~m1731939600000", + "~:name": "Old Project Design", + "~:is-shared": false, + "~:will-be-deleted-at": "~m4102444800000", + "~:thumbnail-id": null, + "~:row-num": 3, + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d" + } +] diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 6e5a868789..8d5f9fca74 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -439,6 +439,78 @@ test.describe("Subscription controls", () => { }); }); +test.describe("Dashboard list controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Trash project options", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + // Deleted files due in 2100, so the trash keeps them. + await dashboardPage.mockRPC( + "get-team-deleted-files?team-id=*", + "dashboard/get-team-deleted-files-pending.json", + ); + // A deleted project, so its row shows the options button. + await dashboardPage.mockRPC( + "get-projects?team-id=*", + "dashboard/get-projects-deleted.json", + ); + await dashboardPage.goToDeleted(); + + const options = page.getByTestId("project-options").first(); + await expect(options).toBeVisible(); + const row = page.locator("[class*='dashboard-project-row']").first(); + await page.mouse.move(640, 700); + await expect(row.locator("header")).toHaveScreenshot(shot); + await options.hover(); + await expect(row.locator("header")).toHaveScreenshot(shot); + }); + + test("Empty project create button", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupNewProject(); + await dashboardPage.goToDashboard(); + await expect(dashboardPage.projectName).toBeVisible(); + + const create = page.locator("[class*='create-new']").first(); + await expect(create).toBeVisible(); + await page.mouse.move(640, 700); + await expect(create).toHaveScreenshot(shot); + await create.hover(); + await expect(create).toHaveScreenshot(shot); + }); + + test("Project rename", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.goToDashboard(); + + await page.getByTestId("project-options").first().click(); + await page.getByText("Rename", { exact: true }).click(); + const edit = page.locator("[class*='edit-wrapper']").first(); + await expect(edit).toBeVisible(); + await expect(edit).toHaveScreenshot(shot); + }); + + test("Comments placeholder", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.mockRPC( + "get-unread-comment-threads?team-id=*", + "logged-in-user/get-unread-comment-threads-empty.json", + ); + await dashboardPage.goToDashboard(); + + await page.getByTestId("open-comments").click(); + const placeholder = page.locator("[class*='thread-groups-placeholder']"); + await expect(placeholder).toBeVisible(); + await expect(placeholder).toHaveScreenshot(shot); + // The whole dropdown, with the header title. + await expect( + page.locator("[class*='comment-threads-section']"), + ).toHaveScreenshot(shot); + }); +}); + test.describe("Team modals controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; diff --git a/frontend/src/app/main/ui/dashboard/comments.cljs b/frontend/src/app/main/ui/dashboard/comments.cljs index 7f0da6b00e..8f2d4b14a8 100644 --- a/frontend/src/app/main/ui/dashboard/comments.cljs +++ b/frontend/src/app/main/ui/dashboard/comments.cljs @@ -15,14 +15,10 @@ [app.main.ui.comments :as cmt] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) -(def ^:private comments-icon-svg - (deprecated-icon/icon-xref :comments (stl/css :comments-icon))) - (mf/defc comments-icon* [{:keys [profile on-show-comments]}] @@ -116,5 +112,7 @@ :key (:page-id tgroup)}])] [:div {:class (stl/css :thread-groups-placeholder)} - comments-icon-svg + [:> icon* {:icon-id i/comments + :size "l" + :class (stl/css :comments-icon)}] (tr "labels.no-comments-available")])]]])) diff --git a/frontend/src/app/main/ui/dashboard/comments.scss b/frontend/src/app/main/ui/dashboard/comments.scss index 4c2b2b86a5..a703749b82 100644 --- a/frontend/src/app/main/ui/dashboard/comments.scss +++ b/frontend/src/app/main/ui/dashboard/comments.scss @@ -4,44 +4,39 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .dashboard-comments-section { - @include deprecated.flex-center; - + display: flex; + justify-content: center; + align-items: center; position: relative; - border-radius: deprecated.$br-8; + border-radius: $br-8; } .thread-groups { - height: calc(100% - deprecated.$s-32); - max-height: deprecated.$s-440; + block-size: calc(100% - #{$sz-32}); + max-block-size: px2rem(440); overflow: auto; } -.thread-group { - display: flex; - flex-direction: column; - font-size: deprecated.$fs-12; -} - .thread-groups-placeholder { align-items: center; display: flex; flex-direction: column; - font-size: deprecated.$fs-12; - padding: deprecated.$s-24; + font-size: px2rem(12); + padding: var(--sp-xxl); text-align: center; color: var(--color-foreground-secondary); } .comments-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); - height: deprecated.$s-24; - width: deprecated.$s-24; - margin-bottom: deprecated.$s-24; + flex-shrink: 0; + color: var(--color-foreground-secondary); + margin-block-end: var(--sp-xxl); } .comment-button { @@ -49,57 +44,44 @@ .unread { position: absolute; - width: deprecated.$s-8; - height: deprecated.$s-8; - border: deprecated.$s-2 solid var(--color-background-tertiary); + inline-size: px2rem(8); + block-size: px2rem(8); + border: $b-2 solid var(--color-background-tertiary); border-radius: 50%; background: var(--color-foreground-error); - top: deprecated.$s-6; - right: deprecated.$s-6; + inset-block-start: px2rem(6); + inset-inline-end: px2rem(6); } } -.comments-icon-small { - @extend %button-icon; - - stroke: var(--comment-icon-small-foreground-color); -} - .dropdown { - @include deprecated.menu-shadow; - + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); background-color: var(--color-background-tertiary); - border-radius: deprecated.$br-8; - border: deprecated.$s-1 solid transparent; - bottom: deprecated.$s-4; - height: 40vh; - max-height: deprecated.$s-480; - min-height: deprecated.$s-200; + border-radius: $br-8; + border: $b-1 solid transparent; + inset-block-end: var(--sp-xs); + block-size: 40vh; + max-block-size: $sz-480; + min-block-size: $sz-200; position: absolute; overflow: hidden; - width: 100%; - z-index: deprecated.$z-index-4; - - hr { - margin: 0; - border-color: var(--color-foreground-secondary); - } + inline-size: 100%; + z-index: var(--z-index-dropdown); } .header { display: flex; - height: deprecated.$s-40; + block-size: $sz-40; align-items: center; - padding: 0 deprecated.$s-12; - gap: deprecated.$s-4; + padding: 0 var(--sp-m); + gap: var(--sp-xs); } .header-title { + @include use-typography("headline-small"); + color: var(--color-foreground-secondary); - font-size: deprecated.$fs-11; - line-height: 1.28; flex-grow: 1; - text-transform: uppercase; } .notifications-button { @@ -108,7 +90,7 @@ } &.mark-all-as-read-button { - border-radius: deprecated.$s-8; - border: deprecated.$s-1 solid; + border-radius: $br-8; + border: $b-1 solid; } } diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs index 6bef519d98..d2f8420121 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.cljs +++ b/frontend/src/app/main/ui/dashboard/deleted.cljs @@ -21,9 +21,10 @@ [app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]] [app.main.ui.dashboard.subscription :refer [get-subscription-type]] [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -33,9 +34,6 @@ (def ^:private ref:deleted-files (l/derived :deleted-files st/state)) -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - (defn- on-restore-project [project] (let [on-accept #(st/emit! (dd/restore-project-immediately project))] @@ -158,13 +156,14 @@ [:div {:class (stl/css :info-wrapper)} [:div {:class (stl/css-case :project-actions true)} - [:button {:class (stl/css :options-btn) - :on-click on-menu-click - :title (tr "dashboard.options") - :aria-label (tr "dashboard.options") - :data-testid "project-options" - :on-key-down handle-menu-click} - menu-icon]] + [:> icon-button* {:variant "ghost" + :class (stl/css :options-btn) + :icon i/menu + :aria-label (tr "dashboard.options") + :aria-pressed (boolean (:menu-open @local)) + :data-testid "project-options" + :on-click on-menu-click + :on-key-down handle-menu-click}]] (when (:menu-open @local) [:> project-context-menu* diff --git a/frontend/src/app/main/ui/dashboard/deleted.scss b/frontend/src/app/main/ui/dashboard/deleted.scss index d6e9b376ea..7b9db012d6 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.scss +++ b/frontend/src/app/main/ui/dashboard/deleted.scss @@ -4,19 +4,21 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +// The shared dashboard header styles move in their own change: they are +// used by ten dashboard modules. @use "common/refactor/common-dashboard"; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/spacing.scss" as *; @use "ds/z-index.scss" as *; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .dashboard-container { flex: 1 0 0; - width: 100%; + inline-size: 100%; margin-inline-end: var(--sp-l); - border-top: $b-1 solid var(--panel-border-color); + border-block-start: $b-1 solid var(--color-background-quaternary); overflow-y: auto; padding-block-end: var(--sp-xxxl); } @@ -31,10 +33,10 @@ display: block; color: var(--color-foreground-secondary); - @include t.use-typography("body-large"); + @include use-typography("body-large"); line-height: 0.8; - height: var(--sp-xl); + block-size: var(--sp-xl); } .info-text-highlight { @@ -43,7 +45,7 @@ .deleted-options { display: flex; - gap: 5px; + gap: px2rem(5); flex-shrink: 0; } @@ -51,12 +53,12 @@ background: var(--color-background-default); padding: var(--sp-xxl) var(--sp-xxl) var(--sp-s) var(--sp-xxl); position: sticky; - top: 0; + inset-block-start: 0; z-index: var(--z-index-panels); } .nav-inside { - border-bottom: $b-1 solid var(--panel-border-color); + border-block-end: $b-1 solid var(--color-background-quaternary); display: flex; gap: var(--sp-l); justify-content: space-between; @@ -74,7 +76,7 @@ .selected { color: var(--color-foreground-primary); - border-bottom: $b-1 solid var(--color-foreground-primary); + border-block-end: $b-1 solid var(--color-foreground-primary); } .project { @@ -83,8 +85,8 @@ align-items: center; justify-content: space-between; gap: var(--sp-s); - width: 99%; - max-height: $sz-40; + inline-size: 99%; + max-block-size: $sz-40; padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-l); margin-block-start: var(--sp-l); border-radius: $br-4; @@ -94,19 +96,19 @@ display: flex; align-items: center; justify-content: flex-start; - width: 100%; - min-height: var(--sp-xxxl); + inline-size: 100%; + min-block-size: var(--sp-xxxl); margin-inline-start: var(--sp-s); } .project-name { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - width: fit-content; + inline-size: fit-content; margin-inline-end: var(--sp-m); line-height: 0.8; - color: var(--title-foreground-color-hover); - height: var(--sp-l); + color: var(--color-foreground-primary); + block-size: var(--sp-l); } .project-actions { @@ -115,14 +117,8 @@ margin-inline-start: var(--sp-xxxl); } -.add-file-btn, .options-btn { - @extend %button-tertiary; - - height: var(--sp-xxxl); - width: var(--sp-xxxl); margin: 0 var(--sp-s); - padding: var(--sp-s); } .info-wrapper { @@ -130,10 +126,3 @@ align-items: center; gap: var(--sp-s); } - -.add-icon, -.menu-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); -} diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index 347364ff67..a536033e4b 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -30,7 +30,6 @@ [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.jobs.progress :as jp] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -356,7 +355,7 @@ [:span {:class (stl/css-case :linked-library-tag true :error error?)} - deprecated-icon/detach]])))]])) + [:> icon* {:icon-id i/detach}]]])))]])) (defn initialize-state [entries] diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.cljs b/frontend/src/app/main/ui/dashboard/inline_edition.cljs index 18f5930c60..c3686d3147 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.cljs +++ b/frontend/src/app/main/ui/dashboard/inline_edition.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.dashboard.inline-edition (:require-macros [app.main.style :as stl]) (:require - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.keyboard :as kbd] [rumext.v2 :as mf])) @@ -68,7 +67,5 @@ :on-change on-input :on-key-down on-keyup :on-blur on-blur - :max-length max-length}] - [:span {:class (stl/css :close) - :on-click on-cancel} deprecated-icon/close]])) + :max-length max-length}]])) diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.scss b/frontend/src/app/main/ui/dashboard/inline_edition.scss index 0414167696..1310bd2925 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.scss +++ b/frontend/src/app/main/ui/dashboard/inline_edition.scss @@ -4,50 +4,31 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .edit-wrapper { - border-radius: deprecated.$br-4; + border-radius: $br-4; display: flex; - padding-right: deprecated.$s-24; + padding-inline-end: var(--sp-xxl); position: relative; - margin-right: deprecated.$s-24; + margin-inline-end: var(--sp-xxl); } -input.element-title { - background-color: var(--input-background-color-active); - border-radius: deprecated.$br-8; +.element-title { + background-color: var(--color-background-primary); + border-radius: $br-8; color: var(--color-foreground-primary); - font-size: deprecated.$fs-16; - height: deprecated.$s-32; + font-size: px2rem(16); + block-size: $sz-32; margin: 0; border: none; - padding: deprecated.$s-6; - width: 100%; + padding: px2rem(6); + inline-size: 100%; &:focus-visible { - border: deprecated.$s-1 solid var(--color-accent-primary); + border: $b-1 solid var(--color-accent-primary); outline: none; } } - -.close { - cursor: pointer; - position: absolute; - top: deprecated.$s-1; - right: calc(-1 * deprecated.$s-8); - - svg { - fill: var(--color-foreground-secondary); - height: deprecated.$s-16; - transform: rotate(45deg) translateY(7px); - width: deprecated.$s-16; - margin: 0; - } - - &:hover { - svg { - fill: var(--element-foreground-warning); - } - } -} diff --git a/frontend/src/app/main/ui/dashboard/placeholder.cljs b/frontend/src/app/main/ui/dashboard/placeholder.cljs index 100f1ed4f3..815c9d04b2 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.cljs +++ b/frontend/src/app/main/ui/dashboard/placeholder.cljs @@ -10,9 +10,9 @@ [app.main.data.event :as ev] [app.main.store :as st] [app.main.ui.dashboard.import :as udi] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] @@ -131,7 +131,7 @@ :on-mouse-leave on-mouse-leave} (if show-text? (tr "dashboard.empty-project.create") - deprecated-icon/add)]])))) + [:> icon* {:icon-id i/add :size "xl"}])]])))) (mf/defc loading-placeholder* [] diff --git a/frontend/src/app/main/ui/dashboard/search.cljs b/frontend/src/app/main/ui/dashboard/search.cljs index bce8ccc0d6..11d613f907 100644 --- a/frontend/src/app/main/ui/dashboard/search.cljs +++ b/frontend/src/app/main/ui/dashboard/search.cljs @@ -12,8 +12,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.dashboard.grid :refer [grid*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] @@ -62,17 +62,20 @@ (cond (empty? search-term) [:div {:class (stl/css :grid-empty-placeholder :search)} - [:div {:class (stl/css :icon)} deprecated-icon/search] + [:div {:class (stl/css :icon)} + [:> icon* {:icon-id i/search :size "xl"}]] [:div {:class (stl/css :text)} (tr "dashboard.type-something")]] (nil? result) [:div {:class (stl/css :grid-empty-placeholder :search)} - [:div {:class (stl/css :icon)} deprecated-icon/search] + [:div {:class (stl/css :icon)} + [:> icon* {:icon-id i/search :size "xl"}]] [:div {:class (stl/css :text)} (tr "dashboard.searching-for" search-term)]] (empty? result) [:div {:class (stl/css :grid-empty-placeholder :search)} - [:div {:class (stl/css :icon)} deprecated-icon/search] + [:div {:class (stl/css :icon)} + [:> icon* {:icon-id i/search :size "xl"}]] [:div {:class (stl/css :text)} (tr "dashboard.no-matches-for" search-term)]] :else From 980ed0a719c0deb7630eba7ffe83747080eb7eee Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 12:11:06 +0100 Subject: [PATCH 24/65] :recycle: Use DS icon buttons to close the subscription dialogs (#12539) * :recycle: Use DS icon buttons to close the subscription dialogs The close buttons of the five subscription dialogs (plan management, success, Nitrate subscription, contact sales and manual cancel) were raw buttons with no name. They become ghost DS icon buttons named "Close"; the class that places them in the corner goes on the tooltip wrapper, so they stay where they were and only show the DS hover. The plans page link-style buttons stay raw: they have no DS equivalent. A visual test covers the plans page and the plan management dialog. AI-assisted-by: claude-opus-5-5 * :recycle: Move settings subscription styles closer to the DS The settings subscription stylesheet inlines the deprecated button-style mixin and %button-icon placeholder, resolves its legacy color aliases and font weight, uses logical properties and the DS typography module without a namespace. The rules on icon svgs that have no class keep a comment; two that match nothing (the dropdown arrow svg and .radio-btns) are commented out with a TODO. refactor/ stays only for the modal placeholders (%modal-*), which move with the modals refactor. No visual change. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the settings subscription The SCSS refactor of the settings subscription left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .icon-dropdown svg, .radio-btns, .radio-btns label. No visual change. AI-assisted-by: claude-opus-5-5 * :recycle: Give the subscription icons classes and drop unused props Review fixes for the subscription styles: - The plan icon span sets the letter color with color, not stroke on a bare svg, and drops fill, stroke-width and color: transparent, which do nothing on a span. - The plan icon border uses $b-2 (was 1.75px), so it is 0.25px thicker. - The letter gets its own class for its 2.25px stroke. With the default stroke it looks too thin next to the border. - The open link and editors arrow icons get a cta-icon class instead of the bare svg selector, and take the button color. The arrow used an icon-dropdown class with no rule. AI-assisted-by: claude-opus-5-5 * :recycle: Use the DS stroke width and color in the subscription icons The plan letter and the member icons keep the DS stroke width (the plan letter line gets thinner), the member icons set their color instead of the stroke, and the props copied from the button icon placeholder go. The success dialog logo gets its own class instead of a bare svg selector. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 37 ++++++ .../app/main/ui/settings/subscription.cljs | 47 +++++--- .../app/main/ui/settings/subscription.scss | 108 +++++++----------- 3 files changed, 109 insertions(+), 83 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 8d5f9fca74..2e3b7fe082 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -559,6 +559,43 @@ test.describe("Team modals controls", () => { }); }); +test.describe("Settings subscription controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Plans page and management dialog", async ({ page }) => { + await DashboardPage.mockConfigFlags( + page, + ["enable-subscriptions", "disable-onboarding"], + { penpotIsSaas: true }, + ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await page.goto("/?screen=settings-subscription"); + await expect( + page.getByRole("heading", { name: "Subscription", level: 2 }), + ).toBeVisible(); + + const trial = page + .getByRole("button", { name: "Try it free for 14 days" }) + .first(); + await expect(trial).toBeVisible(); + await page.mouse.move(10, 700); + await expect(page).toHaveScreenshot(shot); + + await trial.click(); + const dialog = page.locator("[class*='modal-dialog']"); + await expect(dialog).toBeVisible(); + await page.mouse.move(10, 700); + await expect(dialog).toHaveScreenshot(shot); + + await dialog.locator("button").first().hover(); + await expect(dialog).toHaveScreenshot(shot); + }); +}); + test.describe("Dashboard sidebar controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index d1db4cab5f..3b879563a9 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -16,6 +16,7 @@ [app.main.ui.components.forms :as fm] [app.main.ui.dashboard.subscription :refer [get-subscription-type]] [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] [app.main.ui.nitrate.nitrate-activation-success-modal] @@ -93,6 +94,7 @@ [:button {:class (stl/css :cta-button :more-info) :on-click cta-link-with-icon} cta-text-with-icon [:> icon* {:icon-id "open-link" + :class (stl/css :cta-icon) :size "s"}]]) (when has-cta-link [:button {:class (stl/css-case :cta-button true @@ -137,7 +139,7 @@ [:button {:class (stl/css :cta-button :show-editors-button) :on-click handle-click} (tr "subscription.settings.management.dialog.editors") [:> icon* {:icon-id (if show-editors-list i/arrow-up i/arrow-down) - :class (stl/css :icon-dropdown) + :class (stl/css :cta-icon) :size "s"}]] (when show-editors-list [:* @@ -262,9 +264,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title)} (if (= unlimited-modal-step 2) (tr "subscription.settings.management-dialog.step-2-title") @@ -382,12 +386,15 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :subscription-success)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-success-content)} [:div {:class (stl/css :modal-start)} - [:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription")}]] + [:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription") + :class (stl/css :modal-start-logo)}]] [:div {:class (stl/css :modal-end)} [:div {:class (stl/css :modal-title)} @@ -835,9 +842,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title :nitrate-subscription)} (tr "nitrate.form.title")] @@ -889,9 +898,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title)} (tr "nitrate.contact-sales.title" subscription-type)] [:div {:class (stl/css :modal-content)} @@ -942,9 +953,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title)} (tr "nitrate.subscription.settings.manual-cancel")] diff --git a/frontend/src/app/main/ui/settings/subscription.scss b/frontend/src/app/main/ui/settings/subscription.scss index ff7885506d..b1fd03d2b6 100644 --- a/frontend/src/app/main/ui/settings/subscription.scss +++ b/frontend/src/app/main/ui/settings/subscription.scss @@ -4,8 +4,10 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor. @use "refactor/common-refactor.scss" as deprecated; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/_borders.scss" as *; @use "ds/spacing.scss" as *; @use "ds/_sizes.scss" as *; @@ -39,34 +41,29 @@ } .membership-date { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); margin-inline-start: var(--sp-s); } -.subscription-member, .penpot-member { - @extend %button-icon; -} - -.penpot-member { - stroke: var(--icon-foreground); + color: var(--color-foreground-secondary); } .subscription-member { - stroke: var(--color-badge-premium); + color: var(--color-badge-premium); } .title-section { - @include t.use-typography("title-large"); + @include use-typography("title-large"); color: var(--color-foreground-primary); margin-block-end: var(--sp-xxl); } .plan-section-title { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); color: var(--color-foreground-primary); } @@ -100,29 +97,26 @@ } .plan-title-icon { - @extend %button-icon; - + display: flex; + justify-content: center; + align-items: center; + color: var(--color-foreground-primary); block-size: var(--sp-xl); inline-size: var(--sp-xl); - border-radius: 6px; - border: 1.75px solid var(--color-foreground-primary); -} - -.plan-title-icon svg { - stroke: var(--color-foreground-primary); - stroke-width: 2.25px; + border-radius: $br-6; + border: $b-2 solid var(--color-foreground-primary); } .plan-card-title, .plan-price-value, .price-value { - @include t.use-typography("title-medium"); + @include use-typography("title-medium"); color: var(--color-foreground-primary); } .plan-editors { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); align-self: end; color: var(--color-foreground-primary); @@ -130,7 +124,7 @@ } .plan-price-period { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-primary); } @@ -144,7 +138,7 @@ } .plan-cancel-date { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-primary); } @@ -158,7 +152,7 @@ .benefits-title, .benefit { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); } @@ -172,29 +166,24 @@ } .cta-button { - @include t.use-typography("body-medium"); - @include deprecated.button-style; + @include use-typography("body-medium"); + border: none; + background: none; + cursor: pointer; align-items: center; color: var(--color-accent-primary); display: flex; margin-block-start: var(--sp-m); - text-align: left; + text-align: start; } -.cta-button svg { - @extend %button-icon; - +.cta-icon { block-size: var(--sp-l); inline-size: var(--sp-l); - stroke: var(--color-accent-primary); margin-inline-start: var(--sp-xs); } -.icon-dropdown svg { - transform: rotate(90deg); -} - .bottom-link { margin-block-start: var(--sp-xs); } @@ -204,7 +193,7 @@ } .inline-error { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-error); margin-block: var(--sp-s) 0; @@ -225,15 +214,18 @@ min-inline-size: px2rem(648); } +// Tooltip wrapper of the close buttons: it places them in the corner. .close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-title { - @include t.use-typography("title-large"); + @include use-typography("title-large"); margin-block-end: var(--sp-xxxl); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); display: flex; gap: var(--sp-m); } @@ -243,7 +235,7 @@ } .modal-text-medium { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); } .modal-text-cap { @@ -257,7 +249,7 @@ .modal-text-medium strong, .text-strikethrough strong, .modal-text-cap strong { - font-weight: deprecated.$fw700; + font-weight: 700; } .modal-content, @@ -309,13 +301,13 @@ } } -.modal-start svg { +.modal-start-logo { inline-size: 100%; block-size: auto; } .editors-text { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); margin: 0; } @@ -326,7 +318,7 @@ } .editors-list { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); list-style-position: inside; list-style-type: none; @@ -342,7 +334,7 @@ } .error-message { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-error); margin-block-start: var(--sp-s); @@ -361,7 +353,7 @@ } .unlimited-capped-warning { - @include t.use-typography("body-small"); + @include use-typography("body-small"); background-color: var(--color-background-tertiary); border-radius: var(--sp-s); @@ -374,28 +366,12 @@ padding-inline: 0; } -.radio-btns { - display: flex; - flex-direction: column; - padding-block-end: var(--sp-xl); - gap: var(--sp-s); -} - -.radio-btns label { - @include t.use-typography("body-large"); - - padding: 0; - display: flex; - align-items: center; - color: var(--color-foreground-secondary); -} - .modal-contact-content { gap: var(--sp-xl); } .downgrade-warning { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); background-color: var(--color-background-tertiary); border-radius: var(--sp-s); @@ -412,13 +388,13 @@ } .downgrade-item { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); margin-block-end: var(--sp-l); } .cta-link { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); align-items: center; color: var(--color-accent-primary); From da36e3683649b536a8d255cb8fb314c08f4361dc Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 14:26:43 +0100 Subject: [PATCH 25/65] :sparkles: Add the share, handlers and snap icons to the DS (#12659) * :sparkles: Add the share, handlers and snap icons to the DS The share button and the path editing bar could not move to the DS buttons because their icons were missing from the DS icon set. As agreed with design: - handlers-equal, handlers-independent, handlers-mirror and snap join the DS as they are (their SVGs already follow the DS format). - share keeps its drawing, moved onto the 16x16 grid as a single path with no fixed color. - snap-nodes is deleted: nothing used it, and snap is the icon of the snap nodes button. The legacy share button in the workspace header uses the same SVG: its icon is about 6% smaller and now takes the hover color, which the fixed stroke color blocked before. AI-assisted-by: claude-opus-5-5 * :bug: Show the share icon lines outside the app The share SVG had no fill or stroke, so plain SVG viewers used the defaults (black fill, no stroke) and drew only three dots. Set fill="none" and stroke="currentColor", like settings.svg. The app already sets both in CSS, so nothing changes there. AI-assisted-by: claude-opus-5-5 * :bug: Show the handlers and snap icons right outside the app Like share, these SVGs had no fill or stroke, so plain SVG viewers filled the shapes black and drew no lines. Set fill="none" and stroke="currentColor". The app sets both in CSS, so nothing changes there. AI-assisted-by: claude-opus-5-5 --- frontend/resources/images/icons/handlers-equal.svg | 2 +- frontend/resources/images/icons/handlers-independent.svg | 2 +- frontend/resources/images/icons/handlers-mirror.svg | 2 +- frontend/resources/images/icons/share.svg | 4 +++- frontend/resources/images/icons/snap-nodes.svg | 3 --- frontend/resources/images/icons/snap.svg | 2 +- frontend/src/app/main/ui/ds/foundations/assets/icon.cljs | 6 +++++- frontend/src/app/main/ui/icons.cljs | 1 - 8 files changed, 12 insertions(+), 10 deletions(-) delete mode 100644 frontend/resources/images/icons/snap-nodes.svg diff --git a/frontend/resources/images/icons/handlers-equal.svg b/frontend/resources/images/icons/handlers-equal.svg index 645e773d8c..c1b3cb33aa 100644 --- a/frontend/resources/images/icons/handlers-equal.svg +++ b/frontend/resources/images/icons/handlers-equal.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/resources/images/icons/handlers-independent.svg b/frontend/resources/images/icons/handlers-independent.svg index 36e1c06917..cb782fbfe2 100644 --- a/frontend/resources/images/icons/handlers-independent.svg +++ b/frontend/resources/images/icons/handlers-independent.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/resources/images/icons/handlers-mirror.svg b/frontend/resources/images/icons/handlers-mirror.svg index 78f71c740e..1e8ac8868c 100644 --- a/frontend/resources/images/icons/handlers-mirror.svg +++ b/frontend/resources/images/icons/handlers-mirror.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/resources/images/icons/share.svg b/frontend/resources/images/icons/share.svg index 2c0d41de63..0677a583d3 100644 --- a/frontend/resources/images/icons/share.svg +++ b/frontend/resources/images/icons/share.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/frontend/resources/images/icons/snap-nodes.svg b/frontend/resources/images/icons/snap-nodes.svg deleted file mode 100644 index 6ce0a90d67..0000000000 --- a/frontend/resources/images/icons/snap-nodes.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/resources/images/icons/snap.svg b/frontend/resources/images/icons/snap.svg index 0acdce1f20..b2f88cb835 100644 --- a/frontend/resources/images/icons/snap.svg +++ b/frontend/resources/images/icons/snap.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index 991532c963..6d05480deb 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -165,6 +165,9 @@ (def ^:icon-id group "group") (def ^:icon-id gutter-horizontal "gutter-horizontal") (def ^:icon-id gutter-vertical "gutter-vertical") +(def ^:icon-id handlers-equal "handlers-equal") +(def ^:icon-id handlers-independent "handlers-independent") +(def ^:icon-id handlers-mirror "handlers-mirror") (def ^:icon-id help "help") (def ^:icon-id hide "hide") (def ^:icon-id history "history") @@ -238,10 +241,11 @@ (def ^:icon-id search "search") (def ^:icon-id separate-nodes "separate-nodes") (def ^:icon-id settings "settings") +(def ^:icon-id share "share") (def ^:icon-id shown "shown") (def ^:icon-id size-horizontal "size-horizontal") (def ^:icon-id size-vertical "size-vertical") -(def ^:icon-id snap-nodes "snap-nodes") +(def ^:icon-id snap "snap") (def ^:icon-id status-alert "status-alert") (def ^:icon-id status-tick "status-tick") (def ^:icon-id status-update "status-update") diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 7dec276e5d..59708eef1a 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -224,7 +224,6 @@ (def ^:icon size-horizontal (icon-xref :size-horizontal)) (def ^:icon size-vertical (icon-xref :size-vertical)) (def ^:icon snap (icon-xref :snap)) -(def ^:icon snap-nodes (icon-xref :snap-nodes)) (def ^:icon status-alert (icon-xref :status-alert)) (def ^:icon status-tick (icon-xref :status-tick)) (def ^:icon status-update (icon-xref :status-update)) From e308c2eca6d0c26d6746594592148fe8db5c343d Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 14:27:38 +0100 Subject: [PATCH 26/65] :recycle: Use the DS stroke width in the migrated DS icons (#12673) * :recycle: Use the DS stroke width in the migrated DS icons The DS icons in the areas already moved to the DS set their own stroke width (1px, 1.33px or 2.25px) to match the legacy icons. They now keep the DS stroke width, so some icon lines get slightly thinner or thicker: copy button, context menu, layer row, comments filter, assets section, share link, dashboard sidebar, team pages, templates, team switcher and subscription icons. AI-assisted-by: claude-opus-5-5 * :recycle: Give the share link page rows a class The page rows of the share link permissions get their own class instead of a bare li selector. No visual change. AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/ui/components/copy_button.scss | 2 -- .../src/app/main/ui/components/editable_select.scss | 1 - .../main/ui/dashboard/organization_team_switch.scss | 3 --- frontend/src/app/main/ui/dashboard/sidebar.scss | 3 --- frontend/src/app/main/ui/dashboard/subscription.scss | 1 - frontend/src/app/main/ui/dashboard/team.scss | 6 ------ frontend/src/app/main/ui/dashboard/templates.scss | 2 -- frontend/src/app/main/ui/viewer/share_link.cljs | 2 +- frontend/src/app/main/ui/viewer/share_link.scss | 10 ++++------ frontend/src/app/main/ui/workspace/comments.scss | 2 -- frontend/src/app/main/ui/workspace/context_menu.scss | 1 - frontend/src/app/main/ui/workspace/sidebar/assets.scss | 1 - .../src/app/main/ui/workspace/sidebar/layer_item.scss | 5 ----- 13 files changed, 5 insertions(+), 34 deletions(-) diff --git a/frontend/src/app/main/ui/components/copy_button.scss b/frontend/src/app/main/ui/components/copy_button.scss index 8ae7cbf664..cecd60f2f1 100644 --- a/frontend/src/app/main/ui/components/copy_button.scss +++ b/frontend/src/app/main/ui/components/copy_button.scss @@ -28,7 +28,6 @@ .icon { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-secondary); } } @@ -81,7 +80,6 @@ .icon { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-primary); display: none; } diff --git a/frontend/src/app/main/ui/components/editable_select.scss b/frontend/src/app/main/ui/components/editable_select.scss index 7257ee14f2..fc5bc19cf8 100644 --- a/frontend/src/app/main/ui/components/editable_select.scss +++ b/frontend/src/app/main/ui/components/editable_select.scss @@ -123,7 +123,6 @@ justify-content: center; align-items: center; fill: none; - stroke-width: 1.33px; visibility: hidden; color: var(--dropdown-element-icon-color); } diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss index 3fee83b7a4..bba4da94e7 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss @@ -80,7 +80,6 @@ width: $sz-16; color: transparent; fill: none; - stroke-width: 1px; stroke: var(--icon-foreground); } @@ -127,7 +126,6 @@ .chevron-icon svg { color: transparent; fill: none; - stroke-width: 1px; stroke: var(--icon-foreground); } @@ -416,7 +414,6 @@ width: $sz-16; color: transparent; fill: none; - stroke-width: 1px; stroke: var(--sidebar-action-icon-color); } diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 424f56427b..21a5c112bf 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -147,7 +147,6 @@ // side keeps the drawing and the text where they were. .pin-icon { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-secondary); margin: 0 px2rem(10); } @@ -213,7 +212,6 @@ .search-icon { flex-shrink: 0; - stroke-width: 1px; } .clear-search-btn:hover { @@ -374,6 +372,5 @@ } .exit-icon { - stroke-width: 1px; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index 6cfe4c2e9a..e90a800019 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -167,7 +167,6 @@ .subscription-icon { stroke: var(--color-foreground-secondary); - stroke-width: 2.25px; } .menu-item { diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index b51bd9d071..2720ab6d58 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -78,7 +78,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; margin: 0 auto; color: var(--color-foreground-secondary); } @@ -125,7 +124,6 @@ } .image-icon { - stroke-width: $b-1; color: var(--color-foreground-primary); } @@ -571,7 +569,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--color-accent-success); } @@ -581,7 +578,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--color-accent-warning); } @@ -641,7 +637,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--color-foreground-secondary); } @@ -976,7 +971,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--stroke-color); } diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index 1be61d4465..b9ae6c92f2 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -105,7 +105,6 @@ align-items: center; height: $sz-16; width: $sz-16; - stroke-width: $b-1; color: var(--color-foreground-secondary); } @@ -241,7 +240,6 @@ align-items: center; height: $sz-16; width: $sz-16; - stroke-width: $b-1; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/viewer/share_link.cljs b/frontend/src/app/main/ui/viewer/share_link.cljs index 32e9117908..6d43e1c60b 100644 --- a/frontend/src/app/main/ui/viewer/share_link.cljs +++ b/frontend/src/app/main/ui/viewer/share_link.cljs @@ -283,7 +283,7 @@ [:ul {:class (stl/css :pages-selection)} (for [{:keys [id name]} pages] - [:li {:class (stl/css :checkbox-wrapper) + [:li {:class (stl/css :checkbox-wrapper :page-row) :key (dm/str id)} [:label {:for (dm/str "page-" id)} [:span {:class (stl/css-case :global/checked (contains? selected id))} diff --git a/frontend/src/app/main/ui/viewer/share_link.scss b/frontend/src/app/main/ui/viewer/share_link.scss index 336ad5ede3..7cb4e94245 100644 --- a/frontend/src/app/main/ui/viewer/share_link.scss +++ b/frontend/src/app/main/ui/viewer/share_link.scss @@ -136,7 +136,6 @@ .arrow-icon { flex-shrink: 0; - stroke-width: 1px; color: var(--color-foreground-secondary); } @@ -187,13 +186,12 @@ .pages-selection { margin: 0; +} - // The page rows have no class of their own. - li { - border-block-end: $b-1 solid var(--color-background-quaternary); - } +.page-row { + border-block-end: $b-1 solid var(--color-background-quaternary); - li:last-child { + &:last-child { border-block-end: none; } } diff --git a/frontend/src/app/main/ui/workspace/comments.scss b/frontend/src/app/main/ui/workspace/comments.scss index 9e7471e394..ea18d51a42 100644 --- a/frontend/src/app/main/ui/workspace/comments.scss +++ b/frontend/src/app/main/ui/workspace/comments.scss @@ -68,7 +68,6 @@ .arrow-svg { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-secondary); } } @@ -105,7 +104,6 @@ inline-size: $sz-16; block-size: $sz-16; stroke: currentcolor; - stroke-width: 1.33px; color: transparent; } } diff --git a/frontend/src/app/main/ui/workspace/context_menu.scss b/frontend/src/app/main/ui/workspace/context_menu.scss index 3061e59a4b..e46fae5ace 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/context_menu.scss @@ -127,7 +127,6 @@ align-items: center; inline-size: $sz-16; color: var(--color-foreground-primary); - stroke-width: 1.33px; } .separator { diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.scss b/frontend/src/app/main/ui/workspace/sidebar/assets.scss index afccd3404d..97fb0733ca 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.scss @@ -41,7 +41,6 @@ .section-icon { flex-shrink: 0; - stroke-width: 1px; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss index d7cbad6289..18a1395d52 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss @@ -211,8 +211,6 @@ } .toggle-content-icon { - // Same stroke weight as the old 12px icons. - stroke-width: 1.33px; color: var(--color-foreground-secondary); .layer-row.hidden & { @@ -284,9 +282,6 @@ // The DS icon box is 16px and the button leaves 12px: keep the box // whole, centered, instead of letting it shrink and cut the icon. flex-shrink: 0; - - // Same stroke weight as the old 12px icons. - stroke-width: 1.33px; color: var(--color-foreground-secondary); .layer-row.hidden & { From 66c67660f94b36e9d47f5f88619723d16860c7dc Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 14:27:51 +0100 Subject: [PATCH 27/65] :recycle: Use DS typographies in the debug shape panel and drop a dead selector (#12650) * :fire: Remove a commented-out selector from the debug shape panel .shape-info-title was left commented out with a "check by hand" TODO in #12484. Nothing uses it, so delete it instead of keeping it as a comment. AI-assisted-by: claude-opus-5-5 * :recycle: Use DS typographies in the debug shape panel The panel text and the shape title only set a font size, with a "no exact DS typography match" TODO. body-small and body-medium differ from them only in the line-height, so use them directly and drop the TODOs. Visible change: the panel lines get the DS line-height (1.3 for the text, 1.4 for the shape title) instead of the browser default, so rows grow a little taller. The panel is debug-only. AI-assisted-by: claude-opus-5-5 --- .../ui/workspace/sidebar/debug_shape_info.scss | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss index 9f770611f7..27ef6f4b32 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss @@ -5,6 +5,7 @@ // Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; +@use "ds/typography.scss" as t; @use "ds/_utils.scss" as *; .shape-info { @@ -13,26 +14,19 @@ background-color: var(--color-background-primary); color: var(--color-foreground-primary); - // TODO: No exact DS typography match found. - // Closest match: body-small. - font-size: px2rem(12); + @include t.use-typography("body-small"); + user-select: text; } -// TODO: Unused in debug_shape_info.cljs; check by hand before removing. -// .shape-info-title { -// margin: var(--sp-s) var(--sp-s) 0 var(--sp-s); -// } - .attrs-container { padding: var(--sp-l) var(--sp-s); overflow: hidden; } .shape-title { - // TODO: No exact DS typography match found. - // Closest match: body-medium. - font-size: px2rem(14); + @include t.use-typography("body-medium"); + background: var(--color-background-quaternary); color: var(--color-foreground-primary); padding: var(--sp-s); From 8951c57ee6157fcfb209b4c6569fe64014cf5037 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 16:11:52 +0100 Subject: [PATCH 28/65] :recycle: Use DS buttons and icons in the dashboard fonts page (#12533) * :recycle: Use DS buttons and icons in the dashboard fonts page The fonts page moves off the deprecated icons and raw buttons: - "Add custom font", "Upload all", "Upload" and "Save" become DS primary buttons and "Dismiss all" a secondary one. - The remove, cancel and menu controls of the font rows become ghost DS icon buttons named "Remove", "Cancel" and "Options"; the menu one says whether its menu is open. Two of them were spans. - The delete control of a font style was a span; it is now a button named "Delete font style" with the DS close icon, still shown only on hover. - The warning and empty list icons use the DS icons. The loading placeholder keeps the legacy loader: there is no DS one. The font menu offset grows by the 6px the taller menu button added, so the menu opens where it did. New key: dashboard.fonts.delete-variant (en, es). A visual test covers a font ready to upload, the style hover, the font menu and the edition row. AI-assisted-by: claude-opus-5-5 * :recycle: Move dashboard fonts styles to logical properties Physical sizes, offsets, margins and paddings become logical properties and the DS typography module is used without a namespace. The element selectors (section title, inputs, hero text) stay, with a comment: they have no class. The shared dashboard header styles (common-dashboard) still come from refactor/: ten dashboard modules use them, so they move in their own change. No visual change. AI-assisted-by: claude-opus-5-5 * :recycle: Use the DS loader and heading in the dashboard fonts page The fonts loading placeholder used the legacy loader icon, drawn as an outline in a 32px box. It now uses the DS loader*, the same animated pencil as the other dashboard loading screens. Its box is 9px tall, so the label moves 6px up. The section title h3 is now a heading* with the title-small typography and its own class, so the SCSS no longer styles the bare element. The legacy loader icon has no other use, so its SVG and def are gone. AI-assisted-by: claude-opus-5-5 * :recycle: Give the dashboard fonts inputs and hero text classes The font name and search inputs and the hero text get their own classes instead of bare input and p selectors, and the hero title uses the DS heading with title-large instead of the global h2 rule. The title keeps its 24px size and weight; the DS line height (1.4 instead of 1.15) moves the hero content about 6px down. AI-assisted-by: claude-opus-5-5 * :bug: Give the fonts placeholder icon its color back Removing the legacy loader also removed the .icon svg rule, which gave the empty placeholder text icon its color, so the icon had none. The icon now has its own class with the secondary foreground color, and the wrapper div without styles goes. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 68 +++++ frontend/resources/images/icons/loader.svg | 1 - frontend/src/app/main/ui/dashboard/fonts.cljs | 133 ++++++---- frontend/src/app/main/ui/dashboard/fonts.scss | 247 ++++++++---------- frontend/src/app/main/ui/icons.cljs | 1 - frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 7 files changed, 265 insertions(+), 193 deletions(-) delete mode 100644 frontend/resources/images/icons/loader.svg diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 2e3b7fe082..878a02d347 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -338,6 +338,74 @@ test.describe("Delete account modal", () => { }); }); +test.describe("Fonts page controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Installed font variant, menu and edition", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await expect(dashboardPage.mainHeading).toHaveText("Fonts"); + + const installed = page.locator("[class*='dashboard-installed-fonts']"); + const row = installed.locator("[class*='font-item']").first(); + await expect(row).toBeVisible(); + + // The variant delete icon only shows on hover. + await row.locator("[class$='__variant'], [class*='__variant ']").hover(); + await expect(installed).toHaveScreenshot(shot); + + // The menu button: a span before the DS migration, a DS icon button + // named "Options" after it. + const options = row.locator("[class*='options']"); + await options + .locator("span[class*='icon']") + .or(options.getByRole("button", { name: "Options" })) + .first() + .click(); + await expect(page.getByText("Download")).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + + await page.getByText("Edit", { exact: true }).click(); + await expect(row.locator("input")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(installed).toHaveScreenshot(shot); + }); + + test("Fonts ready to upload", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await expect(dashboardPage.mainHeading).toHaveText("Fonts"); + + await page + .locator("#font-upload") + .setInputFiles("playwright/data/render-wasm/assets/ebgaramond.ttf"); + await expect(page.getByTestId("upload-all")).toBeVisible(); + await page.mouse.move(10, 700); + await expect( + page.locator("[class*='dashboard-fonts-upload']"), + ).toHaveScreenshot(shot); + }); + + test("Fonts loading placeholder", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + // Never answer, so the page keeps showing the loader. + await page.route(/get-font-variants/, () => {}); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await expect(dashboardPage.mainHeading).toHaveText("Fonts"); + + const placeholder = page.locator("[class*='fonts-placeholder']"); + await expect(placeholder).toBeVisible(); + await page.mouse.move(10, 700); + await expect(placeholder).toHaveScreenshot(shot); + }); +}); + test.describe("Subscription controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; diff --git a/frontend/resources/images/icons/loader.svg b/frontend/resources/images/icons/loader.svg deleted file mode 100644 index ee1d9b96bf..0000000000 --- a/frontend/resources/images/icons/loader.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 5574f21572..ed27260fe9 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -21,8 +21,13 @@ [app.main.store :as st] [app.main.ui.components.context-menu-a11y :refer [context-menu*]] [app.main.ui.components.file-uploader :refer [file-uploader]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] [app.util.http :as http] @@ -195,13 +200,18 @@ [:div {:class (stl/css :dashboard-fonts-upload)} [:div {:class (stl/css :dashboard-fonts-hero)} [:div {:class (stl/css :desc)} - [:h2 (tr "labels.upload-custom-fonts")] - [:> i18n/tr-html* {:content (tr "dashboard.fonts.hero-text1")}] + [:> heading* {:level 2 + :typography t/title-large + :class (stl/css :hero-title)} + (tr "labels.upload-custom-fonts")] + [:> i18n/tr-html* {:content (tr "dashboard.fonts.hero-text1") + :class (stl/css :hero-text)}] - [:button {:class (stl/css :btn-primary) - :on-click on-click - :tab-index "0"} - [:span (tr "labels.add-custom-font")] + [:> button* {:variant "primary" + :type "button" + :class (stl/css :add-font-button) + :on-click on-click} + (tr "labels.add-custom-font") [:& file-uploader {:input-id "font-upload" :accept accept-font-types :multi true @@ -223,17 +233,19 @@ [:div {:class (stl/css :font-item :table-row)} [:span (tr "dashboard.fonts.fonts-added" (i18n/c (count fonts)))] [:div {:class (stl/css :table-field :options)} - [:button {:class (stl/css-case - :btn-primary true - :disabled disable-upload-all?) - :on-click on-upload-all - :data-testid "upload-all" - :disabled disable-upload-all?} - [:span (tr "dashboard.fonts.upload-all")]] - [:button {:class (stl/css :btn-secondary) - :on-click on-dismis-all - :data-testid "dismiss-all"} - [:span (tr "dashboard.fonts.dismiss-all")]]]]) + [:> button* {:variant "primary" + :type "button" + :class (stl/css :upload-button) + :on-click on-upload-all + :data-testid "upload-all" + :disabled disable-upload-all?} + (tr "dashboard.fonts.upload-all")] + [:> button* {:variant "secondary" + :type "button" + :class (stl/css :dismiss-button) + :on-click on-dismis-all + :data-testid "dismiss-all"} + (tr "dashboard.fonts.dismiss-all")]]]) (for [{:keys [id] :as item} (sort-by :font-family font-vals)] (let [uploading? (contains? uploading id) @@ -242,6 +254,7 @@ :key (dm/str id)} [:div {:class (stl/css :table-field :family)} [:input {:type "text" + :class (stl/css :name-input) :data-id (dm/str id) :on-blur on-blur-name :on-change on-change-name @@ -256,23 +269,23 @@ [:div {:class (stl/css :table-field :options)} (when (:height-warning? item) - [:span {:class (stl/css :icon :failure)} - deprecated-icon/msg-neutral]) + [:> icon* {:icon-id i/msg-neutral + :class (stl/css :warning-icon)}]) - [:button {:on-click on-upload - :data-id (dm/str id) - :class (stl/css-case - :btn-primary true - :upload-button true - :disabled disable-upload?) - :disabled disable-upload?} + [:> button* {:variant "primary" + :type "button" + :on-click on-upload + :data-id (dm/str id) + :class (stl/css :upload-button) + :disabled disable-upload?} (if ^boolean uploading? (tr "labels.uploading") (tr "labels.upload"))] - [:span {:class (stl/css :icon :close) - :data-id (dm/str id) - :on-click on-delete} - deprecated-icon/close]]]))]])) + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.remove") + :icon i/close + :data-id (dm/str id) + :on-click on-delete}]]]))]])) (mf/defc installed-font-context-menu* {::mf/private true} @@ -292,7 +305,7 @@ :show is-open :fixed false :min-width true - :top -15 + :top -21 :left -115 :options options}])) @@ -406,6 +419,7 @@ :is-edition edition?)} (if ^boolean edition? [:input {:type "text" + :class (stl/css :name-input) :auto-focus true :default-value font-family :on-key-down on-key-down @@ -420,29 +434,37 @@ [:span {:class (stl/css :label)} [:> font-variant-display-name* {:variant item}]] (when can-edit - [:span - {:class (stl/css :icon :close) - :data-id (dm/str id) - :on-click on-delete-variant} - deprecated-icon/add])])] + [:button {:type "button" + :class (stl/css :variant-delete) + :aria-label (tr "dashboard.fonts.delete-variant") + :title (tr "dashboard.fonts.delete-variant") + :data-id (dm/str id) + :on-click on-delete-variant} + [:> icon* {:icon-id i/close + :size "s" + :aria-hidden true}]])])] (if ^boolean edition? [:div {:class (stl/css :table-field :options)} - [:button - {:disabled (str/blank? font-family) - :on-click on-save - :class (stl/css-case :btn-primary true - :btn-disabled (str/blank? font-family))} + [:> button* {:variant "primary" + :type "button" + :class (stl/css :upload-button) + :disabled (str/blank? font-family) + :on-click on-save} (tr "labels.save")] - [:button {:class (stl/css :icon :close) - :on-click on-cancel} - deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.cancel") + :icon i/close + :on-click on-cancel}]] (when can-edit [:div {:class (stl/css :table-field :options)} - [:span {:class (stl/css :icon) - :on-click on-menu-open} - deprecated-icon/menu] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.options") + :aria-haspopup "menu" + :aria-expanded menu-open? + :icon i/menu + :on-click on-menu-open}] [:> installed-font-context-menu* {:on-close on-menu-close @@ -468,12 +490,16 @@ (cond (seq fonts) [:* - [:h3 (tr "labels.installed-fonts")] + [:> heading* {:level 3 + :typography t/title-small + :class (stl/css :installed-fonts-title)} + (tr "labels.installed-fonts")] [:div {:class (stl/css :installed-fonts-header)} [:div {:class (stl/css :table-field :family)} (tr "labels.font-family")] [:div {:class (stl/css :table-field :variants)} (tr "labels.font-variants")] [:div {:class (stl/css :table-field :search-input)} - [:input {:placeholder (tr "labels.search-font") + [:input {:class (stl/css :search-font-input) + :placeholder (tr "labels.search-font") :default-value "" :on-change on-change}]]] (for [[font-id variants] (->> (vals fonts) @@ -486,13 +512,16 @@ (nil? fonts) [:div {:class (stl/css :fonts-placeholder)} - [:div {:class (stl/css :icon)} deprecated-icon/loader] + [:> loader* {:width 32 + :title (tr "labels.loading")}] [:div {:class (stl/css :label)} (tr "dashboard.loading-fonts")]] :else (if ^boolean can-edit [:div {:class (stl/css :fonts-placeholder)} - [:div {:class (stl/css :icon)} deprecated-icon/text] + [:> icon* {:icon-id i/text + :size "xl" + :class (stl/css :placeholder-icon)}] [:div {:class (stl/css :label)} (tr "dashboard.fonts.empty-placeholder")]] [:> empty-placeholder* diff --git a/frontend/src/app/main/ui/dashboard/fonts.scss b/frontend/src/app/main/ui/dashboard/fonts.scss index c8cbcdf808..e2e6430ede 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.scss +++ b/frontend/src/app/main/ui/dashboard/fonts.scss @@ -4,66 +4,60 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// The shared dashboard header styles move in their own change: they are +// used by ten dashboard modules. @use "common/refactor/common-dashboard"; @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/spacing.scss" as *; @use "ds/mixins.scss" as *; .dashboard-fonts { - border-top: $b-1 solid var(--color-background-quaternary); + border-block-start: $b-1 solid var(--color-background-quaternary); display: flex; flex-direction: column; - padding-left: px2rem(120); - padding-bottom: px2rem(120); + padding-inline-start: px2rem(120); + padding-block-end: px2rem(120); overflow-y: auto; - - .btn-primary { - font-size: px2rem(11); - height: $sz-32; - min-width: px2rem(100); - } } .dashboard-installed-fonts { - max-width: px2rem(1000); - width: 100%; + max-inline-size: px2rem(1000); + inline-size: 100%; display: flex; - margin-top: var(--sp-xxl); + margin-block-start: var(--sp-xxl); flex-direction: column; - h3 { - @include t.use-typography("title-small"); - - color: var(--color-foreground-secondary); - margin: var(--sp-xs); - } - .font-item { color: var(--color-background-secondary); } } +.installed-fonts-title { + color: var(--color-foreground-secondary); + margin: var(--sp-xs); +} + .installed-fonts-header { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); align-items: center; color: var(--color-foreground-secondary); display: flex; - height: $sz-40; - padding-left: var(--sp-xxl); + block-size: $sz-40; + padding-inline-start: var(--sp-xxl); > .family { @include two-line-text-ellipsis; - min-width: $sz-200; - width: $sz-200; + min-inline-size: $sz-200; + inline-size: $sz-200; } > .variants { - padding-left: var(--sp-m); + padding-inline-start: var(--sp-m); } } @@ -71,31 +65,31 @@ display: flex; flex-grow: 1; justify-content: flex-end; +} - input { - @include t.use-typography("body-medium"); +.search-font-input { + @include use-typography("body-medium"); - background-color: var(--color-background-tertiary); - border-radius: $br-8; - border: $b-1 solid transparent; - color: var(--color-foreground-primary); - height: $sz-32; - margin: 0; - padding: 0 var(--sp-s); - width: px2rem(152); + background-color: var(--color-background-tertiary); + border-radius: $br-8; + border: $b-1 solid transparent; + color: var(--color-foreground-primary); + block-size: $sz-32; + margin: 0; + padding: 0 var(--sp-s); + inline-size: px2rem(152); - &:focus { - outline: $b-1 solid var(--color-accent-primary); - } + &:focus { + outline: $b-1 solid var(--color-accent-primary); + } - &::placeholder { - color: var(--color-foreground-secondary); - } + &::placeholder { + color: var(--color-foreground-secondary); } } .font-item { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); align-items: center; background-color: var(--color-background-tertiary); @@ -103,13 +97,13 @@ color: var(--color-foreground-secondary); display: flex; justify-content: space-between; - margin-top: var(--sp-xs); - max-width: px2rem(1000); + margin-block-start: var(--sp-xs); + max-inline-size: px2rem(1000); padding: var(--sp-m) var(--sp-xxl); - width: 100%; + inline-size: 100%; - input { - @include t.use-typography("body-medium"); + .name-input { + @include use-typography("body-medium"); @include text-ellipsis; border: $b-1 solid transparent; @@ -129,8 +123,8 @@ > .family { @include two-line-text-ellipsis; - min-width: $sz-200; - width: $sz-200; + min-inline-size: $sz-200; + inline-size: $sz-200; &.is-edition { overflow: visible; @@ -140,16 +134,16 @@ > .filenames { @include text-ellipsis; - min-width: $sz-200; + min-inline-size: $sz-200; } > .variants { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: flex; flex-wrap: wrap; flex-grow: 1; - padding-left: var(--sp-l); + padding-inline-start: var(--sp-l); gap: $sz-6; .variant { @@ -160,25 +154,11 @@ cursor: pointer; gap: var(--sp-xs); - .icon { - display: flex; - align-items: center; - justify-content: center; - height: $sz-16; - width: $sz-16; - - svg { - fill: none; - width: $sz-12; - height: $sz-12; - transform: rotate(45deg); - } - } + // The delete button only shows its icon while the variant is hovered. + --variant-delete-color: transparent; &:hover { - .icon svg { - stroke: var(--color-foreground-secondary); - } + --variant-delete-color: var(--color-foreground-secondary); } } @@ -197,79 +177,76 @@ } .filenames { - @include t.use-typography("body-small"); + @include use-typography("body-small"); @include text-ellipsis; - min-width: $sz-400; - padding-left: var(--sp-xxxl); + min-inline-size: $sz-400; + padding-inline-start: var(--sp-xxxl); } .options { display: flex; justify-content: flex-end; - min-width: px2rem(180); - - .icon { - width: $sz-24; - cursor: pointer; - display: flex; - margin-left: var(--sp-m); - justify-content: center; - align-items: center; - - svg { - width: $sz-16; - height: $sz-16; - stroke: var(--color-foreground-secondary); - fill: none; - } - - &.failure { - margin-right: var(--sp-m); - - svg { - stroke: var(--element-foreground-warning); - } - } - - &.close { - background: none; - border: none; - - svg { - stroke: var(--color-foreground-secondary); - } - } - } + min-inline-size: px2rem(180); + align-items: center; + gap: var(--sp-s); } } +.variant-delete { + display: flex; + align-items: center; + justify-content: center; + block-size: $sz-16; + inline-size: $sz-16; + padding: 0; + border: none; + background: none; + cursor: pointer; + color: var(--variant-delete-color); + + &:focus-visible { + --variant-delete-color: var(--color-foreground-secondary); + } +} + +// With the 8px gap of .options it keeps 12px on each side. +.warning-icon { + margin-inline: var(--sp-xs); + color: var(--color-accent-warning); +} + +// A DS button keeps its label at the start when it grows. +.upload-button { + min-inline-size: px2rem(100); + justify-content: center; +} + .dashboard-fonts-upload { - max-width: px2rem(1000); - width: 100%; + max-inline-size: px2rem(1000); + inline-size: 100%; display: flex; flex-direction: column; - .upload-button { - width: px2rem(100); - } - - .btn-secondary { - margin-left: var(--sp-m); + .dismiss-button { + margin-inline-start: var(--sp-xs); } } .dashboard-fonts-hero { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); padding: var(--sp-xxxl) 0; - margin-top: px2rem(80); + margin-block-start: px2rem(80); display: flex; justify-content: space-between; - .btn-primary { - height: $sz-40; - width: 100%; + .add-font-button { + --button-height: #{$sz-40}; + + inline-size: 100%; + flex-shrink: 0; + justify-content: center; } .desc { @@ -277,23 +254,18 @@ flex-direction: column; gap: var(--sp-xxl); color: var(--color-background-secondary); - width: $sz-500; + inline-size: $sz-500; - h2 { + .hero-title { color: var(--color-foreground-primary); - font-weight: 400; } - p { - @include t.use-typography("body-large"); + .hero-text { + @include use-typography("body-large"); color: var(--color-foreground-secondary); } } - - .btn-primary { - flex-shrink: 0; - } } .fonts-placeholder { @@ -302,21 +274,18 @@ border: $b-1 solid var(--color-background-quaternary); display: flex; flex-direction: column; - height: $sz-160; + block-size: $sz-160; justify-content: center; - margin-top: var(--sp-l); - max-width: px2rem(1000); - width: 100%; + margin-block-start: var(--sp-l); + max-inline-size: px2rem(1000); + inline-size: 100%; - .icon svg { - stroke: var(--color-foreground-secondary); - fill: none; - width: $sz-32; - height: $sz-32; + .placeholder-icon { + color: var(--color-foreground-secondary); } .label { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 59708eef1a..4dc9408889 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -13,7 +13,6 @@ ;; Keep the list of icons sorted (def ^:icon icon-verify (icon-xref :icon-verify)) -(def ^:icon loader (icon-xref :loader)) (def ^:icon logo (icon-xref :penpot-logo)) (def ^:icon logo-icon (icon-xref :penpot-logo-icon)) (def ^:icon logo-error-screen (icon-xref :logo-error-screen)) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 3fa9a763f3..09b305cc19 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -579,6 +579,10 @@ msgstr "Grid view" msgid "dashboard.files-layout.list" msgstr "List view" +#: src/app/main/ui/dashboard/fonts.cljs:431 +msgid "dashboard.fonts.delete-variant" +msgstr "Delete font style" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:608 msgid "dashboard.fonts.deleted-placeholder" msgstr "Missing font" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 431886f0d1..94ac75a0a6 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -587,6 +587,10 @@ msgstr "Vista de cuadrícula" msgid "dashboard.files-layout.list" msgstr "Vista de lista" +#: src/app/main/ui/dashboard/fonts.cljs:431 +msgid "dashboard.fonts.delete-variant" +msgstr "Eliminar estilo de fuente" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:608 msgid "dashboard.fonts.deleted-placeholder" msgstr "Fuente no encontrada" From e20a0d748eb9ae431c27a4a201ee9f7067d955e8 Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Wed, 28 Oct 2026 18:40:04 +0100 Subject: [PATCH 29/65] :sparkles: Add OAuth support for MCP server (#12480) * :tada: Add OAuth support for remote MCP connections Add MCP-specific discovery, client registration, and approval endpoints. Exchange single-use authorization codes with PKCE for the fixed MCP user token, enabling MCP and creating the token on approval when needed. Use self-contained client IDs without new database tables or stored client records. Keep temporary authorization state in Redis/Valkey. Accept bearer credentials while preserving query-parameter connections. Enable OAuth through PENPOT_MCP_AUTH_URI in multi-user mode. Document the environment variable in the README and implementation details in Serena. Explain protocol and concurrency decisions in the Clojure code. Cover token provisioning, OAuth state, client registration, HTTP flows, and the approval page with tests. AI-assisted-by: gpt-6 * :bug: Wait for devenv initialization before starting builds Docker reports a container as Running as soon as its main process starts, while the entrypoint may still be preparing the development environment. manage.sh used that status to launch tmux jobs, allowing frontend builds to race with ownership changes to /opt/rustup and Cargo setup. On a fresh container, rustup could fail with Permission denied before the frontend watcher started. Nginx would later serve the existing JavaScript, leaving source changes absent from the running app despite restarting devenv. Wait for nginx's local health endpoint before launching the jobs. Nginx starts after entrypoint setup, so its response confirms that setup has finished. Allow up to 120 seconds and report a timeout without starting the jobs if initialization does not complete. Validation: shell syntax and whitespace checks pass. AI-assisted-by: gpt-6 * :recycle: Share the login modal with MCP authorization Extract the viewer login modal and its styles into a shared auth component, retaining the viewer wrapper and its close behavior. Use the modal on the MCP approval page and preserve the pending request through login. Closing it clears the saved return URL and shows a cancellation message without further actions. Cover return URL handling and cancellation with frontend tests. AI-assisted-by: gpt-6 * :sparkles: Show client icons in MCP authorization Preserve HTTPS logo URLs from client registration and published metadata and show the icon beside the application name on the approval page. Ignore unusable URLs and hide failed images without blocking approval. Update the access description to refer to MCP connections established from Penpot. Cover logo metadata and approval responses in backend tests. AI-assisted-by: gpt-6 * :bug: Follow the system theme when no preference is configured Default to the system theme in the shared resolver used by the app and plugins. Preserve explicit light and dark choices, including the legacy default value that means dark. Reuse the reactive theme hook in progress widgets and show the system preference in settings for accounts without a configured theme. Verify preference resolution with regression tests and browser checks. All 1,474 frontend tests pass. AI-assisted-by: gpt-6 * :bug: Replace expired MCP tokens during OAuth approval Settings can give an MCP token an expiry date. OAuth approval reused the newest MCP token without checking it, so an expired token reached the client and never matched a plugin connection. Approval now treats an expired token as missing and creates a new one. Token exchange rejects a code whose token expired after approval with invalid_grant. AI-assisted-by: claude-opus-5-5 * :bug: Log unexpected errors on MCP OAuth endpoints The OAuth routes sit outside the shared HTTP middleware, so the shared error handler never saw them, and their own handler turned unexpected failures into a 503 without logging anything. Failures in the session middleware of the approval endpoints had no handler at all. Unexpected failures now also go through the shared error handler for its logging, while clients still get an OAuth error body. The OAuth error handler also wraps the session middleware of the approval endpoints. AI-assisted-by: claude-opus-5-5 * :wrench: Add devenv script to run the MCP server with OAuth start-mcp-devenv-oauth starts the MCP server in multi-user mode with PENPOT_MCP_AUTH_URI derived from PENPOT_PUBLIC_URI, to test OAuth in the devenv. Agentic tooling still uses the single-user start-mcp-devenv. AI-assisted-by: claude-opus-5-5 * :bug: Fix failing test case --------- Co-authored-by: alonso.torres --- .serena/memories/mcp/core.md | 27 +- .serena/memories/mcp/oauth.md | 47 +++ backend/src/app/http.clj | 7 +- backend/src/app/http/mcp_oauth.clj | 353 ++++++++++++++++++ backend/src/app/main.clj | 9 + backend/src/app/mcp/oauth_clients.clj | 220 +++++++++++ backend/src/app/mcp/oauth_rate.lua | 5 + backend/src/app/mcp/oauth_state.clj | 105 ++++++ backend/src/app/mcp/oauth_state.lua | 11 + backend/src/app/rpc/commands/access_token.clj | 27 ++ .../backend_tests/mcp_oauth_clients_test.clj | 219 +++++++++++ .../backend_tests/mcp_oauth_http_test.clj | 253 +++++++++++++ .../backend_tests/mcp_oauth_state_test.clj | 53 +++ .../backend_tests/rpc_access_tokens_test.clj | 38 ++ .../test/backend_tests/rpc_binfile_test.clj | 16 +- docker/devenv/files/nginx.conf | 5 + docker/images/files/nginx.conf.template | 5 + .../ui/specs/email-verification.spec.js | 37 +- .../playwright/ui/specs/workspace.spec.js | 2 + frontend/src/app/main/ui.cljs | 4 + frontend/src/app/main/ui/auth/login.cljs | 5 +- .../src/app/main/ui/auth/login_modal.cljs | 146 ++++++++ .../login.scss => auth/login_modal.scss} | 0 .../src/app/main/ui/components/progress.cljs | 8 +- frontend/src/app/main/ui/exports/assets.cljs | 8 +- frontend/src/app/main/ui/mcp_authorize.cljs | 162 ++++++++ frontend/src/app/main/ui/mcp_authorize.scss | 61 +++ frontend/src/app/main/ui/routes.cljs | 3 +- .../src/app/main/ui/settings/options.cljs | 2 +- frontend/src/app/main/ui/viewer/login.cljs | 131 +------ frontend/src/app/util/theme.cljs | 28 +- frontend/test/frontend_tests/runner.cljs | 4 + .../frontend_tests/ui/mcp_authorize_test.cljs | 88 +++++ .../test/frontend_tests/util_theme_test.cljs | 23 ++ frontend/translations/en.po | 24 ++ frontend/translations/es.po | 6 + manage.sh | 11 +- mcp/README.md | 1 + .../server/src/McpAuthentication.test.ts | 81 ++++ mcp/packages/server/src/McpAuthentication.ts | 120 ++++++ .../server/src/PenpotMcpServer.http.test.ts | 34 +- mcp/packages/server/src/PenpotMcpServer.ts | 15 +- mcp/scripts/start-mcp-devenv-oauth | 19 + 43 files changed, 2227 insertions(+), 196 deletions(-) create mode 100644 .serena/memories/mcp/oauth.md create mode 100644 backend/src/app/http/mcp_oauth.clj create mode 100644 backend/src/app/mcp/oauth_clients.clj create mode 100644 backend/src/app/mcp/oauth_rate.lua create mode 100644 backend/src/app/mcp/oauth_state.clj create mode 100644 backend/src/app/mcp/oauth_state.lua create mode 100644 backend/test/backend_tests/mcp_oauth_clients_test.clj create mode 100644 backend/test/backend_tests/mcp_oauth_http_test.clj create mode 100644 backend/test/backend_tests/mcp_oauth_state_test.clj create mode 100644 frontend/src/app/main/ui/auth/login_modal.cljs rename frontend/src/app/main/ui/{viewer/login.scss => auth/login_modal.scss} (100%) create mode 100644 frontend/src/app/main/ui/mcp_authorize.cljs create mode 100644 frontend/src/app/main/ui/mcp_authorize.scss create mode 100644 frontend/test/frontend_tests/ui/mcp_authorize_test.cljs create mode 100644 frontend/test/frontend_tests/util_theme_test.cljs create mode 100644 mcp/packages/server/src/McpAuthentication.test.ts create mode 100644 mcp/packages/server/src/McpAuthentication.ts create mode 100755 mcp/scripts/start-mcp-devenv-oauth diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md index 04bfef910e..551ef961c6 100644 --- a/.serena/memories/mcp/core.md +++ b/.serena/memories/mcp/core.md @@ -13,19 +13,26 @@ enabling advanced AI-driven features in Penpot. - Build Tool: TypeScript Compiler (tsc) + esbuild - Package Manager: pnpm -## General Principles +## Principles & Code Style IMPORTANT: Use an idiomatic, object-oriented style. -In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions -rather than mere functions (i.e. use the strategy pattern, for example). +* You keep each concern in exactly one home. + A mechanism whose parts are only correct in combination is implemented as a single component/class, and its parts are private to it; + expose a minimal public surface. Java-like encapsulation: If helper functions and constants are only used by one abstraction, + make them internal to the respective class. +* For any non-trivial interfaces, you use functions/methods that expect explicitly typed abstractions + rather than mere functions (i.e. use the strategy pattern, for example). +* Prefer declaring and instantiating concrete classes over defining only interfaces and using object literals. Comments: -When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase -clearly defines *what* it is. Any details then follow in subsequent sentences. +* When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase + clearly defines *what* it is. Any details then follow in subsequent sentences. +* When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless + the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is + required for sentences). -When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless -the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is -required for sentences). +Syntax: +* Never use if blocks (or other blocks) without braces, even for single-line cases. ## Project Structure (Excerpt) @@ -102,6 +109,10 @@ From the `mcp/` directory, run stdout directly. `NODE_TEST_CONTEXT` is the env var `node --test` sets. * Cross-cutting testing principles and anti-patterns: `mem:testing`. +## OAuth for remote MCP clients + +- Authorization/discovery URLs, self-contained client IDs, consent and PKCE/code binding, backend Redis state, deployment wiring, and compatibility limits: `mem:mcp/oauth`. + ## Devenv plugin/server wiring In the normal Penpot devenv MCP path, the browser plugin does not discover or route through Postgres. The frontend provides the plugin extension API with `mcp.getServerUrl()`, currently derived from `frontend/src/app/config.cljs` as `penpotMcpServerURI` if set, otherwise `/mcp/ws`. The MCP plugin opens a direct WebSocket to that URL and appends the current MCP access token as a query parameter. diff --git a/.serena/memories/mcp/oauth.md b/.serena/memories/mcp/oauth.md new file mode 100644 index 0000000000..097bdc1186 --- /dev/null +++ b/.serena/memories/mcp/oauth.md @@ -0,0 +1,47 @@ +# MCP OAuth + +## Contract and scope + +- MCP-specific authorization; existing Penpot login identifies the user on the approval page. No application vetting or persistent consent policy. Access requires user approval; registration alone grants nothing. +- Return the existing fixed MCP user token. On approval, enable `:mcp-enabled` and create the token only if no unexpired one exists; preserve an existing unexpired token. Settings can give MCP tokens an expiry, and an expired one is replaced. No refresh token, `expires_in`, or new token lifetime. +- No new client database table or schema migration. Client IDs must remain usable across separate authorization flows without server-side registration storage. Do not replace them with expiring Redis registrations. +- MCP transport continues matching user tokens to connected Penpot plugins; no new per-request token introspection or independent semantic validation. The logged-in Penpot instance remains the content-access boundary. Do not silently add resource-server validation or change token semantics. +- README scope: user/developer usage instructions; document `PENPOT_MCP_AUTH_URI` in its environment-variable table. Keep OAuth architecture and protocol details here and non-obvious invariants in source comments. + +## Deployment and discovery + +- MCP process: `PENPOT_MCP_AUTH_URI=https://penpot.example/api/mcp/oauth` with `--multi-user`. Unset/empty disables OAuth; configured OAuth requires multi-user. Absolute HTTPS, or HTTP on loopback for development; no userinfo, query, or fragment. Invalid configuration fails startup; startup logs OAuth status. +- Devenv: `mcp/scripts/start-mcp-devenv-oauth` starts multi-user mode with `PENPOT_MCP_AUTH_URI` derived from `PENPOT_PUBLIC_URI`. Manual opt-in only: agentic tooling needs the single-user `start-mcp-devenv`. +- Public MCP transport: `/mcp/stream`, proxied to Node `/mcp`. Missing credentials with OAuth enabled: 401 plus `WWW-Authenticate` referencing `/resource` and scope `mcp`. +- Bearer header takes precedence over legacy `?userToken=`. Malformed headers do not fall back to the query token. Existing query-token clients remain supported. +- Protected-resource metadata: `/api/mcp/oauth/resource`; issuer: `/api/mcp/oauth`; authorization-server metadata: `/.well-known/oauth-authorization-server/api/mcp/oauth`. All reach the backend. Bundled devenv/production nginx configs forward the well-known path; custom proxies must do likewise. Keep Penpot public URI and external URLs consistent. +- Node implementation: `mcp/packages/server/src/McpAuthentication.ts`, integrated by `PenpotMcpServer.ts`; OAuth-enabled tools advertise `_meta.securitySchemes`. + +## Client identification + +- `backend/src/app/mcp/oauth_clients.clj`: dynamic registration at `/api/mcp/oauth/register`, HTTPS client metadata documents (CIMD), and registrations obtained in advance via `(app.mcp.oauth-clients/register! {} metadata)`. +- DCR returns `penpot-mcp-v1.` plus unpadded base64url JSON: normalized callback URLs, optional name, authentication method, random nonce, optional secret hash. Fixed grant/response/scope values are reconstructed. Nonce distinguishes registrations with otherwise identical metadata. +- IDs are public, unsigned, unencrypted, non-expiring metadata; validate on every resolution. Client secrets are generated randomly and returned separately; only the hash enters the ID. No signing/encryption key or client record in PostgreSQL/Redis. Old experimental database-backed UUID IDs require registration again. +- An edited payload changes the ID. Authorization-code exchange compares the exact ID string against the approved request before resolving metadata. Do not replace this with comparison of decoded fields; code ownership and PKCE binding must survive metadata edits. +- Supported authentication: `none`, `client_secret_basic`, `client_secret_post`; no `private_key_jwt`. CIMD selects public-client `none` from the document's supported methods; other listed methods do not imply support here. Scope `mcp`; grant `authorization_code`; response `code`. CIMD may advertise additional grants (e.g. Codex lists `refresh_token`); require `authorization_code` support and normalize to the grant Penpot implements, rather than reject broader client capabilities. +- Entire ID capped at 2,048 ASCII characters. DCR returns `invalid_client_metadata` if encoded metadata exceeds this; shorter/fewer callback URLs reduce size. OAuth defines no fixed ID-length maximum (RFC 6749 §2.2); stateless IDs are explicitly described by RFC 7591 §A.5.2. Installed MCP client SDK 2.3.1 accepts a 2,048-character ID; this does not establish Codex/Claude compatibility. +- Redirect matching is exact except loopback HTTP port changes for native clients (`127.0.0.1`, `[::1]`, exact `localhost`). Host, path and query must still match; reject fragments/userinfo and lookalike localhost domains. Allow HTTPS, loopback HTTP, and reverse-domain native schemes. +- CIMD: fetch with existing SSRF-checked `app.http.client/req`, no redirect following, require matching document `client_id`, bounded 64-KiB body and request/body-read timeouts. Cache max 256 entries/one minute, further limited by HTTP max-age; no-store/no-cache/no max-age means no reuse. + +## Approval and exchange + +- `backend/src/app/http/mcp_oauth.clj`: separate router branch from ordinary RPC middleware; preserve OAuth snake_case JSON and form encoding. Session auth applies to approval request/decision only. Public metadata/register/token endpoints allow noncredentialed CORS; MCP transport CORS was not added. +- Errors: every failure answers with an OAuth `{"error": ...}` body, never the shared Penpot error shape. Failures without `:oauth-error` are also passed to `app.http.errors/handle` for logging only; its response is discarded. `wrap-errors` also wraps the session middleware of `/request` and `/decision`. Safe to log because these routes never set request `:params`. +- `/authorize`: validate client/callback, `code`, PKCE S256, scope/resource/state; store pending request for 600 seconds; redirect to `?screen=mcp-authorize&request=`. +- `frontend/src/app/main/ui/mcp_authorize.cljs`: existing login with return URL; show application and account details; submit explicit Allow/Cancel. No fixed MCP token in frontend state or browser redirect. Approval refreshes profile and broadcasts MCP enablement; does not connect a plugin automatically. +- `/request`: first authenticated read binds request to profile plus hashed login-session ID and CSRF value, using compare-and-replace. Re-read after the attempt: concurrent GETs may win/lose binding; only the matching session may proceed. +- `/decision`: check exact Origin, session binding and CSRF, then atomically consume the request. Denial creates no token/enables nothing. Approval calls transactional `access-token/ensure-mcp-access!`, then stores code for 120 seconds. Code state holds token row ID, never the fixed token. Callback echoes OAuth `state` and RFC 9207 `iss`. +- `/token`: validate grant, exact client ID/callback/resource and PKCE; resolve current client metadata/authentication method and verify any secret; atomically consume validated code; fetch the unexpired token by original row ID/profile/type. Regeneration deletes the old token row, so a pending code cannot silently yield the replacement token. Return fixed `access_token`, `token_type: Bearer`, scope `mcp`. +- `backend/src/app/mcp/oauth_state.clj` and Lua: tenant/kind/hash-prefixed keys; random handles; SET NX EX must succeed before returning handle. Reads retain raw JSON for atomic comparison. Consume/delete and bind/replace compare the validated raw record in Lua; replacement preserves TTL. Redis failures fail closed. Backend Redis is required independently of Node's optional Redis plugin bridge. +- `backend/src/app/rpc/commands/access_token.clj`: lock profile row in both MCP generation and OAuth provisioning, including when no token exists. Serializes simultaneous approvals and settings regeneration. Provisioning must run within a transaction. + +## Verification and limits + +- Tests: `mcp_oauth_clients_test.clj`, `mcp_oauth_http_test.clj`, `mcp_oauth_state_test.clj`, OAuth provisioning in `rpc_access_tokens_test.clj`; MCP authentication/HTTP tests; frontend approval tests. Cover no-storage registration, size boundaries, edited-ID rejection, code replay/concurrency, session/CSRF binding, and fixed-token reuse. +- Live OpenAI/Codex/Claude compatibility and marketplace acceptance remain unverified. In particular, verify token persistence without `expires_in` and acceptance of the existing matching-only resource boundary; do not claim protocol conformance from SDK tests alone. +- Protocol references: https://www.rfc-editor.org/rfc/rfc6749.html#section-2.2 ; https://www.rfc-editor.org/rfc/rfc7591.html#appendix-A.5.2 ; https://www.rfc-editor.org/rfc/rfc7636.html ; https://www.rfc-editor.org/rfc/rfc9207.html . diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index aa6950fdeb..83af60c3d2 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -19,6 +19,7 @@ [app.http.errors :as errors] [app.http.link-preview :as-alias link-preview] [app.http.management :as mgmt] + [app.http.mcp-oauth :as-alias mcp-oauth] [app.http.middleware :as mw] [app.http.security :as sec] [app.http.session :as session] @@ -236,6 +237,7 @@ [::ws/routes schema:routes] [::rpc/routes schema:routes] [::oidc/routes schema:routes] + [::mcp-oauth/routes schema:routes] [::assets/routes schema:routes] [::link-preview/routes schema:routes] [::debug/routes schema:routes] @@ -278,4 +280,7 @@ (::ws/routes cfg) (::oidc/routes cfg) - (::rpc/routes cfg)]])) + (::rpc/routes cfg)] + ;; OAuth owns form parsing, protocol JSON names and cross-origin handling; + ;; it must not inherit the RPC transformations and session middleware above. + (::mcp-oauth/routes cfg)])) diff --git a/backend/src/app/http/mcp_oauth.clj b/backend/src/app/http/mcp_oauth.clj new file mode 100644 index 0000000000..5184cc7e65 --- /dev/null +++ b/backend/src/app/http/mcp_oauth.clj @@ -0,0 +1,353 @@ +;; 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 SUBSIDIARY SL + +(ns app.http.mcp-oauth + "MCP-specific OAuth authorization server. + + The authorize endpoint stages a request; the existing Penpot session identifies + the user on the approval page. Approval creates a one-use code, and token + exchange returns the existing fixed MCP user token after verifying PKCE. + Client registration, login sessions, and authorization codes are distinct." + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.http.errors :as errors] + [app.http.middleware :as mw] + [app.http.session :as session] + [app.mcp.oauth-clients :as clients] + [app.mcp.oauth-state :as state] + [app.rpc.commands.access-token :as access-token] + [app.util.json :as json] + [clojure.string :as str] + [integrant.core :as ig] + [yetti.request :as yreq] + [yetti.response :as yres]) + (:import + java.io.InputStream + java.net.URI + java.net.URLDecoder + java.net.URLEncoder + java.nio.charset.StandardCharsets + java.util.Base64)) + +(defn issuer [] (str (cf/get-public-uri "api/mcp/oauth"))) +(defn resource [] (str (cf/get-public-uri "mcp/stream"))) + +(defn authorization-metadata + "OAuth capabilities and endpoints advertised to MCP clients." + [] + {:issuer (issuer) + :authorization_endpoint (str (issuer) "/authorize") + :token_endpoint (str (issuer) "/token") + :registration_endpoint (str (issuer) "/register") + :response_types_supported ["code"] + :grant_types_supported ["authorization_code"] + :code_challenge_methods_supported ["S256"] + :token_endpoint_auth_methods_supported ["none" "client_secret_basic" "client_secret_post"] + :scopes_supported ["mcp"] + :authorization_response_iss_parameter_supported true + :client_id_metadata_document_supported true}) + +(defn resource-metadata + "Protected-resource metadata referenced by the MCP server's 401 challenge." + [] + {:resource (resource) + :authorization_servers [(issuer)] + :scopes_supported ["mcp"] + :bearer_methods_supported ["header"] + :resource_name "Penpot MCP"}) + +(defn- response + ([body] (response 200 body)) + ([status body] + {::yres/status status + ::yres/headers {"content-type" "application/json" + "cache-control" "no-store" + "pragma" "no-cache" + "referrer-policy" "no-referrer"} + ::yres/body (json/encode-str body)})) + +(defn- redirect + [url] + {::yres/status 303 + ::yres/headers {"location" url "cache-control" "no-store" + "referrer-policy" "no-referrer"} + ::yres/body ""}) + +(defn- encode [^String value] (URLEncoder/encode value StandardCharsets/UTF_8)) +(defn- decode + [^String value] + (try (URLDecoder/decode value StandardCharsets/UTF_8) + (catch IllegalArgumentException _ (clients/invalid! "invalid_request")))) + +(defn- parse-form + "Bounded OAuth form/query parser preserving protocol parameter names. + Rejects duplicate keys so validation never depends on which value wins." + [query] + (when (> (count query) 16384) (clients/invalid! "invalid_request")) + (reduce (fn [result part] + (let [[key value] (str/split part #"=" 2) + key (keyword (decode key))] + (when (contains? result key) (clients/invalid! "invalid_request")) + (assoc result key (decode (or value ""))))) + {} + (remove str/blank? (str/split (or query "") #"&")))) + +(defn- read-body + [request content-type] + (when-not (str/starts-with? (or (yreq/get-header request "content-type") "") content-type) + (clients/invalid! "invalid_request")) + (with-open [^InputStream body (yreq/body request)] + (when-not body (clients/invalid! "invalid_request")) + (let [bytes (.readNBytes body 16385)] + (when (> (alength bytes) 16384) (clients/invalid! "invalid_request")) + (String. bytes StandardCharsets/UTF_8)))) + +(defn- append-query + [url params] + (str url (if (str/includes? url "?") "&" "?") + (str/join "&" (for [[key value] params :when (some? value)] + (str (encode (name key)) "=" (encode value)))))) + +(defn- limit! + [cfg request kind limit] + (when-not (state/allow-request! cfg kind (str (yreq/remote-addr request)) limit) + (clients/invalid! "temporarily_unavailable" 429))) + +(defn- authorize + "Validate and stage an authorization request before user login or consent. + The browser receives only an opaque handle to the ten-minute Redis record." + [cfg request] + (limit! cfg request :authorize-rate 60) + (let [params (parse-form (yreq/query request)) + client (clients/resolve-client cfg (:client_id params)) + callback (:redirect_uri params)] + ;; Never redirect until the client and its callback have been checked. + (when-not (clients/redirect-matches? client callback) + (clients/invalid! "invalid_request")) + (when-not (and (= "code" (:response_type params)) + (= "S256" (:code_challenge_method params)) + (string? (:code_challenge params)) + (re-matches #"[A-Za-z0-9_-]{43}" (:code_challenge params)) + (= "mcp" (get params :scope "mcp")) + (= (resource) (get params :resource (resource))) + (<= (count (:state params)) 2048)) + (clients/invalid! "invalid_request")) + (let [handle (state/create! cfg :request + {:client-id (:client_id client) + :client-name (:client_name client) + :logo-uri (:logo_uri client) + :redirect-uri callback + :challenge (:code_challenge params) + :state (:state params) + :resource (resource) + :scope "mcp"} + 600)] + (redirect (append-query (str (cf/get-public-uri)) + {:screen "mcp-authorize" :request handle}))))) + +(defn- session-binding + "Profile and login-session identity used to bind one approval request." + [request] + (let [session (session/get-session request)] + (when-not (:profile-id session) (clients/invalid! "login_required" 401)) + {:profile-id (str (:profile-id session)) + :session-id (state/digest (str (:id session)))})) + +(defn- approval-request + "Bind a pending request to its first authenticated session and return UI data. + Compare-and-replace prevents another session from taking over that request." + [cfg request] + (let [binding (session-binding request) + handle (:request (parse-form (yreq/query request))) + record (state/read! cfg :request handle)] + (when-not record (clients/invalid! "invalid_request")) + (let [value (:value record)] + (if (:session-id value) + (when-not (= binding (select-keys value [:profile-id :session-id])) + (clients/invalid! "access_denied" 403)) + (state/replace! cfg :request handle record + (merge value binding {:csrf (state/random-secret)})))) + ;; Another GET may have won the binding race. Read the winning record so + ;; same-session retries share its CSRF value; other sessions cannot proceed. + (let [value (:value (state/read! cfg :request handle))] + (when-not (= binding (select-keys value [:profile-id :session-id])) + (clients/invalid! "invalid_request")) + (response (select-keys value [:client-id :client-name :logo-uri :csrf :scope]))))) + +(defn- same-origin? + [request] + (let [public (URI. (str (cf/get-public-uri))) + origin (str (.getScheme public) "://" (.getRawAuthority public))] + (= origin (yreq/get-header request "origin")))) + +(defn- decision + "Accept one session-bound approval/denial and return the client callback. + Origin and CSRF checks protect the cookie-authenticated decision endpoint." + [cfg request] + (when-not (same-origin? request) (clients/invalid! "access_denied" 403)) + (let [binding (session-binding request) + params (parse-form (read-body request "application/x-www-form-urlencoded")) + handle (:request params) + record (state/read! cfg :request handle) + value (:value record)] + (when-not (and record + (= binding (select-keys value [:profile-id :session-id])) + (state/secret= (:csrf value) (:csrf params)) + (contains? #{"approve" "deny"} (:decision params))) + (clients/invalid! "invalid_request")) + ;; Consume before provisioning: concurrent clicks must not issue two codes. + ;; A later failure requires a fresh authorization attempt. + (when-not (state/consume! cfg :request handle record) + (clients/invalid! "invalid_request")) + (let [result (if (= "deny" (:decision params)) + {:error "access_denied"} + (let [token (db/tx-run! cfg access-token/ensure-mcp-access! + (uuid/parse (:profile-id value))) + ;; Retain the token row ID, never the fixed token itself. + code (state/create! cfg :code + (-> value + (dissoc :csrf :session-id :client-name) + (assoc :token-id (str (:id token)))) + 120)] + {:code code}))] + (response {:redirect-uri (append-query (:redirect-uri value) + (merge result {:state (:state value) + :iss (issuer)}))})))) + +(defn- client-authentication + "Extract one OAuth client-authentication method, rejecting mixed credentials. + Basic credentials are form-decoded after splitting the decoded header." + [request params] + (let [header (yreq/get-header request "authorization")] + (if header + (do + (when (or (:client_secret params) (not (str/starts-with? header "Basic "))) + (clients/invalid! "invalid_client" 401)) + (let [decoded (try (String. (.decode (Base64/getDecoder) (subs header 6)) + StandardCharsets/UTF_8) + (catch IllegalArgumentException _ + (clients/invalid! "invalid_client" 401))) + [id secret] (str/split decoded #":" 2) + id (decode id)] + (when (or (nil? secret) + (and (:client_id params) (not= id (:client_id params)))) + (clients/invalid! "invalid_client" 401)) + {:id id :secret (decode secret) :method "client_secret_basic"})) + {:id (:client_id params) :secret (:client_secret params) + :method (if (:client_secret params) "client_secret_post" "none")}))) + +(defn- token + "Exchange a code for the fixed MCP token after client and PKCE verification." + [cfg request] + (limit! cfg request :token-rate 120) + (let [params (parse-form (read-body request "application/x-www-form-urlencoded")) + auth (client-authentication request params) + record (state/read! cfg :code (:code params)) + value (:value record)] + (when-not (= "authorization_code" (:grant_type params)) + (clients/invalid! "unsupported_grant_type")) + ;; Compare the exact client ID, not decoded metadata: a self-contained ID + ;; edited after approval identifies a different client for this exchange. + (when-not (and record + (= (:id auth) (:client-id value)) + (= (:redirect_uri params) (:redirect-uri value)) + (= (:resource value) (get params :resource (:resource value))) + (string? (:code_verifier params)) + (re-matches #"[A-Za-z0-9._~-]{43,128}" (:code_verifier params)) + (state/secret= (:challenge value) (state/digest (:code_verifier params)))) + (clients/invalid! "invalid_grant")) + ;; Resolve again because an HTTPS metadata document may have changed + ;; since approval; its current callback and authentication method apply. + (let [client (clients/resolve-client cfg (:id auth))] + (when-not (and (= (:method auth) (:token_endpoint_auth_method client)) + (clients/redirect-matches? client (:redirect-uri value)) + (or (= "none" (:method auth)) + (and (string? (:secret auth)) + (state/secret= (:secret-hash client) + (state/digest (:secret auth)))))) + (clients/invalid! "invalid_client" 401))) + ;; Validate before consuming so an invalid verifier/secret cannot burn the + ;; code. Atomic consumption allows only one valid exchange to succeed. + (when-not (state/consume! cfg :code (:code params) record) + (clients/invalid! "invalid_grant")) + ;; Regeneration deletes the old token row. Never substitute the profile's + ;; latest token for the one this approval authorized, nor return it expired. + (let [row (db/exec-one! (::db/pool cfg) + [(str "SELECT token FROM access_token" + " WHERE id = ? AND profile_id = ? AND type = 'mcp'" + " AND (expires_at IS NULL OR expires_at > now())") + (uuid/parse (:token-id value)) + (uuid/parse (:profile-id value))])] + (when-not row (clients/invalid! "invalid_grant")) + (response {:access_token (:token row) :token_type "Bearer" :scope "mcp"})))) + +(defn- register + "Issue a self-contained client registration; does not grant user access." + [cfg request] + (limit! cfg request :registration-rate 10) + (let [body (read-body request "application/json") + metadata (try (json/decode body) + (catch Exception _ (clients/invalid! "invalid_request")))] + (response 201 (clients/register! cfg metadata)))) + +(defn- wrap-errors + "Answer every failure with an OAuth error body. Unexpected failures also go + through the shared HTTP error handler, only for its logging; these routes + never set request params, so codes and credentials stay out of the log." + [handler] + (fn [request] + (try (handler request) + (catch Exception cause + (let [{:keys [oauth-error status]} (ex-data cause) + status (or status (if (and oauth-error (not= oauth-error "temporarily_unavailable")) 400 503))] + (when-not oauth-error + (errors/handle cause request)) + (cond-> (response status {:error (or oauth-error "temporarily_unavailable")}) + (and (= status 401) (= oauth-error "invalid_client")) + (assoc-in [::yres/headers "www-authenticate"] "Basic realm=\"Penpot MCP\""))))))) + +(defn- wrap-public + "CORS for discovery, registration and exchange without browser credentials. + Cookie-authenticated approval endpoints must not use this wrapper." + [handler] + (fn [request] + (let [response (if (= :options (yreq/method request)) + {::yres/status 204} + (handler request))] + (update response ::yres/headers merge + {"access-control-allow-origin" "*" + "access-control-allow-methods" "GET, POST, OPTIONS" + "access-control-allow-headers" "Content-Type, Authorization" + "access-control-expose-headers" "WWW-Authenticate"})))) + +(defn- endpoint + [cfg handler method public?] + {:handler (partial handler cfg) + :middleware (into (if public? [{:compile (constantly wrap-public)}] []) + [{:compile (constantly wrap-errors)} [mw/restrict-methods]]) + :allowed-methods (cond-> #{method} public? (conj :options))}) + +;; OAuth endpoints parse forms and emit snake_case JSON independently of RPC +;; middleware. Only the approval UI endpoints require a Penpot login session. +(defmethod ig/init-key ::routes + [_ cfg] + ["" + ["/.well-known/oauth-authorization-server/api/mcp/oauth" + (endpoint cfg (fn [_ _] (response (authorization-metadata))) :get true)] + ["/api/mcp/oauth" + ["/resource" (endpoint cfg (fn [_ _] (response (resource-metadata))) :get true)] + ["/authorize" (endpoint cfg authorize :get false)] + ["/register" (endpoint cfg register :post true)] + ["/token" (endpoint cfg token :post true)] + ;; Error handling wraps the session middleware too, so its failures also + ;; get OAuth error bodies and logging. + ["" {:middleware [{:compile (constantly wrap-errors)} + [mw/auth {:cookie (partial session/decode-token cfg)}] + [session/authz cfg]]} + ["/request" (endpoint cfg approval-request :get false)] + ["/decision" (endpoint cfg decision :post false)]]]]) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index f8d856fb76..9267c889f4 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -22,6 +22,7 @@ [app.http.debug :as-alias http.debug] [app.http.link-preview :as-alias http.link-preview] [app.http.management :as mgmt] + [app.http.mcp-oauth :as-alias http.mcp-oauth] [app.http.session :as session] [app.http.websocket :as http.ws] [app.jobs :as-alias jobs] @@ -540,9 +541,17 @@ ::http.debug/routes (ig/ref ::http.debug/routes) ::http.assets/routes (ig/ref ::http.assets/routes) ::http.link-preview/routes (ig/ref ::http.link-preview/routes) + ::http.mcp-oauth/routes (ig/ref ::http.mcp-oauth/routes) ::http.ws/routes (ig/ref ::http.ws/routes) ::http.awsns/routes (ig/ref ::http.awsns/routes)} + ::http.mcp-oauth/routes + {::db/pool (ig/ref ::db/pool) + ::rds/pool (ig/ref ::rds/pool) + ::setup/props (ig/ref ::setup/props) + ::session/manager (ig/ref ::session/manager) + ::http.client/client (ig/ref ::http.client/client)} + ::http.link-preview/routes {::db/pool (ig/ref ::db/pool)} diff --git a/backend/src/app/mcp/oauth_clients.clj b/backend/src/app/mcp/oauth_clients.clj new file mode 100644 index 0000000000..2e36fa6751 --- /dev/null +++ b/backend/src/app/mcp/oauth_clients.clj @@ -0,0 +1,220 @@ +;; 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 SUBSIDIARY SL + +(ns app.mcp.oauth-clients + "Client metadata from self-contained registrations or HTTPS documents. + Registration describes an application; it does not authorize a Penpot user. + Both sources resolve to the same metadata shape without a client database." + (:require + [app.http.client :as http] + [app.mcp.oauth-state :as state] + [app.util.cache :as cache] + [app.util.json :as json] + [clojure.string :as str]) + (:import + java.io.InputStream + java.net.URI + java.util.Base64)) + +(defn invalid! + ([error] (invalid! error 400)) + ([error status] + (throw (ex-info "MCP OAuth request rejected" {:oauth-error error :status status})))) + +(defn uri + [value] + (when (and (string? value) (<= 1 (count value) 2048)) + (try (URI. value) (catch Exception _ nil)))) + +(defn- loopback? + [^URI uri] + (and (= "http" (.getScheme uri)) + (contains? #{"127.0.0.1" "[::1]" "localhost"} (.getHost uri)))) + +(defn redirect-uri? + [value] + (when-let [uri (uri value)] + (and (nil? (.getFragment uri)) + (nil? (.getUserInfo uri)) + (or (and (= "https" (.getScheme uri)) (some? (.getHost uri))) + (loopback? uri) + ;; Native applications use a reverse-domain scheme. + (and (some? (.getScheme uri)) + (str/includes? (.getScheme uri) ".") + (not (.isOpaque uri))))))) + +(defn redirect-matches? + "Exact callback match, except for the port of loopback HTTP URLs. + Native applications may allocate a different local port for each login." + [registered requested] + (and (redirect-uri? requested) + (some (fn [value] + (or (= value requested) + (let [a (uri value) + b (uri requested)] + (and (loopback? a) (loopback? b) + (= (.getHost a) (.getHost b)) + (= (.getRawPath a) (.getRawPath b)) + (= (.getRawQuery a) (.getRawQuery b)))))) + (:redirect_uris registered)))) + +(defn- logo-uri? + [value] + (when-let [uri (uri value)] + (and (= "https" (.getScheme uri)) + (some? (.getHost uri)) + (nil? (.getUserInfo uri)) + (nil? (.getFragment uri))))) + +(defn validate-metadata + "Validate untrusted metadata and retain the fields this server supports. + CIMD selects public-client authentication from the advertised methods; + dynamic registration also supports Basic and form-post client secrets." + [metadata cimd?] + (let [redirects (:redirect_uris metadata) + grants (get metadata :grant_types ["authorization_code"]) + methods (if cimd? + (or (:token_endpoint_auth_methods_supported metadata) + [(get metadata :token_endpoint_auth_method "none")]) + [(get metadata :token_endpoint_auth_method "client_secret_basic")]) + method (if cimd? "none" (first methods))] + (when-not (and (map? metadata) + (vector? redirects) (<= 1 (count redirects) 20) + (every? redirect-uri? redirects) + (or (nil? (:client_name metadata)) + (and (string? (:client_name metadata)) + (<= 1 (count (:client_name metadata)) 200))) + ;; Published metadata describes client capabilities across + ;; servers. Extra grants do not require us to issue them. + (if cimd? + (and (vector? grants) (every? string? grants) + (some #{"authorization_code"} grants)) + (= ["authorization_code"] grants)) + (= ["code"] (get metadata :response_types ["code"])) + (or (nil? (:scope metadata)) (= "mcp" (:scope metadata))) + (if cimd? + (and (vector? methods) (some #{"none"} methods)) + (contains? #{"none" "client_secret_basic" "client_secret_post"} method))) + (invalid! "invalid_client_metadata")) + (cond-> {:redirect_uris redirects + :grant_types ["authorization_code"] + :response_types ["code"] + :scope "mcp" + :token_endpoint_auth_method method} + (:client_name metadata) (assoc :client_name (:client_name metadata)) + ;; Logos are optional browser-loaded images. Ignore unusable URLs + ;; instead of preventing authorization because of cosmetic metadata. + (logo-uri? (:logo_uri metadata)) (assoc :logo_uri (:logo_uri metadata))))) + +(defn register! + "Issue a reusable client ID and, when applicable, a separate client secret. + No client record is stored. The public ID carries only the secret hash; + the random nonce distinguishes registrations with otherwise equal metadata." + [_cfg metadata] + (let [metadata (validate-metadata metadata false) + secret (when-not (= "none" (:token_endpoint_auth_method metadata)) + (state/random-secret)) + ;; Omit fixed protocol values to keep IDs short; validation restores + ;; them on resolution. The version prefix defines the payload format. + payload (cond-> (-> metadata + (dissoc :grant_types :response_types :scope) + (assoc :nonce (state/random-secret))) + secret (assoc :secret-hash (state/digest secret))) + id (str "penpot-mcp-v1." + (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) + (json/encode payload)))] + ;; Enforce the resolver's size cap here rather than issue an unusable ID. + (when (> (count id) 2048) + (invalid! "invalid_client_metadata")) + (cond-> (assoc metadata :client_id id + :client_id_issued_at (quot (System/currentTimeMillis) 1000)) + secret (assoc :client_secret secret :client_secret_expires_at 0)))) + +(defn- decode-client-id + [client-id] + ;; Open registration accepts client-supplied metadata, not a trusted identity. + ;; Changing these details changes the ID; codes are bound to the exact ID + ;; approved by the user, so an edited ID cannot redeem an existing code. + (try + (let [[_ encoded] (re-matches #"penpot-mcp-v1\.([A-Za-z0-9_-]+)" client-id) + _ (when-not encoded (invalid! "invalid_client")) + payload (json/decode (.decode (Base64/getUrlDecoder) ^String encoded)) + metadata (validate-metadata payload false) + nonce (:nonce payload) + secret-hash (:secret-hash payload)] + (when-not (and (string? nonce) (re-matches #"[A-Za-z0-9_-]{43}" nonce) + (if (= "none" (:token_endpoint_auth_method metadata)) + (nil? secret-hash) + (and (string? secret-hash) + (re-matches #"[A-Za-z0-9_-]{43}" secret-hash)))) + (invalid! "invalid_client")) + (cond-> (assoc metadata :client_id client-id) + secret-hash (assoc :secret-hash secret-hash))) + (catch Exception _ + (invalid! "invalid_client")))) + +(defonce ^:private metadata-cache + (cache/create :expire "1m" :max-size 256)) + +(defn- fetch-metadata + "Fetch a client's own HTTPS metadata document with bounded size and freshness. + The shared HTTP client checks SSRF targets and does not follow redirects." + [cfg client-id] + (let [response (http/req cfg {:method :get :uri client-id :timeout 5000 + :headers {"accept" "application/json"}} + {:response-type :input-stream})] + (with-open [^InputStream body (:body response)] + (when-not (and (= 200 (:status response)) + (str/starts-with? (get-in response [:headers "content-type"] "") + "application/json")) + (invalid! "invalid_client")) + ;; Bound both bytes read and time spent receiving the response body. + (let [reader (future (.readNBytes body 65537)) + bytes (deref reader 5000 ::timeout)] + (when (= ::timeout bytes) + (future-cancel reader) + (invalid! "invalid_client")) + (when (> (alength ^bytes bytes) 65536) + (invalid! "invalid_client")) + (let [metadata (json/decode bytes) + control (get-in response [:headers "cache-control"] "") + ;; The local cache ceiling never extends the publisher's TTL. + ;; Missing cache lifetime is treated as immediately stale. + max-age (some-> (re-find #"(?i)max-age=(\d+)" control) second parse-long) + ttl (if (re-find #"(?i)no-store|no-cache" control) + 0 + (min 60 (or max-age 0)))] + (when-not (= client-id (:client_id metadata)) + (invalid! "invalid_client")) + {:client (assoc (validate-metadata metadata true) :client_id client-id) + :expires (+ (System/currentTimeMillis) (* ttl 1000))}))))) + +(defn resolve-client + "Resolve a client ID to validated metadata for authorization or exchange. + HTTPS IDs use the document cache; versioned IDs decode locally without + PostgreSQL or Redis. Neither form establishes user approval." + [cfg client-id] + (when-not (and (string? client-id) (<= 1 (count client-id) 2048)) + (invalid! "invalid_client")) + (if-let [url (when (str/starts-with? client-id "https://") (uri client-id))] + (do + (when-not (and (.getHost url) + (nil? (.getUserInfo url)) + (nil? (.getFragment url)) + (not (str/blank? (.getPath url)))) + (invalid! "invalid_client")) + (try + (let [cached (cache/get metadata-cache client-id) + entry (if (and cached (> (:expires cached) (System/currentTimeMillis))) + cached + (fetch-metadata cfg client-id))] + (when-not (identical? cached entry) + (cache/invalidate metadata-cache client-id) + (cache/get metadata-cache client-id (constantly entry))) + (:client entry)) + (catch Exception _ + (invalid! "invalid_client")))) + (decode-client-id client-id))) diff --git a/backend/src/app/mcp/oauth_rate.lua b/backend/src/app/mcp/oauth_rate.lua new file mode 100644 index 0000000000..0a8a168cfc --- /dev/null +++ b/backend/src/app/mcp/oauth_rate.lua @@ -0,0 +1,5 @@ +-- Copyright (c) KALEIDOS SUBSIDIARY SL +-- MPL-2.0 +local count = redis.call('INCR', KEYS[1]) +if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end +return {count} diff --git a/backend/src/app/mcp/oauth_state.clj b/backend/src/app/mcp/oauth_state.clj new file mode 100644 index 0000000000..6f27a3b05e --- /dev/null +++ b/backend/src/app/mcp/oauth_state.clj @@ -0,0 +1,105 @@ +;; 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 SUBSIDIARY SL + +(ns app.mcp.oauth-state + "Temporary approval requests and authorization codes in Redis/Valkey. + Handles are opaque; tenant, record kind and handle hash form the key. + Mutations compare the exact record that the HTTP handler validated." + (:require + [app.config :as cf] + [app.redis :as rds] + [app.redis.script :as-alias script] + [app.util.json :as json]) + (:import + java.nio.charset.StandardCharsets + java.security.MessageDigest + java.security.SecureRandom + java.util.Base64)) + +(defn random-secret + "32 random bytes encoded as a 43-character, unpadded base64url string." + [] + (let [bytes (byte-array 32)] + (.nextBytes (SecureRandom.) bytes) + (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) bytes))) + +(defn digest + "Unpadded base64url SHA-256 digest, also used for PKCE S256 challenges." + [^String value] + (-> (.digest (MessageDigest/getInstance "SHA-256") + (.getBytes value StandardCharsets/UTF_8)) + (#(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) %)))) + +(defn secret= + [a b] + (and (string? a) (string? b) + (MessageDigest/isEqual (.getBytes ^String a StandardCharsets/UTF_8) + (.getBytes ^String b StandardCharsets/UTF_8)))) + +(defn- state-key + [kind handle] + (str "penpot:" (cf/get :tenant) ":mcp-oauth:" (name kind) ":" (digest handle))) + +(defn create! + "Store a new record for ttl seconds and return its random handle. + The handle is returned only after Redis confirms SET NX EX succeeded." + [cfg kind value ttl] + (let [handle (random-secret) + result (rds/run! cfg + (fn [{::rds/keys [conn]}] + (rds/set conn (state-key kind handle) + (json/encode-str value) {:ex ttl :nx true})))] + (when-not (= "OK" result) + (throw (ex-info "Unable to store OAuth state" {:oauth-error "temporarily_unavailable"}))) + handle)) + +(defn read! + "Read an unexpired record, or nil for a missing/malformed handle. + Return both decoded :value for validation and original :raw for atomic + comparison; serializing the decoded map again is not an equivalent check." + [cfg kind handle] + (when (and (string? handle) (re-matches #"[A-Za-z0-9_-]{43}" handle)) + (when-let [raw (rds/run! cfg (fn [{::rds/keys [conn]}] + (rds/get conn (state-key kind handle))))] + {:raw raw :value (json/decode raw)}))) + +(defn- compare-and-update! + "Atomically change only the exact record the caller read and validated. + Lua deletes for nil replacement, or replaces while preserving the TTL. + False means the record expired or another request changed/consumed it." + [cfg kind handle record replacement] + (let [result (rds/run! cfg + (fn [{::rds/keys [conn]}] + (rds/eval conn + {::script/name ::compare-and-update + ::script/path "app/mcp/oauth_state.lua" + ::script/keys [(state-key kind handle)] + ::script/vals [(:raw record) (or replacement "")]})))] + (= [1] result))) + +(defn consume! + "Delete a validated record once; true only for the request that consumes it." + [cfg kind handle record] + (compare-and-update! cfg kind handle record nil)) + +(defn replace! + "Replace a validated record without extending its original expiration." + [cfg kind handle record value] + (compare-and-update! cfg kind handle record (json/encode-str value))) + +(defn allow-request! + "Limit requests per endpoint kind, tenant and remote address. + The one-minute window begins with the first request; subsequent requests + increment the count without extending that window." + [cfg kind remote-address limit] + (let [[count] (rds/run! cfg + (fn [{::rds/keys [conn]}] + (rds/eval conn + {::script/name ::rate + ::script/path "app/mcp/oauth_rate.lua" + ::script/keys [(state-key kind (or remote-address "unknown"))] + ::script/vals [60]})))] + (and (number? count) (<= count limit)))) diff --git a/backend/src/app/mcp/oauth_state.lua b/backend/src/app/mcp/oauth_state.lua new file mode 100644 index 0000000000..b7bcd02330 --- /dev/null +++ b/backend/src/app/mcp/oauth_state.lua @@ -0,0 +1,11 @@ +-- Copyright (c) KALEIDOS SUBSIDIARY SL +-- MPL-2.0 +if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return {0} +end +if ARGV[2] == '' then + redis.call('DEL', KEYS[1]) +else + redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL') +end +return {1} diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index 09aa9189c2..6681af3cd7 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -12,6 +12,7 @@ [app.db :as db] [app.main :as-alias main] [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] [app.rpc.quotes :as quotes] [app.setup :as-alias setup] @@ -28,12 +29,23 @@ AND id != ? AND type = 'mcp'") +(def ^:private sql:get-valid-mcp-token + "SELECT * FROM access_token + WHERE profile_id = ? + AND type = 'mcp' + AND (expires_at IS NULL OR expires_at > now()) + ORDER BY created_at DESC + LIMIT 1") + (defn create-access-token "Create an access token with empty perms. Elevated permissions (e.g. error-reports:read) are not assignable via the public API; grant them with SQL or `repl:grant-access-token-perm`." [{:keys [::db/conn] :as cfg} profile-id name expiration type] + ;; Serialize regeneration with OAuth provisioning for the same profile. + (when (= type "mcp") + (db/get-by-id conn :profile profile-id {::db/for-update true})) (let [token-id (uuid/next) expires-at (some-> expiration (ct/in-future)) created-at (ct/now) @@ -62,6 +74,21 @@ (decode-row token))) +(defn ensure-mcp-access! + "Enable MCP and return the profile's unexpired token, creating it if absent. + Must run in a transaction. Lock the profile even when no token exists, so + concurrent approvals and settings regeneration use the same lock. Enabling + MCP must not regenerate a token already held by a connected client; an + expired token is replaced because no client can use it." + [{:keys [::db/conn] :as cfg} profile-id] + (let [profile (profile/get-profile conn profile-id ::db/for-update true) + token (db/exec-one! conn [sql:get-valid-mcp-token profile-id])] + (when-not (:mcp-enabled (:props profile)) + (profile/update-profile-props cfg profile-id {:mcp-enabled true})) + (if token + (decode-row token) + (create-access-token cfg profile-id "MCP" nil "mcp")))) + (defn repl:create-access-token [cfg profile-id name expiration] (db/tx-run! cfg create-access-token profile-id name expiration)) diff --git a/backend/test/backend_tests/mcp_oauth_clients_test.clj b/backend/test/backend_tests/mcp_oauth_clients_test.clj new file mode 100644 index 0000000000..04db71a48b --- /dev/null +++ b/backend/test/backend_tests/mcp_oauth_clients_test.clj @@ -0,0 +1,219 @@ +;; 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 SUBSIDIARY SL + +(ns backend-tests.mcp-oauth-clients-test + (:require + [app.mcp.oauth-clients :as clients] + [app.mcp.oauth-state :as state] + [app.util.json :as json] + [backend-tests.helpers :as th] + [clojure.string :as str] + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) + +(t/deftest registered-clients + (let [registration (clients/register! {} + {:client_name "Example" + :redirect_uris ["https://client.example/callback"]}) + client (clients/resolve-client {} (:client_id registration))] + (t/is (string? (:client_secret registration))) + (t/is (nil? (:client_secret client))) + (t/is (state/secret= (:secret-hash client) (state/digest (:client_secret registration)))) + (t/is (clients/redirect-matches? client "https://client.example/callback")) + (doseq [redirect ["https://client.example/callback?extra=1" + "https://client.example/callback/../evil" + "https://evil.example/callback"]] + (t/is (not (clients/redirect-matches? client redirect)))))) + +(t/deftest registrations-are-self-contained-and-distinct + (doseq [method ["none" "client_secret_basic" "client_secret_post"]] + (let [metadata {:client_name "Example α" + :logo_uri "https://client.example/logo.png" + :redirect_uris ["https://client.example/callback?source=mcp&mode=oauth"] + :token_endpoint_auth_method method} + first (clients/register! {} metadata) + second (clients/register! {} metadata) + client (clients/resolve-client {} (:client_id first))] + (t/is (not= (:client_id first) (:client_id second))) + (t/is (<= (count (:client_id first)) 2048)) + (t/is (= metadata (select-keys client (keys metadata)))) + (t/is (= (:client_id first) (:client_id client))) + (t/is (nil? (:client_secret client))) + (if (= method "none") + (do (t/is (nil? (:client_secret first))) + (t/is (nil? (:secret-hash client)))) + (t/is (state/secret= (:secret-hash client) + (state/digest (:client_secret first)))))))) + +(t/deftest oversized-registration-is-rejected + (let [metadata {:redirect_uris [(str "https://client.example/" (apply str (repeat 1800 "a")))]}] + (t/is (= "invalid_client_metadata" + (try (clients/register! {} metadata) + (catch clojure.lang.ExceptionInfo cause (:oauth-error (ex-data cause)))))))) + +(t/deftest unusable-client-logos-are-ignored + ;; An optional image must not prevent a client from authorizing. + (doseq [logo [nil "" 42 "/logo.png" "http://client.example/logo.png" + "javascript:alert(1)" "data:image/svg+xml," + "https://user:secret@client.example/logo.png" + (str "https://client.example/" (apply str (repeat 2048 "a")))]] + (let [client (clients/validate-metadata + {:redirect_uris ["https://client.example/callback"] + :logo_uri logo} + false)] + (t/is (not (contains? client :logo_uri)))))) + +(t/deftest client-id-size-boundary + (let [callback "https://client.example/callback/" + metadata {:redirect_uris [callback] :token_endpoint_auth_method "none"} + registration (clients/register! {} metadata) + [_ encoded] (str/split (:client_id registration) #"\." 2) + payload-size (alength (.decode (java.util.Base64/getUrlDecoder) encoded)) + ;; 1,525 payload bytes encode to 2,034 characters plus the 14-char prefix. + padding (apply str (repeat (- 1525 payload-size) "a")) + metadata (assoc metadata :redirect_uris [(str callback padding)]) + largest (clients/register! {} metadata)] + (t/is (= 2048 (count (:client_id largest)))) + (t/is (= (:redirect_uris metadata) + (:redirect_uris (clients/resolve-client {} (:client_id largest))))) + (t/is (= "invalid_client_metadata" + (try (clients/register! {} (update-in metadata [:redirect_uris 0] str "a")) + (catch clojure.lang.ExceptionInfo cause (:oauth-error (ex-data cause)))))))) + +(t/deftest malformed-client-identifiers-are-rejected + (doseq [id ["unknown" "penpot-mcp-v2.e30" "penpot-mcp-v1.e30" + "penpot-mcp-v1.W10" "penpot-mcp-v1.%%" "penpot-mcp-v1.bm90LWpzb24" + (str "penpot-mcp-v1." (apply str (repeat 2048 "a")))]] + (t/is (= "invalid_client" + (try (clients/resolve-client {} id) + (catch clojure.lang.ExceptionInfo cause (:oauth-error (ex-data cause)))))))) + +(t/deftest client-metadata-validation + (doseq [redirect ["https://client.example/callback#fragment" "javascript:alert(1)" + "http://client.example/callback" "https://user:secret@client.example/cb"]] + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/validate-metadata {:redirect_uris [redirect]} false)))) + (t/is (clients/redirect-matches? {:redirect_uris ["http://127.0.0.1:3000/callback"]} + "http://127.0.0.1:12345/callback")) + (t/is (= "none" (:token_endpoint_auth_method + (clients/validate-metadata + {:redirect_uris ["https://chatgpt.com/connector_platform_oauth_redirect"] + :token_endpoint_auth_methods_supported ["none" "private_key_jwt"]} + true)))) + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/validate-metadata + {:redirect_uris ["https://example.com/cb"] + :grant_types ["authorization_code" "refresh_token"]} false)))) + +(t/deftest client-metadata-documents-can-advertise-additional-grants + (let [metadata {:redirect_uris ["https://client.example/callback"] + :grant_types ["authorization_code" "refresh_token"]}] + (t/is (= ["authorization_code"] + (:grant_types (clients/validate-metadata metadata true)))))) + +(t/deftest client-metadata-documents-require-authorization-code-support + (doseq [grants [[] ["refresh_token"] "authorization_code" + ["authorization_code" nil] ["authorization_code" 1]]] + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/validate-metadata + {:redirect_uris ["https://client.example/callback"] + :grant_types grants} + true))))) + +(t/deftest localhost-callbacks-allow-only-port-changes + (let [client {:redirect_uris ["http://localhost/callback"]}] + (t/is (clients/redirect-uri? "http://localhost/callback")) + (t/is (clients/redirect-matches? client "http://localhost:52351/callback")) + (doseq [redirect ["http://localhost.evil.example:52351/callback" + "http://127.0.0.1:52351/callback" + "http://localhost:52351/other" + "http://localhost:52351/callback?extra=1" + "http://localhost:52351/callback#fragment" + "http://user@localhost:52351/callback"]] + (t/is (not (clients/redirect-matches? client redirect)))) + (doseq [redirect ["http://localhost.evil.example/callback" + "http://user@localhost/callback"]] + (t/is (not (clients/redirect-uri? redirect)))))) + +(t/deftest codex-published-client-metadata + ;; Public metadata from https://chatgpt.com/oauth/codex/client.json. + ;; Keep this fixture offline so upstream changes cannot make CI flaky. + (let [client-id "https://chatgpt.com/oauth/codex/client.json" + metadata {:client_id client-id + :client_uri "https://chatgpt.com/codex" + :application_type "native" + :redirect_uris ["http://127.0.0.1/callback" "http://localhost/callback"] + :token_endpoint_auth_method "none" + :token_endpoint_auth_methods_supported ["none"] + :grant_types ["authorization_code" "refresh_token"] + :response_types ["code"] + :client_name "Codex" + :logo_uri "https://persistent.oaistatic.com/sonic/misc/openai-logo.png"}] + (with-mocks [dns {:target 'app.util.ssrf/resolve-host + :return (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")])} + sender {:target 'app.http.client/send! + :return (fn [& _] + {:status 200 + :headers {"content-type" "application/json" "cache-control" "no-store"} + :body (java.io.ByteArrayInputStream. (json/encode metadata))})}] + (let [client (clients/resolve-client th/*system* client-id)] + (t/is (= client-id (:client_id client))) + (t/is (= (:logo_uri metadata) (:logo_uri client))) + (t/is (= ["authorization_code"] (:grant_types client))) + (t/is (= "none" (:token_endpoint_auth_method client))) + (t/is (clients/redirect-matches? client "http://127.0.0.1:52351/callback")))))) + +(t/deftest client-metadata-documents + (let [client-id "https://client.example/client.json" + calls (atom 0) + metadata (atom {:client_id client-id + :client_name "Example" + :redirect_uris ["https://client.example/callback"] + :token_endpoint_auth_methods_supported ["none" "private_key_jwt"]})] + (with-mocks [dns {:target 'app.util.ssrf/resolve-host + :return (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")])} + sender {:target 'app.http.client/send! + :return (fn [_ _ _] + (swap! calls inc) + {:status 200 + :headers {"content-type" "application/json" "cache-control" "no-store"} + :body (java.io.ByteArrayInputStream. + (json/encode @metadata))})}] + (t/is (= "none" (:token_endpoint_auth_method (clients/resolve-client th/*system* client-id)))) + (swap! metadata assoc :redirect_uris ["https://client.example/changed"]) + (t/is (not (clients/redirect-matches? (clients/resolve-client th/*system* client-id) + "https://client.example/callback"))) + (t/is (= 2 @calls)) + (swap! metadata assoc :client_id "https://different.example/client.json") + (t/is (thrown? clojure.lang.ExceptionInfo (clients/resolve-client th/*system* client-id))) + (swap! metadata assoc :client_id client-id :client_name (apply str (repeat 65537 "a"))) + (t/is (thrown? clojure.lang.ExceptionInfo (clients/resolve-client th/*system* client-id)))))) + +(t/deftest client-metadata-rejects-unsafe-fetches + (with-mocks [sender {:target 'app.http.client/send! + :return (fn [& _] (throw (AssertionError. "Unsafe request reached HTTP client")))}] + (doseq [client-id ["https://127.0.0.1/client.json" + "https://169.254.169.254/client.json" + "https://[::1]/client.json" + "https://user:secret@example.com/client.json" + "https://example.com/client.json#fragment"]] + (t/is (thrown? clojure.lang.ExceptionInfo (clients/resolve-client th/*system* client-id)))) + (t/is (not (:called? @sender))))) + +(t/deftest client-metadata-rejects-redirects + (with-mocks [dns {:target 'app.util.ssrf/resolve-host + :return (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")])} + sender {:target 'app.http.client/send! + :return {:status 302 + :headers {"location" "https://127.0.0.1/internal"} + :body (java.io.ByteArrayInputStream. (byte-array 0))}}] + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/resolve-client th/*system* "https://redirect.example/client.json"))))) diff --git a/backend/test/backend_tests/mcp_oauth_http_test.clj b/backend/test/backend_tests/mcp_oauth_http_test.clj new file mode 100644 index 0000000000..3a19d8bd93 --- /dev/null +++ b/backend/test/backend_tests/mcp_oauth_http_test.clj @@ -0,0 +1,253 @@ +;; 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 SUBSIDIARY SL + +(ns backend-tests.mcp-oauth-http-test + (:require + [app.common.uri :as uri] + [app.config :as cf] + [app.db :as db] + [app.http :as http] + [app.http.errors :as errors] + [app.http.mcp-oauth :as oauth] + [app.http.session :as session] + [app.mcp.oauth-clients :as clients] + [app.mcp.oauth-state :as state] + [app.rpc.commands.access-token :as access-token] + [app.setup.clock :as clock] + [app.util.json :as json] + [backend-tests.helpers :as th] + [clojure.string :as str] + [clojure.test :as t] + [yetti.response :as yres]) + (:import java.net.URLEncoder java.nio.charset.StandardCharsets)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset + (fn [run] + (binding [cf/config (assoc cf/config :tenant (str (java.util.UUID/randomUUID)))] + (run)))) + +(defn- form [params] + (str/join "&" (for [[key value] params] + (str (name key) "=" (URLEncoder/encode (str value) StandardCharsets/UTF_8))))) + +(defn- request [path & {:as options}] + (let [handler (#'http/router-handler (:app.http/router th/*system*))] + (handler (th/make-dummy-request (assoc options :path path))))) + +(defn- body [response] (json/decode (::yres/body response))) + +(defn- login-cookie [profile] + (let [session (session/create-session (:app.http.session/manager th/*system*) + {:profile-id (:id profile)}) + token (:token (#'session/assign-token th/*system* session))] + {(cf/get :auth-token-cookie-name) token})) + +(defn- begin [client] + (let [reply (request "/api/mcp/oauth/authorize" + :query (form {:client_id (:client_id client) + :redirect_uri (first (:redirect_uris client)) + :response_type "code" :scope "mcp" + :resource (oauth/resource) + :state "client-state" + :code_challenge (state/digest (apply str (repeat 43 "a"))) + :code_challenge_method "S256"}))] + (t/is (= 303 (::yres/status reply))) + (get (uri/query-string->map (.getQuery (java.net.URI. (get-in reply [::yres/headers "location"])))) + :request))) + +(t/deftest discovery-and-registration + (let [metadata (body (request "/.well-known/oauth-authorization-server/api/mcp/oauth")) + resource (body (request "/api/mcp/oauth/resource")) + registered (request "/api/mcp/oauth/register" :method :post + :headers {"content-type" "application/json"} + :body-bytes (json/encode-str {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method "none"}))] + (t/is (= (oauth/issuer) (:issuer metadata))) + (t/is (= ["S256"] (:code_challenge_methods_supported metadata))) + (t/is (= (oauth/resource) (:resource resource))) + (t/is (= 201 (::yres/status registered))) + (t/is (= "none" (:token_endpoint_auth_method (body registered)))) + (t/is (nil? (:client_secret (body registered)))))) + +(t/deftest complete-authorization-flow + (let [profile (th/create-profile* 1 {:is-active true}) + cookies (login-cookie profile) + client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"] + :logo_uri "https://example.com/logo.png" + :token_endpoint_auth_method "none"}) + handle (begin client) + details (request "/api/mcp/oauth/request" :query (form {:request handle}) :cookies cookies) + origin (str (cf/get-public-uri)) + origin (str/replace origin #"/$" "") + decision {:request handle :decision "approve" :csrf (:csrf (body details))} + submit #(request "/api/mcp/oauth/decision" :method :post :cookies cookies + :headers {"content-type" "application/x-www-form-urlencoded" "origin" origin} + :body-bytes (form %))] + (t/is (= 401 (::yres/status (request "/api/mcp/oauth/request" :query (form {:request handle}))))) + (t/is (= 200 (::yres/status details))) + (t/is (= "https://example.com/logo.png" (:logo-uri (body details)))) + (t/is (= 400 (::yres/status (submit (assoc decision :csrf "wrong"))))) + (let [approved (submit decision) + callback (java.net.URI. (:redirect-uri (body approved))) + params (uri/query-string->map (.getQuery callback)) + exchange {:grant_type "authorization_code" :code (:code params) + :client_id (:client_id client) :redirect_uri "https://example.com/callback" + :resource (oauth/resource) :code_verifier (apply str (repeat 43 "a"))} + redeem #(request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes (form %))] + (t/is (= 200 (::yres/status approved))) + (t/is (= "client-state" (:state params))) + (t/is (= (oauth/issuer) (:iss params))) + (t/is (= 400 (::yres/status (submit decision)))) + ;; Metadata is public and editable, but changing it changes the client ID. + ;; Even with the correct verifier, an edited ID cannot use this approval. + (let [[prefix encoded] (str/split (:client_id client) #"\." 2) + payload (json/decode (.decode (java.util.Base64/getUrlDecoder) encoded))] + (doseq [changed [(assoc payload :client_name "Different application") + (assoc payload :redirect_uris ["https://attacker.example/callback"])]] + (let [id (str prefix "." (.encodeToString (.withoutPadding (java.util.Base64/getUrlEncoder)) + (json/encode changed)))] + (t/is (string? (:client_id (clients/resolve-client {} id)))) + (t/is (= "invalid_grant" (:error (body (redeem (assoc exchange :client_id id))))))))) + (t/is (= 400 (::yres/status (redeem (assoc exchange :code_verifier (apply str (repeat 43 "b"))))))) + (let [token-response (redeem exchange) + token (body token-response) + stored (first (db/query th/*pool* :access-token {:profile-id (:id profile) :type "mcp"}))] + (t/is (= 200 (::yres/status token-response))) + (t/is (= "no-store" (get-in token-response [::yres/headers "cache-control"]))) + (t/is (= (:token stored) (:access_token token))) + (t/is (= "Bearer" (:token_type token))) + (t/is (nil? (:expires_in token))) + (t/is (nil? (:refresh_token token))) + (t/is (= 400 (::yres/status (redeem exchange)))))))) + +(t/deftest denial-and-session-binding + (let [profile (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + cookies (login-cookie profile) + client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method "none"}) + handle (begin client) + details (body (request "/api/mcp/oauth/request" :query (form {:request handle}) :cookies cookies)) + params {:request handle :csrf (:csrf details) :decision "deny"} + submit #(request "/api/mcp/oauth/decision" :method :post :cookies cookies + :headers {"content-type" "application/x-www-form-urlencoded" "origin" %} + :body-bytes (form params))] + (t/is (= 403 (::yres/status + (request "/api/mcp/oauth/request" :query (form {:request handle}) + :cookies (login-cookie other))))) + (t/is (= 403 (::yres/status (submit "https://attacker.example")))) + (let [denied (submit (str/replace (str (cf/get-public-uri)) #"/$" "")) + callback (java.net.URI. (:redirect-uri (body denied))) + result (uri/query-string->map (.getQuery callback))] + (t/is (= "access_denied" (:error result))) + (t/is (= "client-state" (:state result))) + (t/is (empty? (db/query th/*pool* :access-token {:profile-id (:id profile)}))) + (t/is (not (:mcp-enabled (db/decode-transit-pgobject + (:props (th/db-get :profile {:id (:id profile)}))))))))) + +(t/deftest oauth-input-and-cors-errors + (doseq [data ["{" "[]" (apply str (repeat 16385 "a"))]] + (let [reply (request "/api/mcp/oauth/register" :method :post + :headers {"content-type" "application/json"} + :body-bytes data)] + (t/is (= 400 (::yres/status reply))) + (t/is (= "*" (get-in reply [::yres/headers "access-control-allow-origin"]))))) + (let [reply (request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes "code=a&code=b")] + (t/is (= 400 (::yres/status reply)))) + (t/is (= 204 (::yres/status (request "/api/mcp/oauth/token" :method :options))))) + +(t/deftest confidential-client-exchange + (let [profile (th/create-profile* 1 {:is-active true}) + stored (db/tx-run! th/*system* access-token/ensure-mcp-access! (:id profile)) + verifier (apply str (repeat 43 "a"))] + (doseq [method ["client_secret_basic" "client_secret_post"]] + (let [client (clients/register! th/*system* + {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method method}) + code (state/create! th/*system* :code + {:client-id (:client_id client) + :redirect-uri "https://example.com/callback" + :resource (oauth/resource) + :challenge (state/digest verifier) + :profile-id (str (:id profile)) + :token-id (str (:id stored))} + 120) + params {:grant_type "authorization_code" :client_id (:client_id client) + :code code :code_verifier verifier :redirect_uri "https://example.com/callback"} + redeem (fn [secret] + (request "/api/mcp/oauth/token" :method :post + :headers (cond-> {"content-type" "application/x-www-form-urlencoded"} + (= method "client_secret_basic") + (assoc "authorization" + (str "Basic " (.encodeToString + (java.util.Base64/getEncoder) + (.getBytes (str (:client_id client) ":" secret) "UTF-8"))))) + :body-bytes (form (cond-> params + (= method "client_secret_post") + (assoc :client_secret secret)))))] + (t/is (= 401 (::yres/status (redeem "wrong")))) + (t/is (= (:token stored) (:access_token (body (redeem (:client_secret client)))))))))) + +(t/deftest exchange-rejects-expired-token + (let [profile (th/create-profile* 1 {:is-active true}) + stored (db/tx-run! th/*system* access-token/ensure-mcp-access! (:id profile)) + verifier (apply str (repeat 43 "a")) + client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method "none"}) + code (state/create! th/*system* :code + {:client-id (:client_id client) + :redirect-uri "https://example.com/callback" + :resource (oauth/resource) + :challenge (state/digest verifier) + :profile-id (str (:id profile)) + :token-id (str (:id stored))} + 120)] + (db/exec! th/*pool* ["UPDATE access_token SET expires_at = now() - interval '1 day' WHERE id = ?" + (:id stored)]) + (let [reply (request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes (form {:grant_type "authorization_code" :code code + :client_id (:client_id client) :code_verifier verifier + :redirect_uri "https://example.com/callback"}))] + (t/is (= 400 (::yres/status reply))) + (t/is (= "invalid_grant" (:error (body reply))))))) + +(t/deftest unexpected-errors-are-logged + (let [logged (atom []) + cookies (login-cookie (th/create-profile* 1 {:is-active true})) + fail (fn [& _] (throw (RuntimeException. "unexpected")))] + (with-redefs [errors/handle (fn [cause _] (swap! logged conj (ex-message cause))) + clients/register! fail + clock/get-clock fail] + (t/testing "endpoint failures" + (let [reply (request "/api/mcp/oauth/register" :method :post + :headers {"content-type" "application/json"} + :body-bytes (json/encode-str {:redirect_uris ["https://example.com/callback"]}))] + (t/is (= 503 (::yres/status reply))) + (t/is (= {:error "temporarily_unavailable"} (body reply))) + (t/is (= "*" (get-in reply [::yres/headers "access-control-allow-origin"]))))) + (t/testing "session middleware failures" + (let [reply (request "/api/mcp/oauth/request" :query (form {:request "x"}) :cookies cookies)] + (t/is (= 503 (::yres/status reply))) + (t/is (= {:error "temporarily_unavailable"} (body reply))))) + (t/testing "OAuth rejections are not logged" + (t/is (= 400 (::yres/status (request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes "code=a&code=b")))))) + (t/is (= ["unexpected" "unexpected"] @logged)))) + +(t/deftest authorization-rejects-unregistered-callback + (let [client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"]}) + reply (request "/api/mcp/oauth/authorize" + :query (form {:client_id (:client_id client) + :redirect_uri "https://attacker.example/callback"}))] + (t/is (= 400 (::yres/status reply))) + (t/is (nil? (get-in reply [::yres/headers "location"]))))) diff --git a/backend/test/backend_tests/mcp_oauth_state_test.clj b/backend/test/backend_tests/mcp_oauth_state_test.clj new file mode 100644 index 0000000000..196c8378a2 --- /dev/null +++ b/backend/test/backend_tests/mcp_oauth_state_test.clj @@ -0,0 +1,53 @@ +;; 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 SUBSIDIARY SL + +(ns backend-tests.mcp-oauth-state-test + (:require + [app.config :as cf] + [app.mcp.oauth-state :as state] + [backend-tests.helpers :as th] + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) + +(t/deftest authorization-state + (let [cfg th/*system* + handle (state/create! cfg :code {:client-id "test"} 60) + record (state/read! cfg :code handle)] + (t/is (= {:client-id "test"} (:value record))) + (t/is (nil? (state/read! cfg :request handle))) + (t/is (false? (state/consume! cfg :code handle (assoc record :raw "wrong")))) + (t/is (= record (state/read! cfg :code handle))) + (let [results (mapv deref (repeatedly 8 #(future (state/consume! cfg :code handle record))))] + (t/is (= 1 (count (filter true? results))))) + (t/is (nil? (state/read! cfg :code handle))))) + +(t/deftest bind-pending-request-once + (let [cfg th/*system* + handle (state/create! cfg :request {:client-id "test"} 60) + record (state/read! cfg :request handle)] + (t/is (true? (state/replace! cfg :request handle record {:session-id "first"}))) + (t/is (false? (state/replace! cfg :request handle record {:session-id "second"}))) + (t/is (= {:session-id "first"} (:value (state/read! cfg :request handle)))))) + +(t/deftest pkce-and-secrets + ;; RFC 7636 appendix B + (t/is (= "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + (state/digest "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")))) + +(t/deftest expiry-and-tenant-isolation + (let [cfg th/*system* + handle (state/create! cfg :request {:test true} 1)] + (binding [cf/config (assoc cf/config :tenant "another-tenant")] + (t/is (nil? (state/read! cfg :request handle)))) + (Thread/sleep 1100) + (t/is (nil? (state/read! cfg :request handle))))) + +(t/deftest state-storage-fails-closed + (with-mocks [store {:target 'app.redis/set :return nil}] + (t/is (thrown? clojure.lang.ExceptionInfo + (state/create! th/*system* :request {:test true} 60))))) diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index 5fa3a1496a..e5fcaf5348 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -11,6 +11,7 @@ [app.http :as http] [app.http.access-token :as actoken] [app.rpc :as-alias rpc] + [app.rpc.commands.access-token :as access-token] [app.storage :as sto] [backend-tests.helpers :as th] [clojure.test :as t] @@ -232,3 +233,40 @@ decoded (actoken/decode-token cfg access-token)] (t/is (some? decoded)) (t/is (= (:id prof) (:uid decoded))))))) + +(t/deftest oauth-mcp-token-provisioning + (let [prof (th/create-profile* 1 {:is-active true}) + cfg th/*system* + provision #(db/tx-run! cfg access-token/ensure-mcp-access! (:id prof))] + (t/testing "approval enables MCP and creates a fixed token" + (let [token (provision) + props (-> (th/db-get :profile {:id (:id prof)}) + :props db/decode-transit-pgobject)] + (t/is (string? (:token token))) + (t/is (nil? (:expires-at token))) + (t/is (true? (:mcp-enabled props))) + (t/is (= token (provision))))) + (t/testing "approval preserves a token generated in settings" + (let [{:keys [result error]} + (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "From settings"})] + (t/is (nil? error)) + (t/is (= (:token result) (:token (provision)))))) + (t/testing "approval replaces an expired token" + (db/exec! th/*pool* [(str "UPDATE access_token SET expires_at = now() - interval '1 day'" + " WHERE profile_id = ?") + (:id prof)]) + (let [expired (first (db/query th/*pool* :access-token {:profile-id (:id prof) :type "mcp"})) + token (provision)] + (t/is (not= (:token expired) (:token token))) + (t/is (nil? (:expires-at token))) + (t/is (= [(:id token)] + (map :id (db/query th/*pool* :access-token {:profile-id (:id prof) :type "mcp"})))))) + (t/testing "concurrent approvals share one token" + (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) + (let [results (mapv deref (repeatedly 4 #(future (provision))))] + (t/is (= 1 (count (set (map :token results))))) + (t/is (= 1 (count (db/query th/*pool* :access-token + {:profile-id (:id prof) :type "mcp"})))))))) diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index e684d5b7e0..f15a261040 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -175,14 +175,14 @@ (defn- import-file [profile path] - (let [out (th/command! {::th/type :import-binfile - ::rpc/profile-id (:id profile) - :project-id (:default-project-id profile) - :name "imported" - :file {:filename "imported.penpot" - :size (fs/size path) - :path path - :mtype "application/zip"}})] + (let [out (th/multipart-command! {::th/type :import-binfile + ::rpc/profile-id (:id profile) + :project-id (:default-project-id profile) + :name "imported" + :file {:filename "imported.penpot" + :size (fs/size path) + :path path + :mtype "application/zip"}})] (t/is (nil? (:error out))) (th/consume-sse (:result out)))) diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 5a0a81f091..9a3cfd3222 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -141,6 +141,11 @@ http { proxy_pass http://127.0.0.1:6061; } + location = /.well-known/oauth-authorization-server/api/mcp/oauth { + proxy_pass http://127.0.0.1:6060; + proxy_http_version 1.1; + } + location /api { proxy_pass http://127.0.0.1:6060/api; proxy_http_version 1.1; diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index c8800b0503..54d62e18eb 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -155,6 +155,11 @@ http { proxy_pass $PENPOT_EXPORTER_URI; } + location = /.well-known/oauth-authorization-server/api/mcp/oauth { + proxy_set_header Host $proxy_host; + proxy_pass $PENPOT_BACKEND_URI$request_uri; + } + location /api { proxy_set_header Host $proxy_host; proxy_pass $PENPOT_BACKEND_URI/api; diff --git a/frontend/playwright/ui/specs/email-verification.spec.js b/frontend/playwright/ui/specs/email-verification.spec.js index 65c1a9cb99..f15be88e20 100644 --- a/frontend/playwright/ui/specs/email-verification.spec.js +++ b/frontend/playwright/ui/specs/email-verification.spec.js @@ -1,32 +1,33 @@ import { test, expect } from "@playwright/test"; import { RegisterPage } from "../pages/RegisterPage"; -// Regression test for the bug where a freshly verified account (whose -// profile never had a theme persisted) ended up with an empty string as -// its theme instead of falling back to the dark default: the workspace -// switched to light mode and Settings > UI Theme showed a blank field. +// A freshly verified account has no saved theme preference. It must follow +// the system theme and show that preference in settings, not a blank field. test.beforeEach(async ({ page }) => { await RegisterPage.initWithLoggedOutUser(page); }); test.describe("Email verification", () => { - test("Newly verified account defaults to the dark theme", async ({ - page, - }) => { - const registerPage = new RegisterPage(page); - await registerPage.setupEmailVerificationSuccess(); + for (const colorScheme of ["light", "dark"]) { + test(`Newly verified account follows the ${colorScheme} system theme`, async ({ + page, + }) => { + await page.emulateMedia({ colorScheme }); + const registerPage = new RegisterPage(page); + await registerPage.setupEmailVerificationSuccess(); - await registerPage.goToVerifyToken(); - await page.waitForURL(/screen=dashboard/); + await registerPage.goToVerifyToken(); + await page.waitForURL(/screen=dashboard/); - // `default` is the body class applied for dark theme, `light` for - // light theme (see app.util.theme/set-color-scheme). - await expect(page.locator("body")).toHaveClass(/default/); - await expect(page.locator("body")).not.toHaveClass(/light/); - }); + // The dark theme uses the legacy body class "default". + await expect(page.locator("body")).toHaveClass( + colorScheme === "dark" ? "default" : "light", + ); + }); + } - test("Settings > UI Theme shows Penpot Dark (default) selected, not blank", async ({ + test("Settings > UI Theme shows System theme selected, not blank", async ({ page, }) => { const registerPage = new RegisterPage(page); @@ -40,6 +41,6 @@ test.describe("Email verification", () => { // The language select is the first combobox on the page, the theme // select is the second one. const themeSelect = page.getByRole("combobox").nth(1); - await expect(themeSelect).toHaveText("Penpot Dark (default)"); + await expect(themeSelect).toHaveText("System theme"); }); }); diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index 051530b877..b59266f5eb 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -107,6 +107,8 @@ test("Selection size badge appears on selection and hides on deselect", async ({ test("Selection size badge uses component color for component selection", async ({ page, }) => { + // The expected component color below is the dark palette value. + await page.emulateMedia({ colorScheme: "dark" }); const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(); await workspacePage.mockGetFile("components/get-file-13267.json"); diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index bbae3fec99..5e2075082c 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -20,6 +20,7 @@ [app.main.ui.error-boundary :refer [error-boundary*]] [app.main.ui.exports.files] [app.main.ui.frame-preview :as frame-preview] + [app.main.ui.mcp-authorize :refer [mcp-authorize*]] [app.main.ui.nitrate.entry :as nitrate-entry] [app.main.ui.notifications :as notifications] [app.main.ui.onboarding.questions :refer [questions-modal]] @@ -118,6 +119,9 @@ :auth-verify-token [:? [:> verify-token-page* {:route route}]] + :mcp-authorize + [:> mcp-authorize* {:route route :profile profile}] + :nitrate-entry [:> nitrate-entry/nitrate-entry-page* {:profile profile}] diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index d200c3746d..b43a954986 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -70,7 +70,7 @@ [:string {:min 1}]]]) (mf/defc login-form* - [{:keys [params handle-redirect on-success-callback on-recovery-request origin] :as props}] + [{:keys [params handle-redirect on-success-callback on-recovery-request origin hide-recovery-link] :as props}] (let [initial (mf/with-memo [params] params) form (fm/use-form :schema schema:login-form @@ -193,7 +193,8 @@ :label (tr "auth.password") :class (stl/css :form-field)}]]) - (when (and (not= origin :viewer) + (when (and (not hide-recovery-link) + (not= origin :viewer) (or (contains? cf/flags :login) (contains? cf/flags :login-with-password))) [:div {:class (stl/css :form-row :forgot-password-row)} diff --git a/frontend/src/app/main/ui/auth/login_modal.cljs b/frontend/src/app/main/ui/auth/login_modal.cljs new file mode 100644 index 0000000000..ee192893a4 --- /dev/null +++ b/frontend/src/app/main/ui/auth/login_modal.cljs @@ -0,0 +1,146 @@ +;; 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 SUBSIDIARY SL + +(ns app.main.ui.auth.login-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.config :as cf] + [app.main.router :as rt] + [app.main.store :as st] + [app.main.ui.auth.login :refer [login-dialog*]] + [app.main.ui.auth.recovery-request :refer [recovery-request-page*]] + [app.main.ui.auth.register :refer [register-methods* register-success-page* + register-validate-form* terms-service-privacy-policy*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.util.dom :as dom] + [app.util.i18n :as i18n :refer [tr]] + [app.util.keyboard :as k] + [rumext.v2 :as mf])) + +(mf/defc login-register-modal* + [{:keys [params on-close on-register]}] + (let [user-email (mf/use-state "") + register-token (mf/use-state "") + + current-section* (mf/use-state :login) + current-section (deref current-section*) + + set-current-section + (mf/use-fn + #(reset! current-section* %)) + + set-section + (mf/use-fn + (fn [event] + (let [section (-> (dom/get-current-target event) + (dom/get-data "value") + (keyword))] + (set-current-section section)))) + + go-back-to-login + (mf/use-fn + #(set-current-section :login)) + + main-section (or + (= current-section :login) + (= current-section :register) + (= current-section :register-validate)) + close + (fn [event] + (dom/prevent-default event) + (dom/stop-propagation event) + (on-close)) + + success-email-sent + (fn [email] + (reset! user-email email) + (set-current-section :email-sent)) + + success-login + (fn [& _] + (.reload js/window.location true)) + + success-register + (fn [data] + (cond + (:invitation-token data) + (st/emit! (rt/nav :auth-verify-token {:token (:invitation-token data)})) + + (:is-active data) + (if on-register (on-register) (on-close)) + + :else + (do + (reset! user-email (:email data)) + (set-current-section :email-sent))))] + + [:div {:class (stl/css :modal-overlay) + :on-key-down #(when (k/esc? %) (close %))} + [:div {:class (stl/css :modal-container) + :role "dialog" + :aria-modal true + :aria-labelledby "login-modal-title"} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-header-title) + :id "login-modal-title"} (tr "labels.continue-with-penpot")] + [:> icon-button* {:variant "ghost" + :class (stl/css :modal-close) + :aria-label (tr "labels.close") + :on-click close + :icon i/close}]] + + [:div {:class (stl/css :modal-content)} + (case current-section + :login + [:div {:class (stl/css :login-form)} + [:> login-dialog* {:params params + :on-success-callback success-login + :hide-recovery-link true}] + [:div {:class (stl/css :login-links)} + [:div + [:a {:on-click set-section + :data-value "recovery-request"} + (tr "auth.forgot-password")]] + (when (contains? cf/flags :registration) + [:div + [:span + (tr "auth.register") " "] + [:a {:on-click set-section + :data-value "register"} + (tr "auth.register-submit")]])]] + + :register + [:div {:class (stl/css :login-form)} + [:> register-methods* {:params params :on-success-callback success-register}] + [:div {:class (stl/css :login-links)} + [:div + [:span (tr "auth.already-have-account") " "] + [:a {:on-click set-section + :data-value "login"} + (tr "auth.login-here")]]]] + + :register-validate + [:div {:class (stl/css :login-form)} + [:> register-validate-form* {:params {:token @register-token} + :on-success-callback success-email-sent}] + [:div {:class (stl/css :login-links)} + [:div + [:a {:on-click set-section + :data-value "register"} + (tr "labels.go-back")]]]] + + :recovery-request + [:> recovery-request-page* {:go-back-callback go-back-to-login + :on-success-callback success-email-sent}] + + :email-sent + [:div {:class (stl/css :login-form)} + [:> register-success-page* {:params {:email @user-email}}]]) + + (when main-section + [:div {:class (stl/css :login-links)} + [:> terms-service-privacy-policy*]])]]])) diff --git a/frontend/src/app/main/ui/viewer/login.scss b/frontend/src/app/main/ui/auth/login_modal.scss similarity index 100% rename from frontend/src/app/main/ui/viewer/login.scss rename to frontend/src/app/main/ui/auth/login_modal.scss diff --git a/frontend/src/app/main/ui/components/progress.cljs b/frontend/src/app/main/ui/components/progress.cljs index 52fb0b2393..35ae9f3101 100644 --- a/frontend/src/app/main/ui/components/progress.cljs +++ b/frontend/src/app/main/ui/components/progress.cljs @@ -31,8 +31,8 @@ [] (let [state (mf/deref refs/progress) profile (mf/deref refs/profile) - theme (get profile :theme theme/default) - default-theme? (= theme/default theme) + theme (theme/use-theme profile) + dark-theme? (= "dark" theme) error? (:error state) healthy? (:healthy state) visible? (:visible state) @@ -48,13 +48,13 @@ color (cond error? clr/new-danger - healthy? (if default-theme? + healthy? (if dark-theme? clr/new-primary clr/new-primary-light) (not healthy?) clr/new-warning) background-clr - (if default-theme? + (if dark-theme? clr/background-quaternary clr/background-quaternary-light) diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index eb842c1f6b..6f7f2311aa 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -207,8 +207,8 @@ [] (let [state (mf/deref refs/export) profile (mf/deref refs/profile) - theme (or (:theme profile) theme/default) - is-default-theme? (= theme/default theme) + theme (theme/use-theme profile) + dark-theme? (= "dark" theme) error? (:error state) ;; The exporter is at capacity: worth its own wording, so the user ;; knows retrying later is the thing to do. @@ -245,13 +245,13 @@ error? clr/new-danger (or cancelling? cancelled?) clr/new-warning - healthy? (if is-default-theme? + healthy? (if dark-theme? clr/new-primary clr/new-primary-light) (not healthy?) clr/new-warning) background-clr - (if is-default-theme? + (if dark-theme? clr/background-quaternary clr/background-quaternary-light) diff --git a/frontend/src/app/main/ui/mcp_authorize.cljs b/frontend/src/app/main/ui/mcp_authorize.cljs new file mode 100644 index 0000000000..5f47fd5df6 --- /dev/null +++ b/frontend/src/app/main/ui/mcp_authorize.cljs @@ -0,0 +1,162 @@ +;; 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 SUBSIDIARY SL + +(ns app.main.ui.mcp-authorize + (:require-macros [app.main.style :as stl]) + (:require + [app.common.uri :as uri] + [app.config :as cf] + [app.main.broadcast :as mbc] + [app.main.data.profile :as profile] + [app.main.router :as rt] + [app.main.store :as st] + [app.main.ui.auth.login-modal :refer [login-register-modal*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.typography :as typography] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.product.loader :refer [loader*]] + [app.util.http :as http] + [app.util.i18n :refer [tr]] + [app.util.storage :as storage] + [beicon.v2.core :as rx] + [rumext.v2 :as mf])) + +(defn- endpoint + [path] + (str (uri/join cf/public-uri (str "api/mcp/oauth/" path)))) + +(defn- prepare-login! + [] + (let [href (rt/get-current-href)] + ;; Save the return URL even when only SSO buttons are enabled and the + ;; password form (which normally stores it) never mounts. + (binding [storage/*sync* true] + (swap! storage/session assoc :login-redirect href)) + {:callback-url href})) + +(defn- cancel-login! + [] + ;; Do not let a later, unrelated login resume this cancelled attempt. + (when (= (:login-redirect storage/session) (rt/get-current-href)) + (binding [storage/*sync* true] + (swap! storage/session dissoc :login-redirect)))) + +(defn- oauth-request + [method path params] + (->> (http/send! (cond-> {:method method + :uri (endpoint path) + :response-type :json + :omit-default-headers true} + (= method :get) (assoc :query params) + (= method :post) (assoc :headers {"content-type" "application/x-www-form-urlencoded"} + :body (uri/map->query-string params)))) + (rx/map #(update % :body js->clj :keywordize-keys true)))) + +(mf/defc client-logo* + {::mf/private true} + [{:keys [uri]}] + (let [failed (mf/use-state false)] + (when-not @failed + [:img {:class (stl/css :client-logo) + :src uri + :alt "" + :referrer-policy "no-referrer" + :on-error #(reset! failed true)}]))) + +(mf/defc mcp-authorize* + [{:keys [route profile]}] + (let [handle (get-in route [:query-params :request]) + details (mf/use-state nil) + failed? (mf/use-state false) + login-params (mf/use-state nil) + cancelled? (mf/use-state false) + busy? (mf/use-state false) + ;; The ref also blocks a second click before React renders disabled buttons. + submitting (mf/use-memo (fn [] (atom false))) + + decide! + (mf/use-fn + (mf/deps handle @details) + (fn [decision] + (when (compare-and-set! submitting false true) + (reset! busy? true) + (->> (oauth-request :post "decision" + {:request handle :csrf (:csrf @details) :decision decision}) + (rx/subs! + (fn [{:keys [status body]}] + (if (= status 200) + (do + (when (= decision "approve") + (st/emit! (profile/refresh-profile) + (mbc/event :mcp/enable {}))) + (st/emit! (rt/nav-raw :href (:redirect-uri body)))) + (reset! failed? true))) + (fn [_] (reset! failed? true)))))))] + + (mf/with-effect [handle] + (reset! details nil) + (reset! failed? false) + (reset! login-params nil) + (reset! cancelled? false) + (reset! busy? false) + (reset! submitting false) + (let [subscription + (->> (oauth-request :get "request" {:request handle}) + (rx/subs! + (fn [{:keys [status body]}] + (case status + 200 (reset! details body) + 401 (reset! login-params (prepare-login!)) + (reset! failed? true))) + (fn [_] (reset! failed? true))))] + #(rx/dispose! subscription))) + + [:main {:class (stl/css :page)} + (if @login-params + [:> login-register-modal* + {:params @login-params + :on-register #(st/emit! (rt/reload true)) + :on-close (fn [] + (cancel-login!) + (reset! login-params nil) + (reset! cancelled? true))}] + [:section {:class (stl/css :card) :aria-labelledby "mcp-approval-title"} + [:> heading* {:level 1 :typography typography/title-large :id "mcp-approval-title"} + (tr "mcp.oauth.title")] + (cond + @cancelled? + [:> text* {:typography typography/body-large :role "status"} + (tr "mcp.oauth.cancelled")] + + @failed? + [:> text* {:typography typography/body-large :role "alert"} + (tr "mcp.oauth.failed")] + + (nil? @details) + [:> loader* {:title (tr "labels.loading")}] + + :else + [:* + [:div {:class (stl/css :client)} + (when-let [logo-uri (:logo-uri @details)] + [:> client-logo* {:key logo-uri :uri logo-uri}]) + [:div {:class (stl/css :client-details)} + [:> text* {:typography typography/body-large :class (stl/css :client-text)} + (tr "mcp.oauth.request" (or (:client-name @details) (:client-id @details)))] + [:> text* {:typography typography/body-small :class (stl/css :client-text)} + (:client-id @details)]]] + [:> text* {:typography typography/body-medium} + (tr "mcp.oauth.account" (:email profile))] + [:> text* {:typography typography/body-large} + (tr "mcp.oauth.access")] + [:> text* {:typography typography/body-medium} + (tr "mcp.oauth.enable")] + [:div {:class (stl/css :actions)} + [:> button* {:variant "secondary" :disabled @busy? :on-click #(decide! "deny")} + (tr "labels.cancel")] + [:> button* {:variant "primary" :disabled @busy? :on-click #(decide! "approve")} + (tr "mcp.oauth.approve")]]])])])) diff --git a/frontend/src/app/main/ui/mcp_authorize.scss b/frontend/src/app/main/ui/mcp_authorize.scss new file mode 100644 index 0000000000..64eed408fa --- /dev/null +++ b/frontend/src/app/main/ui/mcp_authorize.scss @@ -0,0 +1,61 @@ +// 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 SUBSIDIARY SL + +@use "ds/_sizes.scss" as *; + +.page { + display: grid; + place-items: center; + min-block-size: 100%; + padding: var(--sp-xl); + background: var(--color-background-primary); + color: var(--color-foreground-primary); +} + +.card { + display: flex; + flex-direction: column; + gap: var(--sp-l); + max-inline-size: 36rem; + padding: var(--sp-xxl); + border: 1px solid var(--color-background-quaternary); + border-radius: var(--br-8); +} + +.client { + display: flex; + align-items: center; + gap: var(--sp-l); + margin-block-end: var(--sp-s); + overflow-wrap: anywhere; +} + +.client-logo { + inline-size: $sz-48; + block-size: $sz-48; + flex-shrink: 0; + object-fit: contain; + border-radius: var(--br-8); +} + +.client-details { + display: flex; + flex-direction: column; + gap: calc(var(--sp-s) - var(--sp-xxs)); + min-inline-size: 0; +} + +.client-text { + margin: 0; +} + +.actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--sp-m); + margin-block-start: var(--sp-l); +} diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 8096051d40..fa9926911b 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -33,7 +33,8 @@ "Enabled route names. Query-string routing: the `screen` query param carries the route name (`?screen=¶ms`); it is router-owned and reserved, every other param travels as a plain query param." - (into #{:auth-login + (into #{:mcp-authorize + :auth-login :auth-register :auth-register-validate :auth-register-success diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index c4d711ba79..12ba30ef6a 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -49,7 +49,7 @@ (update :lang #(or % "")) (update :theme #(if (= % "default") "dark" - (or % "dark"))))) + (or % theme/default))))) form (fm/use-form :schema schema:options-form :initial initial)] diff --git a/frontend/src/app/main/ui/viewer/login.cljs b/frontend/src/app/main/ui/viewer/login.cljs index e99e7e3383..fca78a1aa4 100644 --- a/frontend/src/app/main/ui/viewer/login.cljs +++ b/frontend/src/app/main/ui/viewer/login.cljs @@ -5,140 +5,13 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.login - (:require-macros [app.main.style :as stl]) (:require - [app.common.logging :as log] - [app.config :as cf] [app.main.data.modal :as modal] - [app.main.router :as rt] - [app.main.store :as st] - [app.main.ui.auth.login :refer [login-dialog*]] - [app.main.ui.auth.recovery-request :refer [recovery-request-page*]] - [app.main.ui.auth.register :refer [register-methods* register-success-page* - register-validate-form* terms-service-privacy-policy*]] - [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] - [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] + [app.main.ui.auth.login-modal :refer [login-register-modal*]] [rumext.v2 :as mf])) -(log/set-level! :warn) - (mf/defc login-register-modal {::mf/register modal/components ::mf/register-as :login-register} [_] - (let [user-email (mf/use-state "") - register-token (mf/use-state "") - - current-section* (mf/use-state :login) - current-section (deref current-section*) - - set-current-section - (mf/use-fn - #(reset! current-section* %)) - - set-section - (mf/use-fn - (fn [event] - (let [section (-> (dom/get-current-target event) - (dom/get-data "value") - (keyword))] - (set-current-section section)))) - - go-back-to-login - (mf/use-fn - #(set-current-section :login)) - - main-section (or - (= current-section :login) - (= current-section :register) - (= current-section :register-validate)) - close - (fn [event] - (dom/prevent-default event) - (st/emit! (modal/hide))) - - success-email-sent - (fn [email] - (reset! user-email email) - (set-current-section :email-sent)) - - success-login - (fn [] - (.reload js/window.location true)) - - success-register - (fn [data] - (cond - (:invitation-token data) - (st/emit! (rt/nav :auth-verify-token {:token (:invitation-token data)})) - - (:is-active data) - (st/emit! (modal/hide)) - - :else - (do - (reset! user-email (:email data)) - (set-current-section :email-sent))))] - - [:div {:class (stl/css :modal-overlay)} - [:div {:class (stl/css :modal-container)} - [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-header-title)} (tr "labels.continue-with-penpot")] - [:> icon-button* {:variant "ghost" - :class (stl/css :modal-close) - :aria-label (tr "labels.close") - :on-click close - :icon i/close}]] - - [:div {:class (stl/css :modal-content)} - (case current-section - :login - [:div {:class (stl/css :login-form)} - [:> login-dialog* {:on-success-callback success-login - :origin :viewer}] - [:div {:class (stl/css :login-links)} - [:div - [:a {:on-click set-section - :data-value "recovery-request"} - (tr "auth.forgot-password")]] - (when (contains? cf/flags :registration) - [:div - [:span - (tr "auth.register") " "] - [:a {:on-click set-section - :data-value "register"} - (tr "auth.register-submit")]])]] - - :register - [:div {:class (stl/css :login-form)} - [:> register-methods* {:on-success-callback success-register}] - [:div {:class (stl/css :login-links)} - [:div - [:span (tr "auth.already-have-account") " "] - [:a {:on-click set-section - :data-value "login"} - (tr "auth.login-here")]]]] - - :register-validate - [:div {:class (stl/css :login-form)} - [:> register-validate-form* {:params {:token @register-token} - :on-success-callback success-email-sent}] - [:div {:class (stl/css :login-links)} - [:div - [:a {:on-click set-section - :data-value "register"} - (tr "labels.go-back")]]]] - - :recovery-request - [:> recovery-request-page* {:go-back-callback go-back-to-login - :on-success-callback success-email-sent}] - - :email-sent - [:div {:class (stl/css :login-form)} - [:> register-success-page* {:params {:email @user-email}}]]) - - (when main-section - [:div {:class (stl/css :login-links)} - [:> terms-service-privacy-policy*]])]]])) + [:> login-register-modal* {:on-close modal/hide!}]) diff --git a/frontend/src/app/util/theme.cljs b/frontend/src/app/util/theme.cljs index 75c0837d40..b5c0437a6e 100644 --- a/frontend/src/app/util/theme.cljs +++ b/frontend/src/app/util/theme.cljs @@ -17,7 +17,7 @@ (defonce ^:private color-scheme-listeners* (atom #{})) -(def ^:const default "dark") +(def ^:const default "system") (defn get-system-theme [] @@ -47,16 +47,17 @@ (defn resolve-theme "Resolves the profile's theme setting to the effective UI theme ('dark' or - 'light'): 'system' follows the given system theme, 'default' and an unset - theme mean dark, and any other value is taken as-is. Single source of truth - for the app's own theme and the theme reported to plugins." + 'light'). An unset preference or 'system' follows the system theme; + the legacy 'default' preference means dark. Used by the app and plugins." [profile-theme system-theme] - (cond - (= profile-theme "system") system-theme - (= profile-theme "default") "dark" - :else (d/nilv profile-theme "dark"))) + (let [preference (d/nilv profile-theme default)] + (case preference + "system" system-theme + "default" "dark" + preference))) -(defn use-initialize +(defn use-theme + "Return the effective theme and follow system preference changes." [{profile-theme :theme}] (let [system-theme* (mf/use-state get-system-theme) system-theme (deref system-theme*)] @@ -68,6 +69,11 @@ (fn [] (rx/dispose! s)))) - (mf/with-effect [system-theme profile-theme] - (set-color-scheme (resolve-theme profile-theme system-theme)) + (resolve-theme profile-theme system-theme))) + +(defn use-initialize + [profile] + (let [color (use-theme profile)] + (mf/with-effect [color] + (set-color-scheme color) (notify-color-scheme-listeners!)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e23f915520..bdc9193088 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -131,6 +131,7 @@ [frontend-tests.ui.inspect-stroke-tokens-test] [frontend-tests.ui.jobs-progress-test] [frontend-tests.ui.layout-container-multiple-test] + [frontend-tests.ui.mcp-authorize-test] [frontend-tests.ui.measurements-test] [frontend-tests.ui.measures-menu-props-test] [frontend-tests.ui.organization-team-switch-test] @@ -152,6 +153,7 @@ [frontend-tests.util-simple-math-test] [frontend-tests.util-text-clipboard-test] [frontend-tests.util-text-editor-test] + [frontend-tests.util-theme-test] [frontend-tests.util-webapi-test] [frontend-tests.util-zip-test] [frontend-tests.util.code-gen-markup-svg-wasm-test] @@ -301,6 +303,7 @@ 'frontend-tests.ui.measurements-test 'frontend-tests.ui.measures-menu-props-test 'frontend-tests.ui.organization-team-switch-test + 'frontend-tests.ui.mcp-authorize-test 'frontend-tests.ui.routes-test 'frontend-tests.render-dimensions-test 'frontend-tests.text-editor-paste-guard-test @@ -321,6 +324,7 @@ 'frontend-tests.util-simple-math-test 'frontend-tests.util-text-clipboard-test 'frontend-tests.util-text-editor-test + 'frontend-tests.util-theme-test 'frontend-tests.util-webapi-test 'frontend-tests.util.code-gen-markup-svg-wasm-test 'frontend-tests.util.dom.dnd-test diff --git a/frontend/test/frontend_tests/ui/mcp_authorize_test.cljs b/frontend/test/frontend_tests/ui/mcp_authorize_test.cljs new file mode 100644 index 0000000000..b91e3cbef2 --- /dev/null +++ b/frontend/test/frontend_tests/ui/mcp_authorize_test.cljs @@ -0,0 +1,88 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.ui.mcp-authorize-test + (:require + [app.common.uri :as uri] + [app.main.router :as rt] + [app.main.store :as st] + [app.main.ui.mcp-authorize :as oauth] + [app.util.http :as http] + [app.util.storage :as storage] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as a] + [frontend-tests.helpers.mock :as mock])) + +(t/deftest ^:async approval-uses-form-encoding-and-preserves-response-keys + (let [sent (atom nil)] + (await + (mock/with-mocks* + {http/send! (mock/stub + (fn [request] + (reset! sent request) + (->> (rx/of {:status 200 :body #js {"redirect-uri" "https://client.example/cb?code=abc"}}) + (rx/observe-on :async))))} + (let [response (await (a/->promise + (#'oauth/oauth-request :post "decision" + {:request "opaque-handle" :csrf "a+b&c" :decision "approve"})))] + (t/is (= :post (:method @sent))) + (t/is (= "application/x-www-form-urlencoded" (get-in @sent [:headers "content-type"]))) + (t/is (= {:request "opaque-handle" :csrf "a+b&c" :decision "approve"} + (uri/query-string->map (:body @sent)))) + (t/is (= "https://client.example/cb?code=abc" (get-in response [:body :redirect-uri]))) + (t/is (nil? (get-in response [:body :access_token])))))))) + +(t/deftest ^:async approval-request-preserves-login-required-response + (await + (mock/with-mocks* + {http/send! (mock/stub + (fn [_] + (->> (rx/of {:status 401 :body #js {:error "login_required"}}) + (rx/observe-on :async))))} + (let [response (await (a/->promise (#'oauth/oauth-request :get "request" {:request "opaque-handle"})))] + (t/is (= 401 (:status response))) + (t/is (= "login_required" (get-in response [:body :error]))))))) + +(t/deftest ^:async login-keeps-the-approval-return-url-without-navigation + (let [href "https://penpot.example/?screen=mcp-authorize&request=opaque-handle" + saved (:login-redirect storage/session) + emitted (atom [])] + (try + (await + (mock/with-mocks* + {rt/get-current-href (mock/stub (fn [] href)) + st/emit! (mock/stub (fn [event] (swap! emitted conj event)))} + (let [params (#'oauth/prepare-login!)] + (await (a/settle)) + (t/is (= href (:callback-url params))) + (t/is (= href (:login-redirect storage/session))) + (t/is (empty? @emitted))))) + (finally + (binding [storage/*sync* true] + (if saved + (swap! storage/session assoc :login-redirect saved) + (swap! storage/session dissoc :login-redirect))))))) + +(t/deftest ^:async cancelling-login-clears-only-this-attempts-return-url + (let [href "https://penpot.example/?screen=mcp-authorize&request=opaque-handle" + saved (:login-redirect storage/session)] + (try + (await + (mock/with-mocks* + {rt/get-current-href (mock/stub (fn [] href))} + (#'oauth/prepare-login!) + (#'oauth/cancel-login!) + (t/is (nil? (:login-redirect storage/session))) + (binding [storage/*sync* true] + (swap! storage/session assoc :login-redirect "https://penpot.example/other")) + (#'oauth/cancel-login!) + (t/is (= "https://penpot.example/other" (:login-redirect storage/session))))) + (finally + (binding [storage/*sync* true] + (if saved + (swap! storage/session assoc :login-redirect saved) + (swap! storage/session dissoc :login-redirect))))))) diff --git a/frontend/test/frontend_tests/util_theme_test.cljs b/frontend/test/frontend_tests/util_theme_test.cljs new file mode 100644 index 0000000000..83889bab96 --- /dev/null +++ b/frontend/test/frontend_tests/util_theme_test.cljs @@ -0,0 +1,23 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.util-theme-test + (:require + [app.util.theme :as theme] + [cljs.test :as t])) + +(t/deftest unset-theme-follows-system + (doseq [system ["light" "dark"]] + (t/is (= system (theme/resolve-theme nil system))) + (t/is (= system (theme/resolve-theme theme/default system))) + (t/is (= system (theme/resolve-theme "system" system))))) + +(t/deftest explicit-theme-overrides-system + (doseq [system ["light" "dark"]] + (t/is (= "light" (theme/resolve-theme "light" system))) + (t/is (= "dark" (theme/resolve-theme "dark" system))) + ;; Older profiles store the dark preference as "default". + (t/is (= "dark" (theme/resolve-theme "default" system))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 09b305cc19..8d5af490a5 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -9716,3 +9716,27 @@ msgstr "" #: src/app/main/ui/workspace/sidebar/versions.cljs:462 msgid "workspace.versions.warning.text" msgstr "Autosaved versions will be kept for %s days." + +msgid "mcp.oauth.title" +msgstr "Connect to Penpot MCP" + +msgid "mcp.oauth.request" +msgstr "%s wants to connect to Penpot." + +msgid "mcp.oauth.account" +msgstr "You are signed in as %s." + +msgid "mcp.oauth.access" +msgstr "This client can read and change designs through MCP connections established from Penpot." + +msgid "mcp.oauth.enable" +msgstr "Allowing access enables the MCP integration and creates your MCP token if needed." + +msgid "mcp.oauth.approve" +msgstr "Allow access" + +msgid "mcp.oauth.failed" +msgstr "This request has expired, was already used, or could not be completed. Start the connection again from your MCP client." + +msgid "mcp.oauth.cancelled" +msgstr "Connection cancelled" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 94ac75a0a6..0e75450d92 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9820,3 +9820,9 @@ msgstr "Si quieres aumentar este límite, contáctanos en [support@penpot.app](% #: src/app/main/ui/workspace/sidebar/versions.cljs:462 msgid "workspace.versions.warning.text" msgstr "Los autoguardados duran %s días." + +msgid "mcp.oauth.access" +msgstr "Este cliente puede leer y modificar diseños mediante conexiones MCP establecidas desde Penpot." + +msgid "mcp.oauth.cancelled" +msgstr "Conexión cancelada" diff --git a/manage.sh b/manage.sh index f9a5824bfe..9ef5fb72ad 100755 --- a/manage.sh +++ b/manage.sh @@ -784,13 +784,16 @@ function start-instance { instance-compose "$instance" up -d main - # Wait briefly for main to be reachable; the tmux session lives inside. + # Running does not mean entrypoint setup has finished. Nginx starts only + # after ownership and Cargo setup, so wait for its local health endpoint + # before launching builds as penpot (especially on a fresh container). local container deadline container=$(devenv-main-container "$instance") - deadline=$(( SECONDS + 30 )) - while ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; do + deadline=$(( SECONDS + 120 )) + while ! docker exec "$container" curl --fail --silent --max-time 2 \ + --output /dev/null http://127.0.0.1:8082/stub_status 2>/dev/null; do [[ $SECONDS -ge $deadline ]] && { - echo "[${instance}] main container did not reach Running within 30s" >&2 + echo "[${instance}] main container initialization did not finish within 120s; check docker logs $container" >&2 return 1 } sleep 1 diff --git a/mcp/README.md b/mcp/README.md index 59d60aaf61..b38b60d502 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -299,6 +299,7 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` | | `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` | | `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | +| `PENPOT_MCP_AUTH_URI` | Public base URL of the MCP authorization service, e.g. `https://penpot.example/api/mcp/oauth`. Set to enable OAuth; requires `--multi-user`. Use HTTPS (HTTP is allowed on loopback for development), without credentials, query parameters, or a fragment. | (unset; OAuth disabled) | | `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | ### Logging Configuration diff --git a/mcp/packages/server/src/McpAuthentication.test.ts b/mcp/packages/server/src/McpAuthentication.test.ts new file mode 100644 index 0000000000..715e60fdc9 --- /dev/null +++ b/mcp/packages/server/src/McpAuthentication.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import express from "express"; +import { McpAuthentication } from "./McpAuthentication"; + +test("OAuth is opt-in and requires multi-user mode", () => { + assert.equal(new McpAuthentication(undefined, false).isOAuthEnabled, false); + assert.equal(new McpAuthentication("", true).isOAuthEnabled, false); + assert.throws(() => new McpAuthentication("https://penpot.example/api/mcp/oauth", false), /multi-user/); + for (const uri of [ + "not a URL", + "http://remote.example/auth", + "https://user:secret@example.com/auth", + "https://example.com/auth?x=1", + "https://example.com/auth#fragment", + ]) { + assert.throws(() => new McpAuthentication(uri, true), /PENPOT_MCP_AUTH_URI/); + } + assert.equal(new McpAuthentication("http://localhost:3449/api/mcp/oauth", true).isOAuthEnabled, true); +}); + +async function withEndpoint(uri: string | undefined, run: (base: string) => Promise) { + const auth = new McpAuthentication(uri, true); + const app = express(); + app.all("/mcp", (req, res) => { + const result = auth.authenticate(req, res); + if (!result.isRejected) res.json({ token: result.userToken ?? null }); + }); + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + try { + await run(`http://127.0.0.1:${address.port}/mcp`); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +} + +test("missing credentials challenge the client only when OAuth is enabled", async () => { + await withEndpoint(undefined, async (base) => { + const response = await fetch(base); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { token: null }); + }); + await withEndpoint("https://penpot.example/api/mcp/oauth/", async (base) => { + const response = await fetch(base); + assert.equal(response.status, 401); + assert.equal( + response.headers.get("www-authenticate"), + 'Bearer resource_metadata="https://penpot.example/api/mcp/oauth/resource", scope="mcp"' + ); + assert.equal(response.headers.get("cache-control"), "no-store"); + }); +}); + +test("bearer credentials take precedence and query credentials remain supported", async () => { + for (const uri of [undefined, "https://penpot.example/api/mcp/oauth"]) { + await withEndpoint(uri, async (base) => { + const bearer = await fetch(base + "?userToken=query-token", { + headers: { Authorization: "bEaReR header-token" }, + }); + assert.deepEqual(await bearer.json(), { token: "header-token" }); + const query = await fetch(base + "?userToken=query-token"); + assert.deepEqual(await query.json(), { token: "query-token" }); + }); + } +}); + +test("malformed credentials never fall back to another token", async () => { + await withEndpoint("https://penpot.example/api/mcp/oauth", async (base) => { + for (const header of ["Basic abc", "Bearer", "Bearer two tokens", "Bearer a, Bearer b"]) { + const response = await fetch(base + "?userToken=valid", { headers: { Authorization: header } }); + assert.equal(response.status, 401, header); + assert.deepEqual(await response.json(), { error: "invalid_token" }); + } + for (const query of ["?userToken=a&userToken=b", "?userToken=", "?userToken=a%20b"]) { + assert.equal((await fetch(base + query)).status, 401, query); + } + }); +}); diff --git a/mcp/packages/server/src/McpAuthentication.ts b/mcp/packages/server/src/McpAuthentication.ts new file mode 100644 index 0000000000..6df14f6733 --- /dev/null +++ b/mcp/packages/server/src/McpAuthentication.ts @@ -0,0 +1,120 @@ +import type { Request, Response } from "express"; + +/** + * Result of processing credentials from an MCP HTTP request. + */ +export class McpAuthenticationResult { + /** + * @param userToken the extracted user token, if present + * @param isRejected whether the request was rejected and its HTTP error response sent + */ + constructor( + public readonly userToken: string | undefined, + public readonly isRejected: boolean = false + ) {} +} + +/** + * HTTP credential extraction and optional OAuth discovery for MCP clients. + * + * Tokens retain their existing role of matching requests to Penpot connections. + */ +export class McpAuthentication { + public readonly isOAuthEnabled: boolean; + private readonly resourceMetadataUri?: string; + + /** + * Creates a new MCP authentication handler. + * + * @param authUri the authentication URI from PENPOT_MCP_AUTH_URI, or undefined/empty to disable OAuth + * @param multiUser whether the server is running in multi-user mode + */ + constructor(authUri: string | undefined, multiUser: boolean) { + if (!authUri?.trim()) { + this.isOAuthEnabled = false; + } else { + if (!multiUser) { + throw new Error("PENPOT_MCP_AUTH_URI (which enables OAuth) requires multi-user mode"); + } + + // validate the authentication URI + let uri: URL; + try { + uri = new URL(authUri); + } catch { + throw new Error("PENPOT_MCP_AUTH_URI must be an absolute HTTP(S) URL"); + } + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(uri.hostname); + if ( + (uri.protocol !== "https:" && !(uri.protocol === "http:" && loopback)) || + uri.username || + uri.password || + uri.search || + uri.hash + ) { + throw new Error( + "PENPOT_MCP_AUTH_URI must use HTTPS (HTTP is allowed on loopback) without credentials, query, or fragment" + ); + } + + this.resourceMetadataUri = uri.href.replace(/\/$/, "") + "/resource"; + this.isOAuthEnabled = true; + } + } + + /** + * Extracts credentials. + * If credentials are absent while OAuth is enabled, sends an authentication error. + */ + public authenticate(req: Request, res: Response): McpAuthenticationResult { + let userToken: string | undefined; + + // check for a Bearer token in the Authorization header + const header = req.headers.authorization; + if (header !== undefined) { + const headerCount = req.rawHeaders.filter( + (_, i) => i % 2 === 0 && req.rawHeaders[i].toLowerCase() === "authorization" + ).length; + const match = /^Bearer +([A-Za-z0-9\-._~+/]+=*)$/i.exec(header); + if (headerCount !== 1 || !match) { + this.rejectOAuth(res, "invalid_token"); + return new McpAuthenticationResult(undefined, true); + } + userToken = match[1]; + } + // check for a userToken query parameter + else if (req.query.userToken !== undefined) { + const value = req.query.userToken; + if (typeof value !== "string" || !/^[A-Za-z0-9\-._~+/]+=*$/.test(value)) { + this.rejectOAuth(res, "invalid_token"); + return new McpAuthenticationResult(undefined, true); + } + userToken = value; + } + + // NOTE: The MCP server does not validate userToken against the backend. + // It uses the token to match MCP requests to a connection established + // from the Penpot webapp. Without that matching connection, requests + // cannot access or modify designs. The user must be logged in to Penpot + // to establish that connection; Penpot login and file permissions thus + // provide the authentication and access control boundary. + + if (this.isOAuthEnabled && !userToken) { + this.rejectOAuth(res); + return new McpAuthenticationResult(undefined, true); + } else { + return new McpAuthenticationResult(userToken); + } + } + + private rejectOAuth(res: Response, error?: "invalid_token"): void { + let challenge = "Bearer"; + if (this.resourceMetadataUri) { + challenge += ` resource_metadata="${this.resourceMetadataUri}", scope="mcp"`; + } + if (error) challenge += `${this.resourceMetadataUri ? "," : ""} error="${error}"`; + res.setHeader("WWW-Authenticate", challenge); + res.setHeader("Cache-Control", "no-store"); + res.status(401).json({ error: error ?? "unauthorized" }); + } +} diff --git a/mcp/packages/server/src/PenpotMcpServer.http.test.ts b/mcp/packages/server/src/PenpotMcpServer.http.test.ts index 2a72ed5305..693e3197ab 100644 --- a/mcp/packages/server/src/PenpotMcpServer.http.test.ts +++ b/mcp/packages/server/src/PenpotMcpServer.http.test.ts @@ -16,6 +16,7 @@ beforeEach(async () => { process.env.PENPOT_MCP_DEVENV = "false"; process.env.PENPOT_MCP_REPL_ENABLE = "false"; delete process.env.PENPOT_MCP_REDIS_URI; + delete process.env.PENPOT_MCP_AUTH_URI; server = new PenpotMcpServer(true); baseUrl = `http://127.0.0.1:${server.port}`; await server.start(); @@ -26,7 +27,7 @@ afterEach(async () => { process.env = previousEnv; }); -async function modernRequest(method: string, params: Record = {}, query = "") { +async function modernRequest(method: string, params: Record = {}, query = "", bearer?: string) { return fetch(`${baseUrl}/mcp${query}`, { method: "POST", headers: { @@ -34,6 +35,7 @@ async function modernRequest(method: string, params: Record = { Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": method, + ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), ...(typeof params.name === "string" ? { "Mcp-Name": params.name } : {}), }, body: JSON.stringify({ @@ -93,7 +95,8 @@ test("isolates user tokens across overlapping tool calls with the same request I name: "execute_code", arguments: { code: "return 1;" }, }, - `?userToken=${token}` + token === "alice" ? "" : `?userToken=${token}`, + token === "alice" ? token : undefined ); const body = (await response.json()) as { result: CallToolResult }; assert.equal(response.status, 200, JSON.stringify(body)); @@ -135,3 +138,30 @@ test("supports older Streamable HTTP clients without allocating a session", asyn await client.close(); } }); + +test("OAuth challenges missing credentials and accepts bearer and query tokens", async () => { + await server.stop(); + process.env.PENPOT_MCP_AUTH_URI = "https://penpot.example/api/mcp/oauth"; + server = new PenpotMcpServer(true); + await server.start(); + + const missing = await modernRequest("tools/list"); + assert.equal(missing.status, 401); + assert.match(missing.headers.get("www-authenticate") ?? "", /resource_metadata=/); + + for (const useBearer of [true, false]) { + const client = new Client({ name: "oauth-test", version: "1" }); + const transport = new StreamableHTTPClientTransport( + new URL(`${baseUrl}/mcp${useBearer ? "" : "?userToken=alice"}`), + useBearer ? { requestInit: { headers: { Authorization: "Bearer alice" } } } : undefined + ); + try { + await client.connect(transport); + const result = await client.listTools(); + const tool = result.tools.find((tool) => tool.name === "execute_code"); + assert.ok(tool, "authenticated clients can discover execute_code"); + } finally { + await client.close(); + } + } +}); diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 73ffd83a9f..2ec5cbe146 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -22,6 +22,7 @@ import { ReadTaigaIssueTool } from "./tools/ReadTaigaIssueTool"; import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; +import { McpAuthentication } from "./McpAuthentication"; /** * Session context for request-scoped data. @@ -111,6 +112,7 @@ export class PenpotMcpServer { private app!: Express; private httpServer?: HttpServer; private readonly mcpHandler: McpHttpHandler; + private readonly authentication: McpAuthentication; public readonly pluginBridge: PluginBridge; private readonly replServer: ReplServer | null; private apiDocs: ApiDocs; @@ -144,6 +146,7 @@ export class PenpotMcpServer { private readonly tenant: string; constructor(private isMultiUser: boolean = false) { + this.authentication = new McpAuthentication(process.env.PENPOT_MCP_AUTH_URI, isMultiUser); // read port configuration from environment variables this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "localhost"; this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10); @@ -298,11 +301,16 @@ export class PenpotMcpServer { private setupHttpEndpoints(): void { const handleMcpRequest = toNodeHandler(this.mcpHandler); this.app.all("/mcp", async (req, res) => { - const userToken = req.query.userToken as string | undefined; + const authResult = this.authentication.authenticate(req, res); + if (authResult.isRejected) { + return; + } this.logger.info( - `Received MCP request: method=${req.body?.method ?? ""}; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` + `Received MCP request: method=${req.body?.method ?? ""}; userTokenFp=${PenpotMcpServer.tokenFingerprint(authResult.userToken)}` + ); + await this.sessionContext.run({ userToken: authResult.userToken }, () => + handleMcpRequest(req, res, req.body) ); - await this.sessionContext.run({ userToken }, () => handleMcpRequest(req, res, req.body)); }); } @@ -316,6 +324,7 @@ export class PenpotMcpServer { return new Promise((resolve, reject) => { this.httpServer = this.app.listen(this.port, this.host, async () => { this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`); + this.logger.info(`OAuth enabled: ${this.authentication.isOAuthEnabled}`); this.logger.info( `Multi-instance mode with Redis-backed transport: ${this.redisBridge ? "true" : "false"}` ); diff --git a/mcp/scripts/start-mcp-devenv-oauth b/mcp/scripts/start-mcp-devenv-oauth new file mode 100755 index 0000000000..230dbf4029 --- /dev/null +++ b/mcp/scripts/start-mcp-devenv-oauth @@ -0,0 +1,19 @@ +#!/bin/sh + +# This starts the MCP server in multi-user mode with OAuth enabled, for +# testing OAuth in the Penpot development environment (assuming devenv). +# Agentic tooling expects the single-user server from start-mcp-devenv. + +set -e + +if [ -z "$PENPOT_PUBLIC_URI" ]; then + echo "PENPOT_PUBLIC_URI is not set; run this inside the devenv container." >&2 + exit 1 +fi + +PENPOT_MCP_AUTH_URI="${PENPOT_PUBLIC_URI%/}/api/mcp/oauth" + +echo "OAuth authorization URI: $PENPOT_MCP_AUTH_URI" +echo "MCP endpoint for clients: ${PENPOT_PUBLIC_URI%/}/mcp/stream" + +PENPOT_MCP_SERVER_HOST=0.0.0.0 PENPOT_MCP_REMOTE_MODE=true PENPOT_MCP_AUTH_URI="$PENPOT_MCP_AUTH_URI" pnpm run bootstrap:multi-user From 3cc91e8ba218f3ee068429a38a83a10f8c3f7c32 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 28 Oct 2026 18:44:36 +0100 Subject: [PATCH 30/65] :recycle: Take the command limits before the root ones in the RPC limiter (#12686) * :recycle: Take the command limits before the root ones in the RPC limiter The RPC concurrency limiter took the root limiters (the ones every request passes through) before the ones a command declares, so a request waiting for the limits of its own command held the root permits of its profile while it waited: a profile with a pile-up of heavy requests saw its other requests queue behind them and be rejected with 429. That order was forced by two commands that entered the limiter from inside their handler: a command that declares no limits of its own can only take them deeper than the root ones, so two paths of one profile could take the same limiters in opposite order and park for good. The commands declare their limits in their metadata now and their handlers call the work directly, so no command enters the limiter from inside its handler and every path takes the limiters in the same order. The root limiters are appended to the sequence first, so the limits a command declares end up outermost and are taken first: a request waiting for them does not hold the root permits of its profile. The photo of a team keeps its limits, because it declares them now that the helper does not. A call from inside a handler can only reenter the limits that thread already holds, and it is refused when it asks for a new one, because that would take it after the root ones and invert the order against the other paths. The key of a limit is resolved the same way in every path, so both paths point at the same limiter and the reentrancy check sees it. Every entry of the shipped climit.edn declares a queue, so the number of requests waiting for the same limit is bounded instead of piling up until the timeout. The production file lives outside the repository and has to be mirrored to pick them up. The tests cover the acquisition order of a command with and without limits of its own, the order of the limits as a whole (a ring of any size fails, not only a pair in opposite order), the nested call that asks for a limit the thread does not hold, and the queues and timeouts of the shipped configuration. The import of a binfile declares its request as multipart in its test, the way the commands that take an upload map do. AI-assisted-by: minimax-m3.1-flash AI-assisted-by: deepseek-v4.1-flash * :wrench: Keep the commands' limits and the shipped config in sync `profile-plugin-ops` guarded only an UPDATE on the caller's own profile row, which the row lock already serializes, so its global cap could only reject a burst of different profiles toggling plugins. Both commands declare the by-profile limit alone now, and the example config drops the entry. A declared id without an entry does not limit anything: `wrap` logs a warning and runs the handler with no limiter at all, which is how a command ends up unbounded in production. A test fails now when a command declares an id the config does not list, and the header of the example says the production file mirrors the shape and lists every declared id, not the values. AI-assisted-by: deepseek-v4.1-flash * :books: Record that background work is not waited for with a sleep A long command moved to the background reports its own completion, so polling it with a sleep inside a foreground command only blocks the session and the notification arrives anyway. The rule says to keep working on something else, or to end the response and wait for it. AI-assisted-by: minimax-m3.1-flash --- .serena/memories/backend/subtleties.md | 7 +- .serena/memories/critical-info.md | 1 + backend/resources/climit.edn | 38 ++- backend/src/app/rpc/climit.clj | 174 ++++------ backend/src/app/rpc/commands/media.clj | 13 +- backend/src/app/rpc/commands/plugins.clj | 6 +- backend/src/app/rpc/commands/profile.clj | 15 +- backend/src/app/rpc/commands/teams.clj | 3 + backend/test/backend_tests/climit_test.clj | 307 +++++++----------- backend/test/backend_tests/helpers.clj | 10 +- .../test/backend_tests/rpc_binfile_test.clj | 16 +- .../test/backend_tests/rpc_climit_test.clj | 134 +++++++- 12 files changed, 379 insertions(+), 345 deletions(-) diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index e778f31f8e..1b4fa4ddec 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -17,9 +17,10 @@ - The waiting counters are a field of the `ConcurrencyLimiter` component (a `ConcurrentHashMap` of name to `AtomicLong`), so they start at zero, die with the component, and the sampler that closes over that map republishes only its own names every 15 s; the sampler stops with the component in `halt-key!`. `impl-invoke` pairs one decrement with one increment through a per-call flag: the flag turns a second call into a no-op and the `finally` guarantees that there is one. The increment sits inside the `try` and the decrement lives only in the acquired callback and the `finally`, so a handler that throws, a rejection and a throwable from the publishing path all leave the count where it was and the thread without the limiter marked as held (a leftover mark would skip that limiter for that key for ever). Concurrent `inc`/`dec` publications of two threads can land out of order and the value can read low for a moment; the republish corrects it. - The `app.rpc.climit` log lines carry `queue`, `queue-max`, `permits`, `elapsed`, `code` and `reason`, and their `id` carries the key (`name/`). That is the only place where the profile behind a queue is visible: a pile-up is chased there, on demand, not in the metrics. The `queue` of the line is the calls inside the limiter minus its permits, so it reads 0 for a lone caller that times out: that line goes out at TRACE instead of WARN, and the rejection count has to come from the metric. - Nothing in the limiter path has a timeout unless the config sets one, and `psm/acquire` then calls `Semaphore.acquire`, which parks the calling thread forever. A parked thread still counts as in-system for every outer limiter of its chain, so the outermost one (the method's own by-profile limiter) stops granting permits and answers 429 to everything. A limiter without `:queue` gets an unlimited queue and therefore never rejects: its pile-up is silent, with no 429 and no log line except the WARN `enqueued`/`acquired` lines of the keyed id. -- Deadlock shape between a command's own limits and the root ones (fixed): a command declaring its own limits held `process-image/by-profile` before `root/by-profile`, while a command with no limits of its own takes the roots first and then re-enters with the same keyed limits from inside its handler (`create-file-media-object-from-url`, `upload-photo`) and waits for them holding a root permit. Two calls of one profile then held the same two limiters inverted and parked for good: 69 threads at 0 CPU, an audit limiter with 0 acquisitions in 24h and 100% 429s, on an instance that never served a 204. -- `climit/wrap` now appends the root limiters after the command's own, so `root/global`/`root/by-profile` end up outermost and every path meets them first; the nested `climit/invoke!` chains carry only the command's own limits and run inside them, which puts both paths in the same order. `impl-invoke` also keeps, per thread, the set of limiters that thread is inside of and runs the handler inline when a call asks for one it already holds, instead of queueing behind itself. That check relies on `pbh/invoke!` running the handler on the calling thread; the mark is removed in a `finally`. Commands declare the by-profile limit before the global one, which makes it the outermost of the pair: keep that declaration order, the reverse one would put two paths back in inverted order. The two nested call sites (`create-file-media-object-from-url`, `upload-photo`) still share `process-image` with the enclosing call instead of getting a limiter of their own, so the comment there about keeping the download out of that limit no longer holds. -- The shipped `backend/resources/climit.edn` sets a timeout on every limiter, 60s for operations that can be slow and 30s otherwise, with a test that fails when an entry ships without one, and `init-key` logs a WARN for every entry without a timeout. The production file lives outside the repository: mirror it there, and remember that a parked thread only goes away with a restart or a deploy. +- Deadlock shape between a command's own limits and the root ones (fixed): a command declaring its own limits held `process-image/by-profile` before `root/by-profile`, while a command with no limits of its own takes the roots first and then re-enters with the same keyed limits from inside its handler (`create-file-media-object-from-url`, `upload-photo`) and waits for them holding a root permit. Two calls of one profile then held the same two limiters inverted and parked for good: 69 threads at 0 CPU, an audit limiter with 0 acquisitions in 24h and 100% 429s, on an instance that never served a 204. Both commands declare their limits now, so the shape cannot happen any more: the root limits are taken last and every path takes the limiters in the same order. +- `climit/wrap` takes the command's own limits before the root ones: the root limiters are appended to the sequence first and the last element of the sequence is the outermost one, so `root/global`/`root/by-profile` end up innermost. A request waiting for its own limits no longer holds the root permits of its profile, which is what kept a pile-up of heavy requests from blocking the other commands of that profile. Every acquisition goes through that wrapper, so every path takes the limiters in the same order: there is no way to enter the limiter from inside a handler, and a command that calls another command internally would take the limiters twice (the second call waits for a permit the first one holds and answers 429 when the timeout runs out). The key of a limit is a function of the params (a keyword is looked up in them, a function is applied to them, any other value is the key itself). Commands declare the by-profile limit before the global one, which makes it the outermost of the pair, and a test fails when the relation between all the limits the commands declare cannot be sorted: two commands that take the same limits in opposite order, or a ring of three, leave the paths of one profile holding them in opposite order. +- The two commands that used to enter climit from inside their handler declare their limits now, so the whole command runs inside them: the download of an external image takes a `process-image` permit, and that limit has one permit per profile, so a slow download holds the image processing of that profile for as long as it takes. It takes the global permit of that limit too (8 permits, queue 20), so eight slow downloads of eight different profiles leave the image processing of every profile waiting. The copy of the body has no timeout (the 30 s of the request covers the connection and the headers), so a server that stops sending keeps the permits until the operating system gives up. The storage write of the profile photo runs inside its limit too. +- The shipped `backend/resources/climit.edn` sets a timeout and a queue on every limiter, 60s for operations that can be slow and 30s otherwise, with a test that fails when an entry ships without one, and `init-key` logs a WARN for every entry without a timeout. The queue matters because the root limits no longer bound the waiters: a command takes its own limits first, so a pile-up waits in the queue of that limit. The file and the declarations have to list the same ids: a `::climit/id` without an entry makes `wrap` log `no config found for specified queue` and run the handler with no limiter at all, and a test fails when a command declares an id the file does not list. Only key a limit by profile when the work contends on something shared (`update-file`, images, fonts, thumbnails, the profile row): a global cap on a cheap command adds a rejection path nobody needs, which is why `profile-plugin-ops` and `submit-audit-events` declare only `by-profile`. The production file lives outside the repository: mirror it there, and remember that a parked thread only goes away with a restart or a deploy. ## DB helpers diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index d66a46eb84..00eb2cab6f 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -25,6 +25,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Align `let` binding values: when a `let` form has multiple bindings spanning several lines, align the value forms to the same column with spaces. - If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files, fix them with `scripts/paren-repair` BEFORE running lint/format checks. See `mem:scripts/paren-repair` for usage. - Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra. +- **Never wait for background work with a sleep.** A long command moved to the background reports its own completion: do not poll it with `sleep 150` (or any other wait) inside a foreground command, and do not re-read its output file to check whether it finished. Keep working on something else, or end the response and wait for the notification. # Project modules diff --git a/backend/resources/climit.edn b/backend/resources/climit.edn index 9b585edabc..1a5c8f1ecc 100644 --- a/backend/resources/climit.edn +++ b/backend/resources/climit.edn @@ -6,66 +6,68 @@ ;; 60s for operations that can be slow (file updates, images, fonts, thumbnails, ;; snapshots, imports) and 30s for the rest. ;; Note: a queue bounds how many waiters pile up, a timeout bounds how long one -;; waiter waits. Every entry sets a timeout; the by-profile ones also set a -;; queue, which is where a pile-up hurts. +;; waiter waits. Every entry sets a timeout and a queue: a command takes the +;; limits it declares before the root ones, so the queue of a limit is where +;; its own pile-up waits. The root limits bound what runs at the same time, +;; not what waits for it. +;; The production file lives outside this repository: mirror the shape there, +;; not these numbers. It has to list every id a command declares: an id without +;; an entry runs with no limiter at all. {:update-file/global - {:permits 20 :timeout 60000} + {:permits 20 :queue 50 :timeout 60000} :update-file/by-profile {:permits 1 :queue 5 :timeout 60000} :process-font/global - {:permits 4 :timeout 60000} + {:permits 4 :queue 10 :timeout 60000} :process-font/by-profile - {:permits 1 :timeout 60000} + {:permits 1 :queue 5 :timeout 60000} :process-image/global - {:permits 8 :timeout 60000} + {:permits 8 :queue 20 :timeout 60000} :process-image/by-profile - {:permits 1 :timeout 60000} + {:permits 1 :queue 5 :timeout 60000} :auth/global - {:permits 8 :timeout 30000} + {:permits 8 :queue 20 :timeout 30000} :root/global - {:permits 40 :timeout 30000} + {:permits 40 :queue 100 :timeout 30000} :root/by-profile {:permits 10 :queue 30 :timeout 30000} :file-thumbnail-ops/global - {:permits 20 :timeout 60000} + {:permits 20 :queue 50 :timeout 60000} :file-thumbnail-ops/by-profile - {:permits 2 :timeout 60000} + {:permits 2 :queue 10 :timeout 60000} :submit-audit-events/by-profile {:permits 1 :queue 3 :timeout 30000} :restore-file-snapshot/global - {:permits 3 :timeout 60000} + {:permits 3 :queue 5 :timeout 60000} :restore-file-snapshot/by-profile {:permits 1 :queue 2 :timeout 60000} :create-file-snapshot/global - {:permits 3 :timeout 60000} + {:permits 3 :queue 5 :timeout 60000} :create-file-snapshot/by-profile {:permits 1 :queue 2 :timeout 60000} :send-user-feedback/global - {:permits 4 :timeout 30000} + {:permits 4 :queue 10 :timeout 30000} :send-user-feedback/by-profile {:permits 1 :queue 3 :timeout 30000} :import-binfile/global - {:permits 4 :timeout 60000} + {:permits 4 :queue 10 :timeout 60000} :import-binfile/by-profile {:permits 1 :queue 2 :timeout 60000} - :profile-plugin-ops/global - {:permits 4 :timeout 30000} - :profile-plugin-ops/by-profile {:permits 1 :queue 2 :timeout 30000}} diff --git a/backend/src/app/rpc/climit.clj b/backend/src/app/rpc/climit.clj index 82fe9ff234..3f7330238e 100644 --- a/backend/src/app/rpc/climit.clj +++ b/backend/src/app/rpc/climit.clj @@ -5,7 +5,16 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.climit - "Concurrencly limiter for RPC." + "Concurrencly limiter for RPC. + + A command declares its limits in its `::climit/id` metadata and takes them + before the root ones, so a request waiting for its own limits does not hold + the root permits of its profile. + + Every acquisition goes through this wrapper, so every path takes the + limiters in the same order. A command that calls another command from + inside its handler would take them twice: the second call waits for a + permit the first one holds, and answers 429 when the timeout runs out." (:refer-clojure :exclude [run!]) (:require [app.common.exceptions :as ex] @@ -221,16 +230,6 @@ (def ^:private idseq (AtomicLong. 0)) -(def ^:private ^ThreadLocal held-limiters - ;; Limiters a thread is inside of, identified by the limiter instance, which - ;; already encodes the limit id and its key: the cache is per id+key pair. - ;; `pbh/invoke!` runs the handler on the calling thread, so a handler that - ;; enters climit again for a limit it holds finds it here. Without this a - ;; thread parks forever behind a permit it holds itself. - (ThreadLocal/withInitial - (reify java.util.function.Supplier - (get [_] (java.util.HashSet.))))) - (defn- impl-invoke [metrics counters cache config handler] (let [limit-id (::id config) @@ -249,69 +248,57 @@ :permits (or (:permits config) (:concurrency config) 1) :queue-max (or (:queue config) (:queue-size config))} limiter (cache/get cache limit-id (partial create-limiter config)) - held ^java.util.Set (.get ^ThreadLocal held-limiters)] + tpoint (ct/tpoint) + req-id (.incrementAndGet ^AtomicLong idseq) + waiting (volatile! true) + stop-waiting (fn [] + ;; Exactly one decrement per increment: the flag + ;; makes a second call a no-op and the `finally` + ;; guarantees that there is one. + (when @waiting + (vreset! waiting false) + (waiting-dec metrics counters limit-name)))] - (if (.contains held limiter) - ;; The calling thread already holds this limiter for this key: run the - ;; handler inline instead of queueing behind itself. - (do - (l/trc :hint "reentrant invocation" :id limit-id :label limit-label) - (handler)) + (try + ;; The increment lives inside the `try`, so a throwable from the + ;; publishing path unwinds through the `finally`: the count comes back + ;; down instead of staying up for good. + (waiting-inc metrics counters limit-name) - (let [tpoint (ct/tpoint) - req-id (.incrementAndGet ^AtomicLong idseq) - waiting (volatile! true) - stop-waiting (fn [] - ;; Exactly one decrement per increment: the flag - ;; makes a second call a no-op and the `finally` - ;; guarantees that there is one. - (when @waiting - (vreset! waiting false) - (waiting-dec metrics counters limit-name)))] + (let [stats (pbh/get-stats limiter)] + (log limit "enqueued" req-id stats nil nil nil)) - (.add held limiter) + (pbh/invoke! limiter (fn [] + (stop-waiting) + (let [elapsed (tpoint) + stats (pbh/get-stats limiter)] + (measure metrics mlabels elapsed) + (log limit "acquired" req-id stats elapsed nil nil) + (handler)))) - (try - ;; The increment lives inside the `try`, so a throwable from the - ;; publishing path unwinds through the `finally`: the thread drops the - ;; mark and the count comes back down instead of staying up for good. - (waiting-inc metrics counters limit-name) - - (let [stats (pbh/get-stats limiter)] - (log limit "enqueued" req-id stats nil nil nil)) - - (pbh/invoke! limiter (fn [] - (stop-waiting) - (let [elapsed (tpoint) - stats (pbh/get-stats limiter)] - (measure metrics mlabels elapsed) - (log limit "acquired" req-id stats elapsed nil nil) - (handler)))) - - (catch ExceptionInfo cause - (let [{:keys [type code] :as data} (ex-data cause)] - (if (= :bulkhead-error type) - (let [elapsed (tpoint) - stats (pbh/get-stats limiter) - reason (if (some? (:timeout data)) "timeout" "queue-full")] - (mtx/run! metrics - :id :rpc-climit-rejected - :inc 1 - :labels (into-array String [limit-name reason])) - (log limit "rejected" req-id stats elapsed code reason) - (ex/raise :type :concurrency-limit - :code code - :hint "concurrency limit reached" - :cause cause)) - (throw cause)))) - - (finally - (.remove held limiter) - (stop-waiting) + (catch ExceptionInfo cause + (let [{:keys [type code] :as data} (ex-data cause)] + (if (= :bulkhead-error type) (let [elapsed (tpoint) - stats (pbh/get-stats limiter)] + stats (pbh/get-stats limiter) + reason (if (some? (:timeout data)) "timeout" "queue-full")] + (mtx/run! metrics + :id :rpc-climit-rejected + :inc 1 + :labels (into-array String [limit-name reason])) + (log limit "rejected" req-id stats elapsed code reason) + (ex/raise :type :concurrency-limit + :code code + :hint "concurrency limit reached" + :cause cause)) + (throw cause)))) - (log limit "finished" req-id stats elapsed nil nil)))))))) + (finally + (stop-waiting) + (let [elapsed (tpoint) + stats (pbh/get-stats limiter)] + + (log limit "finished" req-id stats elapsed nil nil)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; MIDDLEWARE @@ -340,6 +327,16 @@ :else (throw (IllegalArgumentException. "unable to normalize limit"))))) +(defn- resolve-limit-key + "The key of a limit, resolved the same way in every path: a keyword is + looked up in the params, a function is applied to them, any other value is + the key itself." + [key params] + (cond + (keyword? key) (get params key) + (fn? key) (key params) + :else key)) + (defn wrap [cfg handler {label ::sv/name :as mdata}] (if-let [climit (::rpc/climit cfg)] @@ -363,7 +360,7 @@ (fn [cfg params] (let [config (-> config - (assoc ::key (key-fn params)) + (assoc ::key (resolve-limit-key key-fn params)) (assoc ::label label) ;; NOTE: only used for debugging output (assoc ::params params))] @@ -373,40 +370,11 @@ (l/wrn :hint "no config found for specified queue" :id (id->str limit-id)) handler))) handler - ;; The root limiters go last so they end up outermost: every path - ;; meets them before the command's own limits. The nested - ;; `climit/invoke!` chains carry only the command's own limits and - ;; run inside those of the enclosing call, so this puts both paths - ;; in the same order. With the opposite order two calls of the same - ;; profile hold the same limiters inverted and park for good. - (concat (get-limits mdata) global-limits)) + ;; The root limiters go first so they end up innermost: a command + ;; takes the limits it declares before the root ones. Every path + ;; meets the limiters in the same order, because every acquisition + ;; goes through this wrapper. + (concat global-limits (get-limits mdata))) handler)) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; PUBLIC API -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- build-exec-chain - [{:keys [::label ::rpc/climit] :as cfg} f] - (reduce (fn [handler [limit-id limit-key]] - (if-let [config (get-config climit limit-id)] - (let [config (-> config - (assoc ::key limit-key) - (assoc ::label label))] - (fn [cfg params] - (let [config (assoc config ::params params)] - (invoke climit config (partial handler cfg params))))) - (do - (l/wrn :hint "config not found" :label label :id limit-id) - f))) - f - (get-limits cfg))) - -(defn invoke! - "Run a function in context of climit." - [{:keys [::rpc/climit] :as cfg} f params] - (let [f (if climit - (build-exec-chain cfg f) - f)] - (f cfg params))) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index c5a78df076..3ba28923b0 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -239,6 +239,8 @@ (sv/defmethod ::create-file-media-object-from-url {::doc/added "1.17" ::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]] + ::climit/id [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] ::sm/params schema:create-file-media-object-from-url} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (files/check-edition-permissions! pool profile-id file-id) @@ -262,16 +264,7 @@ (assoc :from-url? true) (assoc :content content) (assoc :name (d/nilv name "unknown")))] - - ;; NOTE: we use the climit here in a dynamic invocation because we - ;; don't want saturate the process-image limit with IO (download - ;; of external image) - - (-> cfg - (assoc ::climit/id [[:process-image/by-profile (:profile-id params)] - [:process-image/global]]) - (assoc ::climit/label "create-file-media-object-from-url") - (climit/invoke! #(db/run! %1 create-file-media-object %2) params)))) + (db/run! cfg create-file-media-object params))) ;; --- Clone File Media object (Upload and create from url) diff --git a/backend/src/app/rpc/commands/plugins.clj b/backend/src/app/rpc/commands/plugins.clj index 8f01b3b521..882ab5e597 100644 --- a/backend/src/app/rpc/commands/plugins.clj +++ b/backend/src/app/rpc/commands/plugins.clj @@ -34,8 +34,7 @@ (sv/defmethod ::add-profile-plugin {::doc/added "2.18" - ::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id] - [:profile-plugin-ops/global]] + ::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]] ::sm/params schema:add-profile-plugin ::sm/result ctp/schema:registry-entry ::db/transaction true} @@ -69,8 +68,7 @@ (sv/defmethod ::remove-profile-plugin {::doc/added "2.18" - ::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id] - [:profile-plugin-ops/global]] + ::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]] ::sm/params schema:remove-profile-plugin ::sm/result :nil ::db/transaction true} diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index c0cdf56971..339509b1ed 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -306,6 +306,8 @@ (sv/defmethod ::update-profile-photo {:doc/added "1.1" + ::climit/id [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] ::sm/params schema:update-profile-photo ::rpc/multipart-only-params [:file] ::sm/result :nil} @@ -355,13 +357,12 @@ :content-type (:mtype thumb)})) (defn upload-photo - [{:keys [::sto/storage] :as cfg} {:keys [file] :as params}] - (let [params (-> cfg - (assoc ::climit/id [[:process-image/by-profile (:profile-id params)] - [:process-image/global]]) - (assoc ::climit/label "upload-photo") - (climit/invoke! generate-thumbnail file))] - (sto/put-object! storage params))) + "Generate the thumbnail of a photo and store it. + + The caller declares the `process-image` limits: this helper runs inside + them, so a command that calls it has to take them." + [{:keys [::sto/storage] :as cfg} {:keys [file]}] + (sto/put-object! storage (generate-thumbnail cfg file))) ;; --- MUTATION: Delete Photo diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index f8b85ac0eb..3b1785830a 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -26,6 +26,7 @@ [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] @@ -1084,6 +1085,8 @@ (sv/defmethod ::update-team-photo {::doc/added "1.17" + ::climit/id [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] ::sm/params schema:update-team-photo ::rpc/multipart-only-params [:file]} [cfg {:keys [::rpc/profile-id file] :as params}] diff --git a/backend/test/backend_tests/climit_test.clj b/backend/test/backend_tests/climit_test.clj index b5cef6bda3..76aff9252a 100644 --- a/backend/test/backend_tests/climit_test.clj +++ b/backend/test/backend_tests/climit_test.clj @@ -38,20 +38,16 @@ (mapcat :labels) (vec))) -(defn- saw-call? - "Wait until a metrics call mentions `label`." - [label timeout] - (let [deadline (+ (System/currentTimeMillis) timeout)] - (loop [] - (cond - (some (fn [call] (some #{label} (:labels call))) @calls) - true - - (> (System/currentTimeMillis) deadline) - false - - :else - (do (Thread/sleep 10) (recur)))))) +(defn- invoke! + "Run `f` under the limits the cfg declares, the way a command does: the key + of each limit comes from the params, and the root limiters come after the + ones the cfg declares." + [cfg f params] + (let [wrapped (climit/wrap cfg + f + {::sv/name "test-command" + ::climit/id (::climit/id cfg)})] + (wrapped cfg params))) (defn- failed-type "Run `f` and return its value, or the type of the error it raised." @@ -101,11 +97,10 @@ ;; ACQUISITION ORDER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(t/deftest acquires-root-limits-before-the-command-own-limits - ;; A command declaring its own limits must meet the root limiters first. - ;; The nested `climit/invoke!` calls take only the command's own limits and - ;; run inside the root ones, so any other order lets two calls of the same - ;; profile hold the same limiters in opposite order and park for good. +(t/deftest acquires-the-command-own-limits-before-the-root-limits + ;; A command declaring its own limits must meet them before the root + ;; limiters. No command enters climit from inside its handler any more, so + ;; every path takes the limiters in the same order. (record-metrics (fn [] (th/with-climit [climit test-config] @@ -113,124 +108,57 @@ wrapped (climit/wrap {::rpc/climit climit} (fn [_ _] :ok) mdata) result (wrapped {::rpc/climit climit} {::rpc/profile-id (uuid/next)})] (t/is (= :ok result)) - (t/is (= ["root/by-profile" - "root/global" - "process-image/by-profile" - "process-image/global"] + (t/is (= ["process-image/by-profile" + "process-image/global" + "root/by-profile" + "root/global"] (acquired-in-order)))))))) -(t/deftest nested-limits-do-not-deadlock-with-the-command-own-limits - ;; Two paths over one profile: a command declaring its own limits (like - ;; `upload-file-media-object`) and a command that takes the root limiters and - ;; then asks for the same limits from inside its handler (like - ;; `create-file-media-object-from-url`). Each path holds what the other one - ;; waits for the moment the order differs, and nothing releases. +(t/deftest the-key-of-a-limit-comes-from-the-params + ;; The key of a limit is a function of the params: a keyword is looked up in + ;; them, and that is what tells one profile from another. Two calls with the + ;; same key share the limiter, and two calls with a different key do not. + (th/with-climit [climit {:test-key/by-profile {:permits 1 :timeout 2000}}] + (let [pid (uuid/next) + limits [[:test-key/by-profile ::rpc/profile-id]] + cfg {::rpc/climit climit + ::climit/id limits} + gate (promise) + entered (promise) + wrapped (climit/wrap cfg + (fn [_ params] + ;; only the call of the first profile waits + (when (= pid (::rpc/profile-id params)) + (deliver entered true) + @gate) + :ok) + {::sv/name "test-command" ::climit/id limits}) + holder (future (wrapped cfg {::rpc/profile-id pid}))] + + ;; The call of the same profile waits for the permit this thread holds. + (t/is (true? (deref entered 5000 false))) + (t/is (= :timeout (deref (future (wrapped cfg {::rpc/profile-id pid})) 300 :timeout))) + + ;; The call of another profile takes the permit of its own key. + (t/is (= :ok (deref (future (wrapped cfg {::rpc/profile-id (uuid/next)})) 5000 :timeout))) + + (deliver gate true) + (t/is (= :ok (deref holder 5000 :timeout)))))) + +(t/deftest a-command-without-limits-takes-the-root-limits + ;; A command that declares no limits of its own still takes the root ones, + ;; and in the same order as the ones of a command that declares them. (record-metrics (fn [] (th/with-climit [climit test-config] - (let [pid (uuid/next) - gate (promise) - entered (promise) + (let [mdata {::sv/name "test-command"} + wrapped (climit/wrap {::rpc/climit climit} (fn [_ _] :ok) mdata) + result (wrapped {::rpc/climit climit} {::rpc/profile-id (uuid/next)})] + (t/is (= :ok result)) + (t/is (= ["root/by-profile" + "root/global"] + (acquired-in-order)))))))) - handler-b (fn [cfg params] - (deliver entered true) - @gate - (-> cfg - (assoc ::climit/id [[:process-image/by-profile pid] - [:process-image/global]]) - (assoc ::climit/label "path-b") - (climit/invoke! (fn [_ _] :nested-ok) params))) - - wrapped-a (climit/wrap {::rpc/climit climit} - (fn [_ _] :ok) - {::sv/name "path-a" ::climit/id image-limits}) - - wrapped-b (climit/wrap {::rpc/climit climit} - handler-b - {::sv/name "path-b"})] - - ;; Path B takes the root limiters and waits inside its handler. - (let [fb (future (failed-type #(wrapped-b {::rpc/climit climit} - {::rpc/profile-id pid})))] - (t/is (true? (deref entered 5000 false))) - - ;; Path A, started now, takes its own limiters and then waits for the - ;; root ones, which B holds. The recorded metrics are how the test - ;; knows A got that far instead of guessing with a sleep. - (reset! calls []) - (let [fa (future (failed-type #(wrapped-a {::rpc/climit climit} - {::rpc/profile-id pid})))] - (t/is (true? (saw-call? "root/by-profile" 5000))) - - ;; Let B ask for the limits A holds: if the order differs, each path - ;; now waits for a permit the other one holds. - (deliver gate true) - - (t/is (= :ok (deref fa 15000 :timeout))) - (t/is (= :nested-ok (deref fb 15000 :timeout)))))))))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; REENTRANCY -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(t/deftest reentrant-invoke-does-not-consume-a-second-permit - ;; A handler entering climit for a limit it already holds must not queue - ;; behind itself: it runs inline and takes no second permit. Another thread - ;; asking for the same limiter still waits, so the limit stays in place. - (th/with-climit [climit {:process-image/by-profile - {:permits 1 :timeout 2000}}] - (let [pid (uuid/next) - cfg {::rpc/climit climit - ::climit/id [[:process-image/by-profile pid]]} - inner (promise) - other (promise) - handler (fn [cfg params] - (deliver inner - (failed-type #(climit/invoke! cfg (fn [_ _] :inner-ok) params))) - (deliver other (future (climit/invoke! cfg (fn [_ _] :other-ok) params))) - (t/is (= :waiting (deref @other 300 :waiting))) - :outer-ok) - - outcome (failed-type #(climit/invoke! cfg handler {}))] - - ;; With no reentrancy handling the inner call waits for the permit this very - ;; thread holds and ends failing by timeout. - (t/is (= :inner-ok (deref inner 5000 :timeout))) - (t/is (= :outer-ok outcome)) - - ;; once the outer call releases, the other thread gets its permit - (t/is (= :other-ok (deref @other 5000 :timeout)))))) - -(t/deftest reentrant-tracking-is-cleaned-up-when-the-handler-fails - ;; If the mark is not removed, the thread keeps believing it holds the - ;; limiter and every later call of that key would skip the limit. - (th/with-climit [climit {:process-image/by-profile - {:permits 1 :timeout 1000}}] - (let [pid (uuid/next) - cfg {::rpc/climit climit - ::climit/id [[:process-image/by-profile pid]]} - holding (promise) - gate (promise)] - - ;; The failing call runs on this thread, so this thread is the one that - ;; must forget it held the limiter. - (t/is (thrown? clojure.lang.ExceptionInfo - (climit/invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {}))) - - ;; another thread takes the only permit - (let [fb (future (climit/invoke! cfg (fn [_ _] - (deliver holding true) - @gate - :held) - {}))] - (t/is (true? (deref holding 5000 false))) - - ;; this thread must wait for it, not skip the limiter - (t/is (= :concurrency-limit - (failed-type #(climit/invoke! cfg (fn [_ _] :inner-ok) {})))) - - (deliver gate true) - (t/is (= :held (deref fb 5000 :timeout))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SHIPPED CONFIG @@ -239,14 +167,19 @@ (t/deftest example-config-bounds-every-wait ;; A limiter without `:timeout` parks the calling thread forever, which is ;; how a limit nobody releases turns into lost worker threads and days of - ;; silent 429s. Every entry of the shipped config must carry one. + ;; silent 429s. Every entry of the shipped config must carry one. A limiter + ;; without `:queue` lets its waiters pile up without bound, and a command + ;; takes its own limits before the root ones, so the queue of a limit is + ;; where its own pile-up waits. (let [path (io/resource "climit.edn")] (t/is (some? path)) (let [data (edn/read-string (slurp path))] (t/is (seq data)) (doseq [[id config] data] (t/is (int? (:timeout config)) - (str id " must declare a timeout")))))) + (str id " must declare a timeout")) + (t/is (int? (:queue config)) + (str id " must declare a queue")))))) (t/deftest warns-when-a-limiter-has-no-timeout ;; The config that production loads lives outside the repository and is @@ -321,17 +254,17 @@ (let [name (limiter-name :test-waiting/by-profile) entered (promise) gate (promise) - holder (future (climit/invoke! (limiter-cfg climit :test-waiting/by-profile "a") - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! (limiter-cfg climit :test-waiting/by-profile "a") + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) ;; the holder is running, not waiting (reset! calls []) - (let [waiter (future (climit/invoke! (limiter-cfg climit :test-waiting/by-profile "a") - (fn [_ _] :waited) - {}))] + (let [waiter (future (invoke! (limiter-cfg climit :test-waiting/by-profile "a") + (fn [_ _] :waited) + {}))] (t/is (true? (wait-until #(= 1 (waiting-last name)) 5000))) (t/is (= 1 (waiting-count climit name))) @@ -350,30 +283,30 @@ (fn [] (th/with-climit [climit {:test-waiting-keys/by-profile {:permits 1 :timeout 3000}}] (let [name (limiter-name :test-waiting-keys/by-profile) - gates {:a (promise) :b (promise)} + gates {"a" (promise) "b" (promise)} holders (into {} (map (fn [k] (let [entered (promise)] [k {:entered entered - :future (future (climit/invoke! (limiter-cfg climit :test-waiting-keys/by-profile k) - (fn [_ _] - (deliver entered true) - @(gates k) - :held) - {}))}]))) - [:a :b])] + :future (future (invoke! (limiter-cfg climit :test-waiting-keys/by-profile k) + (fn [_ _] + (deliver entered true) + @(gates k) + :held) + {}))}]))) + ["a" "b"])] - (doseq [k [:a :b]] + (doseq [k ["a" "b"]] (t/is (true? (deref (:entered (holders k)) 5000 false)))) ;; one call per key, waiting behind its own holder (let [waiters (into {} (map (fn [k] - [k (future (climit/invoke! (limiter-cfg climit :test-waiting-keys/by-profile k) - (fn [_ _] :waited) - {}))])) - [:a :b])] + [k (future (invoke! (limiter-cfg climit :test-waiting-keys/by-profile k) + (fn [_ _] :waited) + {}))])) + ["a" "b"])] (t/is (true? (wait-until #(= 2 (waiting-count climit name)) 5000))) ;; the increment that reaches 2 always publishes 2, whichever waiter @@ -381,9 +314,9 @@ ;; contract, the value is. (t/is (some #(= 2 %) (waiting-value-of name))) - (doseq [k [:a :b]] (deliver (gates k) true)) + (doseq [k ["a" "b"]] (deliver (gates k) true)) - (doseq [k [:a :b]] + (doseq [k ["a" "b"]] (t/is (= :waited (deref (waiters k) 5000 :timeout))) (t/is (= :held (deref (:future (holders k)) 5000 :timeout)))) @@ -413,17 +346,17 @@ cfg (limiter-cfg climit :test-rejected/by-profile "a") entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) (reset! calls []) ;; the queue holds one waiter, so the next call is rejected right away - (let [queued (future (failed-type #(climit/invoke! cfg (fn [_ _] :queued) {})))] + (let [queued (future (failed-type #(invoke! cfg (fn [_ _] :queued) {})))] (t/is (true? (wait-until #(= 1 (waiting-last name)) 5000))) - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :extra) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :extra) {})))) (t/is (= [[name "queue-full"]] (rejections))) (deliver gate true) @@ -436,13 +369,13 @@ cfg (limiter-cfg climit :test-timeout/by-profile "a") entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) (reset! calls []) - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :late) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :late) {})))) (t/is (= [[name "timeout"]] (rejections))) (deliver gate true) @@ -458,14 +391,14 @@ cfg (limiter-cfg climit :test-rejected-baseline/by-profile "a") entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) (reset! calls []) - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :extra) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :extra) {})))) (t/is (= 0 (waiting-count climit name))) (t/is (true? (wait-until #(= 0 (waiting-last name)) 5000))) @@ -482,7 +415,7 @@ (reset! calls []) (t/is (thrown? clojure.lang.ExceptionInfo - (climit/invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {}))) + (invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {}))) (t/is (= 0 (waiting-count climit name))) (t/is (true? (wait-until #(= 0 (waiting-last name)) 5000)))))))) @@ -510,7 +443,7 @@ (reset! calls []) (with-redefs [mtx/run! (failing-first-waiting-publish calls)] (t/is (thrown? clojure.lang.ExceptionInfo - (climit/invoke! cfg (fn [_ _] :never) {})))) + (invoke! cfg (fn [_ _] :never) {})))) ;; the count came back down instead of staying up for good (t/is (= 0 (waiting-count climit name))) @@ -520,11 +453,11 @@ ;; run inline instead of waiting for the permit another thread holds. (let [entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :inline) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :inline) {})))) (deliver gate true) (t/is (= :held (deref holder 5000 :timeout))))))) @@ -549,13 +482,13 @@ (let [cfg (limiter-cfg climit :test-registry/by-profile "a") entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) - (let [waiter (future (climit/invoke! cfg (fn [_ _] :waited) {}))] + (let [waiter (future (invoke! cfg (fn [_ _] :waited) {}))] (t/is (true? (wait-until #(= 1.0 (gauge-value metrics name)) 5000))) (deliver gate true) (t/is (= :waited (deref waiter 5000 :timeout))) @@ -592,20 +525,20 @@ cfg (limiter-cfg climit :test-log/by-profile "a") entered (promise) gate (promise) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) (reset! calls []) - (let [q1 (future (climit/invoke! cfg (fn [_ _] :q1) {})) - q2 (future (climit/invoke! cfg (fn [_ _] :q2) {}))] + (let [q1 (future (invoke! cfg (fn [_ _] :q1) {})) + q2 (future (invoke! cfg (fn [_ _] :q2) {}))] ;; the two of them fill the queue, so the next one is rejected (t/is (true? (wait-until #(= 2 (waiting-count climit name)) 5000))) (t/is (some #(= 2 %) (waiting-value-of name))) - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :x) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :x) {})))) (let [props (::l/props (rejected-record records "queue-full"))] (t/is (= 1 (get props :permits))) @@ -630,14 +563,14 @@ gate (promise) capture (fn [_ action _req-id _stats _elapsed code reason] (swap! lines conj [action code reason])) - holder (future (climit/invoke! cfg - (fn [_ _] (deliver entered true) @gate :held) - {}))] + holder (future (invoke! cfg + (fn [_ _] (deliver entered true) @gate :held) + {}))] (t/is (true? (deref entered 5000 false))) (with-redefs [climit/log capture] - (t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :late) {})))) + (t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :late) {})))) (t/is (= [["rejected" :capacity-limit-reached "timeout"]] (filterv #(= "rejected" (first %)) @lines)))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 8aa3b684fc..58b36244ea 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -577,13 +577,19 @@ (defn init-climit "Build a concurrency limiter component from an inline config map. The optional second argument is the metrics instance to publish to (a - `metrics-stub` by default, where every `run!` is a no-op)." + `metrics-stub` by default, where every `run!` is a no-op). + + The root limiters are added with permits that no test reaches: every + request goes through them, and a test that does not list them would log a + warning per request." ([config] (init-climit config (metrics-stub))) ([config metrics] (let [file (doto (java.io.File/createTempFile "climit" ".edn") (.deleteOnExit))] - (spit file (pr-str config)) + (spit file (pr-str (merge {:root/global {:permits 1000 :timeout 3000} + :root/by-profile {:permits 1000 :timeout 3000}} + config))) (ig/init-key ::rpc/climit {::climit/enabled true ::climit/config (.getAbsolutePath file) diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index e684d5b7e0..f15a261040 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -175,14 +175,14 @@ (defn- import-file [profile path] - (let [out (th/command! {::th/type :import-binfile - ::rpc/profile-id (:id profile) - :project-id (:default-project-id profile) - :name "imported" - :file {:filename "imported.penpot" - :size (fs/size path) - :path path - :mtype "application/zip"}})] + (let [out (th/multipart-command! {::th/type :import-binfile + ::rpc/profile-id (:id profile) + :project-id (:default-project-id profile) + :name "imported" + :file {:filename "imported.penpot" + :size (fs/size path) + :path path + :mtype "application/zip"}})] (t/is (nil? (:error out))) (th/consume-sse (:result out)))) diff --git a/backend/test/backend_tests/rpc_climit_test.clj b/backend/test/backend_tests/rpc_climit_test.clj index b5b8e853d8..aa058f03d2 100644 --- a/backend/test/backend_tests/rpc_climit_test.clj +++ b/backend/test/backend_tests/rpc_climit_test.clj @@ -6,13 +6,22 @@ (ns backend-tests.rpc-climit-test (:require + [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.util.services :as-alias sv] [backend-tests.helpers :as th] + [clojure.edn :as edn] + [clojure.java.io :as io] [clojure.test :as t])) (t/use-fixtures :once th/state-init) +(defn- command-limits + "The `::climit/id` declaration of the RPC command named `name`." + [name] + (let [[mdata _] (get (:app.rpc/methods th/*system*) (keyword name))] + (::climit/id mdata))) + (defn- method-limit-declarations "The `::climit/id` declarations of every RPC command, as the running system wraps them." @@ -24,11 +33,87 @@ (when-let [limits (::climit/id mdata)] [mdata limits]))))) +(defn- declared-ids + "The limit ids a command declares, in the order it declares them." + [limits] + (cond + (keyword? limits) + [limits] + + (vector? (first limits)) + (mapv first limits) + + :else + [(first limits)])) + +(defn- limit-order-edges + "The pairs of limit ids the commands declare, as edges: the first one of the + pair is taken before the second one." + [declarations] + (reduce (fn [edges [mdata limits]] + (let [ids (declared-ids limits) + size (count ids)] + (into edges + (for [i (range size) + j (range (inc i) size)] + [(nth ids i) (nth ids j)])))) + #{} + declarations)) + +(defn- limit-order-cycle + "The limit ids whose relative order the commands disagree about: the ones + left when the relation cannot be sorted. + + Two commands that take the same limiters in opposite order leave two paths + of one profile holding them in opposite order, and the threads park for + good. A ring of three does the same, so the relation is checked as a whole + and not pair by pair." + [edges] + (let [nodes (into #{} (mapcat identity) edges) + outgoing (reduce (fn [acc [a b]] (update acc a (fnil conj #{}) b)) {} edges) + indegree (reduce (fn [acc [_ b]] (update acc b (fnil inc 0))) {} edges)] + (loop [pending nodes + degrees indegree] + (let [ready (filterv #(zero? (get degrees % 0)) pending)] + (if (empty? ready) + (seq pending) + (recur (apply disj pending ready) + (reduce (fn [degrees node] + (reduce (fn [degrees next] + (update degrees next (fnil dec 0))) + degrees + (get outgoing node))) + degrees + ready))))))) + +(t/deftest the-media-object-from-url-command-declares-its-own-limits + ;; The command used to enter climit from inside its handler, which forced + ;; every path to take the root limiters before the command ones. It declares + ;; its limits now, so there is a single place where they are read. + (t/is (= [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] + (command-limits "create-file-media-object-from-url")))) + +(t/deftest the-update-profile-photo-command-declares-its-own-limits + ;; The command used to enter climit from inside its handler, which forced + ;; every path to take the root limiters before the command ones. It declares + ;; its limits now, so there is a single place where they are read. + (t/is (= [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] + (command-limits "update-profile-photo")))) + +(t/deftest the-update-team-photo-command-declares-its-own-limits + ;; The command generates the thumbnail of a team through `upload-photo`, + ;; which used to enter climit on its own. It declares the limits now, so the + ;; image processing of a team is bounded like the one of a profile. + (t/is (= [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]] + (command-limits "update-team-photo")))) + (t/deftest commands-declare-the-by-profile-limit-before-the-global-one ;; `climit/get-limits` reverses a declared vector of pairs, so the first one - ;; declared is acquired first, and the nested `climit/invoke!` chains read the - ;; same declaration. A pair declared global first puts a command back in the - ;; inverted order that parked two paths of one profile for good. + ;; declared is acquired first. A pair declared global first puts a command + ;; back in the inverted order that parked two paths of one profile for good. (let [declarations (method-limit-declarations)] ;; the system wraps every command, so a short list means the scan went stale @@ -49,3 +134,46 @@ (t/is (< idx gidx) (str where " must declare " id " before " (keyword (namespace id) "global"))))))))) + +(t/deftest every-command-takes-the-shared-limits-in-the-same-order + ;; Two commands that share a limit and take it in opposite order can hold + ;; the same limiters in opposite order and park for good. The order is free + ;; now that no command enters climit from inside its handler, so this test + ;; is what keeps the commands consistent with each other. + (let [declarations (method-limit-declarations) + edges (limit-order-edges declarations) + cycle (limit-order-cycle edges)] + + ;; the system wraps every command, so a short list means the scan went stale + (t/is (<= 10 (count declarations))) + (t/is (seq edges)) + (t/is (nil? cycle) + (str "the commands take these limits in different orders: " + (pr-str cycle))))) + +(t/deftest a-ring-of-three-limits-is-a-cycle + ;; A ring of three limits does the same as a pair in opposite order: three + ;; paths hold the three limiters and wait for each other. The relation is + ;; checked as a whole for that reason, and not pair by pair. + (t/is (= #{:a :b :c} + (set (limit-order-cycle #{[:a :b] [:b :c] [:c :a]})))) + (t/is (nil? (limit-order-cycle #{[:a :b] [:b :c]})))) + +(t/deftest every-declared-limit-has-an-entry-in-the-shipped-config + ;; `climit/wrap` logs a warning and runs the handler with no limiter at all + ;; when the config has no entry for a declared id, so the command runs on + ;; every thread that asks for it. The production file is maintained by hand + ;; outside the repository and mirrors this one, so a command declaring a limit + ;; the file does not list is the shape of an unbounded command in production. + (let [path (io/resource "climit.edn") + config (edn/read-string (slurp path)) + declared (into #{} + (mapcat (fn [[_ limits]] (declared-ids limits))) + (method-limit-declarations))] + + ;; the system wraps every command, so an empty set means the scan went stale + (t/is (seq declared)) + + (doseq [id declared] + (t/is (some? (get config id)) + (str id " is declared by a command but has no entry in climit.edn"))))) From a3fe43c1ecd1631e97bf930ef21f7e2bca423372 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 09:02:45 +0100 Subject: [PATCH 31/65] :bug: Report raw fetch failures as connectivity errors (#12624) * :bug: Report raw fetch failures as connectivity errors A fetch that fails outside app.util.http (zip.js, plugins, schema loads) rejects with a bare TypeError such as "Failed to fetch". It reached the global handlers and was reported as an unhandled crash, with no stack and no URL. The global handlers now wrap the browser's network TypeErrors as :network (or :offline when the browser says so), so they get the connection toast and a compact handled report. Response body reads in app.util.http also wrap a TypeError as :network with the request uri, as fetch itself already did. Relates to #12581 AI-assisted-by: claude-opus-5-5 * :bug: Treat only network TypeErrors on body read as connectivity errors process-response-type turned every TypeError from a body read into a :network error, so a bug such as "body stream already read" showed a connection toast. It now uses network-failure?, which moves to app.util.http so that both the http client and the global error handlers share it. Add Firefox's "Error in body stream" message. Tests now check :offline and :network separately by setting navigator.onLine, and read-error builds on async/->promise. AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/errors.cljs | 18 +++++ frontend/src/app/util/http.cljs | 34 ++++++++- frontend/test/frontend_tests/errors_test.cljs | 31 ++++++++ frontend/test/frontend_tests/runner.cljs | 2 + .../test/frontend_tests/util_http_test.cljs | 75 +++++++++++++++++++ 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 frontend/test/frontend_tests/util_http_test.cljs diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 54401c94af..5125730363 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -22,6 +22,7 @@ [app.main.worker] [app.util.dom :as dom] [app.util.globals :as g] + [app.util.http :as http] [app.util.i18n :refer [tr]] [app.util.timers :as ts] [app.util.webapi :as wapi] @@ -1123,6 +1124,15 @@ (and (= (.-name ^js cause) "NotFoundError") (str/includes? message "removeChild"))))) +(defn network-failure->error + "Wrap a raw network TypeError as a connectivity error, so it is reported + as an environment failure instead of an application crash." + [cause] + (ex-info (ex-message cause) + {:type (if (false? (.-onLine ^js g/navigator)) :offline :network) + :hint "unable to perform fetch operation"} + cause)) + (defn- from-plugin? "Check if the error is marked as originating from plugin code. The plugin runtime tracks plugin errors in a WeakMap, which works even @@ -1149,6 +1159,10 @@ (is-ignorable-exception? cause) nil + ;; Raw fetch failures: connectivity, not a crash + (http/network-failure? cause) + (on-error (network-failure->error cause)) + ;; All other errors: show exception page :else @@ -1176,6 +1190,10 @@ (is-ignorable-exception? cause) nil + ;; Raw fetch failures: connectivity, not a crash + (http/network-failure? cause) + (on-error (network-failure->error cause)) + ;; All other errors: show exception page :else (let [data (ex-data cause) diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 62a8b1cac7..97638f7231 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -146,6 +146,26 @@ :body (.-body ^js response) ::response response}) +;; Messages of the TypeError a failed `fetch` rejects with, or a failed +;; read of its response body. +(def ^:private network-failure-messages + #{"Failed to fetch" ; Chrome + "NetworkError when attempting to fetch resource." ; Firefox + "Load failed" ; Safari + "network error" ; Chrome, body read + "Error in body stream"}) ; Firefox, body read + +(defn network-failure? + "True when the error is the browser's TypeError for a request that never + got an answer. Other TypeErrors, such as reading a body twice, are bugs + and do not match." + [cause] + (and (= (.-name ^js cause) "TypeError") + (let [message (ex-message cause)] + (or (contains? network-failure-messages message) + ;; Newer Chrome versions append the host in parentheses + (str/starts-with? message "Failed to fetch ("))))) + (defn process-response-type [response-type response] (let [native-response (::response response) @@ -156,7 +176,19 @@ :blob (.blob ^js native-response))] (->> (rx/from body) (rx/map (fn [body] - (assoc response :body body)))))) + (assoc response :body body))) + ;; The connection can drop while the body streams in, after + ;; `fetch` already resolved; the browser then rejects the read + ;; with a bare TypeError. Other errors, such as parse failures + ;; (SyntaxError), pass as is. + (rx/catch (fn [cause] + (if (network-failure? cause) + (rx/throw (ex-info (ex-message cause) + {:type :network + :hint "unable to read response body" + :uri (:uri response)} + cause)) + (rx/throw cause))))))) (defn send! [{:keys [response-type] :or {response-type :text} :as params}] diff --git a/frontend/test/frontend_tests/errors_test.cljs b/frontend/test/frontend_tests/errors_test.cljs index c373926680..baa5da1f96 100644 --- a/frontend/test/frontend_tests/errors_test.cljs +++ b/frontend/test/frontend_tests/errors_test.cljs @@ -7,6 +7,7 @@ (ns frontend-tests.errors-test (:require [app.main.errors :as errors] + [app.util.globals :as g] [cljs.test :as t :include-macros true])) (defn- make-error @@ -148,3 +149,33 @@ ;; V8 gives native rejections such as a failed fetch no frames at all. (let [cause (make-error "TypeError" "Failed to fetch" :stack "TypeError: Failed to fetch")] (t/is (false? (errors/is-ignorable-exception? cause)))))) + +;; --------------------------------------------------------------------------- +;; network-failure->error tests +;; --------------------------------------------------------------------------- + +(defn- with-online + "Call `f` while `navigator.onLine` reads `online?`." + [online? f] + (js/Object.defineProperty g/navigator "onLine" + #js {:value online? :configurable true}) + (try + (f) + (finally + (js-delete g/navigator "onLine")))) + +(t/deftest test-network-failure-becomes-environment-error + (t/testing "A raw fetch failure is wrapped as an environment error" + (let [cause (make-error "TypeError" "Failed to fetch") + error (errors/network-failure->error cause)] + (t/is (true? (errors/environment-error? error))) + (t/is (= "Failed to fetch" (ex-message error))) + (t/is (identical? cause (ex-cause error)))))) + +(t/deftest test-network-failure-type-follows-connection + (let [cause (make-error "TypeError" "Failed to fetch") + type #(:type (ex-data (errors/network-failure->error cause)))] + (t/testing "Offline browsers report :offline" + (t/is (= :offline (with-online false type)))) + (t/testing "Online browsers report :network" + (t/is (= :network (with-online true type)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index c7e2d547fa..c96f1e4983 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -132,6 +132,7 @@ [frontend-tests.ui.stroke-menu-test] [frontend-tests.ui.workspace-libraries-test] [frontend-tests.util-clipboard-test] + [frontend-tests.util-http-test] [frontend-tests.util-object-test] [frontend-tests.util-queue-test] [frontend-tests.util-range-tree-test] @@ -283,6 +284,7 @@ 'frontend-tests.ui.stroke-menu-test 'frontend-tests.ui.workspace-libraries-test 'frontend-tests.util-clipboard-test + 'frontend-tests.util-http-test 'frontend-tests.util-object-test 'frontend-tests.util-queue-test 'frontend-tests.util-range-tree-test diff --git a/frontend/test/frontend_tests/util_http_test.cljs b/frontend/test/frontend_tests/util_http_test.cljs new file mode 100644 index 0000000000..e686d4d3a9 --- /dev/null +++ b/frontend/test/frontend_tests/util_http_test.cljs @@ -0,0 +1,75 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.util-http-test + (:require + [app.util.http :as http] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async])) + +(def ^:private uri "https://example.com/assets/file.json") + +(defn- failing-response + "A response map whose native body read rejects with `cause`." + [cause] + {:status 200 + :uri uri + :headers {} + ::http/response #js {:text (fn [] (js/Promise.reject cause))}}) + +(defn- ^:async read-error + "Read the body of `response` as text and resolve with the error raised." + [response] + (try + (await (async/->promise (http/process-response-type :text response))) + nil + (catch :default cause + cause))) + +(t/deftest ^:async body-read-network-failure-is-a-network-error + (let [cause (js/TypeError. "network error") + error (await (read-error (failing-response cause))) + data (ex-data error)] + (t/is (= :network (:type data))) + (t/is (= uri (:uri data))) + (t/is (identical? cause (ex-cause error))))) + +(t/deftest ^:async body-parse-failure-passes-unchanged + (let [cause (js/SyntaxError. "Unexpected token < in JSON") + error (await (read-error (failing-response cause)))] + (t/is (identical? cause error)))) + +(t/deftest ^:async body-read-programming-error-passes-unchanged + (let [cause (js/TypeError. "body stream already read") + error (await (read-error (failing-response cause)))] + (t/is (identical? cause error)))) + +;; --------------------------------------------------------------------------- +;; network-failure? tests +;; --------------------------------------------------------------------------- + +(defn- make-error + [error-name message] + (let [err (js/Error. message)] + (set! (.-name err) error-name) + err)) + +(t/deftest test-network-failure-browser-messages + (t/testing "The TypeError each browser gives a failed fetch is a network failure" + (doseq [message ["Failed to fetch" + "Failed to fetch (design.penpot.app)" + "NetworkError when attempting to fetch resource." + "Load failed" + "network error" + "Error in body stream"]] + (t/is (true? (http/network-failure? (make-error "TypeError" message))) + message)))) + +(t/deftest test-network-failure-needs-type-error + (t/testing "Other errors with the same message are not network failures" + (t/is (false? (http/network-failure? (make-error "Error" "Failed to fetch"))))) + (t/testing "Other TypeErrors are not network failures" + (t/is (false? (http/network-failure? (make-error "TypeError" "x is not a function")))))) From 1962860f041d73e379b362db82bdf5218df729b4 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 09:02:55 +0100 Subject: [PATCH 32/65] :bug: Reduce thumbnail re-renders and report throttled update loops (#12610) * :bug: Stop thumbnail subscribers re-rendering on every store change A thumbnail loaded from the server keeps a media id as :uri, and workspace-thumbnail-by-id resolves it into a new map on each read. The ref compared values with identical?, so every root frame, every component in the assets panel and the interactions layer re-rendered on every store change, mouse moves included. Compare by value instead. This does not loop by itself, but it adds work to every commit and brings other update loops closer to React's limit. Relates to #12580 AI-assisted-by: claude-opus-5-5 * :sparkles: Report update loops throttled by the update scheduler When the scheduler throttles an update loop, it only logs a console warning, so a loop that no longer crashes leaves no trace. Send an error report too, with the events in flight and the last events, at most once per report window. Relates to #12580 AI-assisted-by: claude-opus-5-5 * :bug: Report throttled update loops as unhandled exceptions The throttled update loop report used the handled-exception event, which only lands in the audit log: error reports and alerts read unhandled-exception and exception-page. A throttled loop is an application defect (its cause has :type :internal), and the point of the report is to find those loops, so it is now an unhandled exception. AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/errors.cljs | 27 ++++++++++++++----- frontend/src/app/main/refs.cljs | 5 +++- .../test/frontend_tests/main/refs_test.cljs | 21 +++++++++++++++ .../test/frontend_tests/main_errors_test.cljs | 27 ++++++++++++++++++- 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 5125730363..666a4d846b 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -440,16 +440,31 @@ (defn on-update-loop "Handler for the rumext update scheduler trips: logs the throttled - update loop with the events being processed, at most once per - `report-window-ms`. Runs inside store watchers and effect bodies, so it - never throws." - [_] + update loop and reports it as an unhandled exception with the events + being processed, at most once per `report-window-ms`. Runs inside store + watchers and effect bodies, so it never throws." + [{:keys [rounds]}] (try (let [now (inst-ms (ct/now))] (when (>= (- now @update-loop-warned-at) report-window-ms) (vreset! update-loop-warned-at now) - (.warn js/console "[update-scheduler] update loop throttled; in-flight events:" - (into-array (map ptk/repr-event (ptk/in-flight-events st/state)))))) + (let [events (ptk/in-flight-events st/state) + cause (ex-info "update loop throttled" + {:type :internal + :code :update-loop-throttled + :rounds rounds})] + (.warn js/console "[update-scheduler] update loop throttled; in-flight events:" + (into-array (map ptk/repr-event events))) + (when (seq events) + (.set in-flight-by-cause cause (in-flight-lines events))) + ;; Report outside the loop that tripped the scheduler, as an + ;; unhandled exception: a throttled loop is an application defect, + ;; so it must reach the error reports and alerts, not only audit. + (ts/schedule + #(submit-report :event-name "unhandled-exception" + :hint (ex-message cause) + :report (fn [] (generate-report cause)) + :cause cause))))) (catch :default _ nil))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 84f6bd72f2..bb7fc0392a 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -609,6 +609,8 @@ (defn workspace-thumbnail-by-id [object-id] + ;; Resolving a media id builds a new map on each read; compare by + ;; value so subscribers do not re-render on every store change. (l/derived (fn [state] (when-let [entry (dm/get-in state [:thumbnails object-id])] @@ -616,7 +618,8 @@ (and (:uri entry) (not (resolved-uri? (:uri entry)))) (update :uri cf/resolve-media)))) - st/state)) + st/state + =)) (def workspace-text-modifier (l/derived :workspace-text-modifier st/state)) diff --git a/frontend/test/frontend_tests/main/refs_test.cljs b/frontend/test/frontend_tests/main/refs_test.cljs index 99c9f57657..581ff949d2 100644 --- a/frontend/test/frontend_tests/main/refs_test.cljs +++ b/frontend/test/frontend_tests/main/refs_test.cljs @@ -8,6 +8,7 @@ (:require [app.common.uuid :as uuid] [app.main.refs :as refs] + [app.main.store :as st] [cljs.test :as t :include-macros true])) (def ^:private resolved-uri? @#'refs/resolved-uri?) @@ -28,3 +29,23 @@ (t/is (false? (resolved-uri? (uuid/next)))) (t/is (false? (resolved-uri? nil))) (t/is (false? (resolved-uri? 42)))) + +(t/deftest thumbnail-ref-ignores-unrelated-state-changes + ;; A thumbnail loaded from the server keeps a media id as :uri, and + ;; the ref resolves it to a new map on each read. Subscribers (root + ;; frames, assets) must not re-render on every unrelated store change. + (let [object-id (str (uuid/next)) + ref (refs/workspace-thumbnail-by-id object-id) + calls (atom 0)] + (swap! st/state assoc-in [:thumbnails object-id] {:uri (uuid/next) :rendered-at nil}) + (add-watch ref ::probe (fn [_ _ _ _] (swap! calls inc))) + (try + (swap! st/state assoc ::probe (uuid/next)) + (t/is (zero? @calls) "unrelated change does not notify") + (swap! st/state assoc-in [:thumbnails object-id :uri] (uuid/next)) + (t/is (= 1 @calls) "a new thumbnail uri notifies") + (finally + (remove-watch ref ::probe) + (swap! st/state #(-> % + (dissoc ::probe) + (update :thumbnails dissoc object-id))))))) diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index 5954eab457..32898be282 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -14,7 +14,8 @@ - events in flight – the full report lists the events the store was processing when it caught the exception; a failing stream pipeline lists the emitters of the value, not its own event - - on-update-loop – never throws into the scheduler's caller + - on-update-loop – never throws into the scheduler's caller; + reports the throttled loop as unhandled, with the events in flight - flash schedules async report and toast – neither the report nor ntf/show is emitted synchronously - organization SSO recovery – expired SSO sessions go back to the provider @@ -554,6 +555,30 @@ {ptk/in-flight-events (fn [_] (throw (js/Error. "in-flight read failure")))} (t/is (nil? (errors/on-update-loop {:rounds 26})))))) +(t/deftest ^:async update-loop-is-reported-with-the-events-in-flight + (errors/reset-report-governor!) + (vreset! @#'errors/update-loop-warned-at 0) + (let [events (atom []) + loop-ev (ptk/reify ::test-update-loop-source)] + (await + (mock/with-mocks* + {st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + ptk/in-flight-events (fn [_] [loop-ev]) + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (f)))} + (errors/on-update-loop {:rounds 26}) + ;; A second trip within the window is not reported again + (errors/on-update-loop {:rounds 26}) + (t/is (= 1 (count @events))) + (let [props (deref (first @events))] + ;; An unhandled report: it reaches the error reports and alerts, + ;; not only the audit log. + (t/is (= "unhandled-exception" (::ev/name props))) + (t/is (= "update loop throttled" (:hint props))) + (t/is (str/includes? (in-flight-section (:report props)) + "test-update-loop-source"))))))) + ;; --------------------------------------------------------------------------- ;; Expired organization SSO session ;; From 705ea42622cdd14b287e3dc07327e8263771c279 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 09:04:05 +0100 Subject: [PATCH 33/65] :bug: Keep the default cursor in the token dropdown of numeric inputs (#12680) A numeric input that can be scrubbed sets the ew-resize cursor on its wrapper, and the token dropdown renders inside that wrapper, so the whole list inherited the scrub cursor. Give the options dropdown the default cursor, so it never takes the cursor of the control that opens it. AI-assisted-by: claude-opus-5-5 --- .../playwright/ui/specs/tokens/apply.spec.js | 30 +++++++++++++++++++ .../ds/controls/shared/options_dropdown.scss | 4 +++ 2 files changed, 34 insertions(+) diff --git a/frontend/playwright/ui/specs/tokens/apply.spec.js b/frontend/playwright/ui/specs/tokens/apply.spec.js index a0e91b7c60..e13a377ae7 100644 --- a/frontend/playwright/ui/specs/tokens/apply.spec.js +++ b/frontend/playwright/ui/specs/tokens/apply.spec.js @@ -259,6 +259,36 @@ test.describe("Tokens: Apply token", () => { await expect(opacityLowPill).toBeVisible(); }); + test("Token dropdown of a scrubbable numeric input shows the default cursor", async ({ + page, + }) => { + const { workspacePage } = await setupTokensFileRender(page); + + await page.getByRole("tab", { name: "Layers" }).click(); + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layerMenuSection = page.getByRole("region", { + name: "Layer menu section", + }); + const opacityWrapper = layerMenuSection + .getByTestId("numeric-input-wrapper") + .first(); + await expect(opacityWrapper).toHaveCSS("cursor", "ew-resize"); + + await layerMenuSection + .getByRole("button", { name: "Open token list" }) + .click(); + + // The scrub cursor belongs to the input, not to the dropdown it opens. + const tokenList = layerMenuSection.getByRole("listbox"); + await expect(tokenList).toBeVisible(); + await expect(tokenList).toHaveCSS("cursor", "default"); + await expect(tokenList.getByRole("option").first()).toHaveCSS( + "cursor", + "default", + ); + }); + test("User applies typography token to a text shape", async ({ page }) => { const { workspacePage, tokensSidebar, tokenContextMenuForToken } = await setupTypographyTokensFileRender(page); diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss index c961b9e06c..6480e6cd79 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss @@ -29,6 +29,10 @@ z-index: var(--z-index-dropdown); box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); + // Do not inherit the cursor of the control that opens it (e.g. the + // scrub cursor of the numeric input). + cursor: default; + &:focus { outline: none; } From a6c225d5102b11cfe65557ecab72633708240951 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 09:28:53 +0100 Subject: [PATCH 34/65] :bug: Move the CSS @charset to the start of the bundled stylesheet (#12678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sass adds @charset "UTF-8" to every module with non-ASCII characters, and concatSass joined the modules as they came, so main.css carried it in the middle of the file. Browsers only honor @charset as the first rule, and the server sends text/css with no charset. When the browser did not take the page encoding for the stylesheet, it read main.css as Latin-1 and CSS content such as the bullet of the library element counts showed as "•". Drop every @charset while joining the modules and put one at the start. Closes #12677 AI-assisted-by: claude-opus-5-5 --- frontend/scripts/_helpers.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/scripts/_helpers.js b/frontend/scripts/_helpers.js index 60c59e8f6d..9a6bf14ec8 100644 --- a/frontend/scripts/_helpers.js +++ b/frontend/scripts/_helpers.js @@ -165,9 +165,22 @@ export async function compileSassAll(worker) { export function concatSass(data) { const output = []; + const charset = '@charset "UTF-8";'; + let hasCharset = false; + // Sass adds @charset to each file with non-ASCII characters. Browsers + // only honor it as the first rule of the stylesheet, so move it there. for (let path of data.items) { - output.push(data.index[path]); + let css = data.index[path]; + if (css.includes(charset)) { + hasCharset = true; + css = css.replaceAll(charset + "\n", "").replaceAll(charset, ""); + } + output.push(css); + } + + if (hasCharset) { + output.unshift(charset); } return output.join("\n"); From 9dc222c6427007be66e7ce24ec30ab6e470a7675 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Thu, 29 Oct 2026 10:13:33 +0100 Subject: [PATCH 35/65] :bug: Fix bad characters allowed on variants properties (#12688) --- .../app/common/logic/variant_properties.cljc | 123 ++++++------ common/src/app/common/types/variant.cljc | 31 ++- .../common_tests/logic/variants_test.cljc | 176 +++++++++++++++++- common/test/common_tests/variant_test.cljc | 42 +++++ .../sidebar/options/menus/component.cljs | 10 +- frontend/src/app/plugins/library.cljs | 14 +- .../frontend_tests/plugins/library_test.cljs | 42 +++++ 7 files changed, 368 insertions(+), 70 deletions(-) diff --git a/common/src/app/common/logic/variant_properties.cljc b/common/src/app/common/logic/variant_properties.cljc index fe87fa3e5e..81dc28399d 100644 --- a/common/src/app/common/logic/variant_properties.cljc +++ b/common/src/app/common/logic/variant_properties.cljc @@ -16,22 +16,24 @@ (defn generate-update-property-name [changes variant-id pos new-name] - (let [data (pcb/get-library-data changes) - objects (pcb/get-objects changes) - related-components (cfv/find-variant-components data objects variant-id) + (if-let [new-name (ctv/prepare-property-name new-name)] + (let [data (pcb/get-library-data changes) + objects (pcb/get-objects changes) + related-components (cfv/find-variant-components data objects variant-id) - props (-> related-components last :variant-properties) - prop-names (mapv :name props) - prop-names (concat (subvec prop-names 0 pos) (subvec prop-names (inc pos))) - new-name (ctv/update-number-in-repeated-item prop-names new-name)] - (reduce (fn [changes component] - (pcb/update-component - changes (:id component) - (fn [component] - (d/update-in-when component [:variant-properties pos] #(assoc % :name new-name))) - {:apply-changes-local-library? true})) - changes - related-components))) + props (-> related-components last :variant-properties) + prop-names (mapv :name props) + prop-names (concat (subvec prop-names 0 pos) (subvec prop-names (inc pos))) + new-name (ctv/update-number-in-repeated-item prop-names new-name)] + (reduce (fn [changes component] + (pcb/update-component + changes (:id component) + (fn [component] + (d/update-in-when component [:variant-properties pos] #(assoc % :name new-name))) + {:apply-changes-local-library? true})) + changes + related-components)) + changes)) (defn generate-remove-property @@ -57,16 +59,18 @@ (defn generate-update-property-value [changes component-id pos value] - (let [data (pcb/get-library-data changes) - component (ctcl/get-component data component-id true) - main-id (:main-instance-id component) - name (-> (:variant-properties component) - (update pos assoc :value value) - ctv/properties-to-name)] - (-> changes - (pcb/update-component component-id #(assoc-in % [:variant-properties pos :value] value) - {:apply-changes-local-library? true}) - (pcb/update-shapes [main-id] #(assoc % :variant-name name))))) + (if-let [value (ctv/prepare-property-value value)] + (let [data (pcb/get-library-data changes) + component (ctcl/get-component data component-id true) + main-id (:main-instance-id component) + name (-> (:variant-properties component) + (update pos assoc :value value) + ctv/properties-to-name)] + (-> changes + (pcb/update-component component-id #(assoc-in % [:variant-properties pos :value] value) + {:apply-changes-local-library? true}) + (pcb/update-shapes [main-id] #(assoc % :variant-name name)))) + changes)) (defn generate-set-variant-error @@ -108,40 +112,45 @@ props (-> related-components last :variant-properties) next-prop-num (ctv/next-property-number props) - property-name (or property-name (str ctv/property-prefix next-prop-num)) + property-name (if (some? property-name) + (ctv/prepare-property-name property-name) + (str ctv/property-prefix next-prop-num))] + (if (nil? property-name) + changes + (let [prop-names (mapv :name props) + property-name (ctv/update-number-in-repeated-item prop-names property-name) + property-value (not-empty + (when property-value + (ctv/prepare-property-value property-value))) + mdata (if editing? {:editing? true} nil) - prop-names (mapv :name props) - property-name (ctv/update-number-in-repeated-item prop-names property-name) + [_ changes] + (reduce (fn [[num changes] component] + (let [main-id (:main-instance-id component) - mdata (if editing? {:editing? true} nil) + update-props #(-> (d/nilv % []) + (conj (with-meta {:name property-name + :value (cond fill-values? (str ctv/value-prefix num) + property-value property-value + :else "")} + mdata))) - [_ changes] - (reduce (fn [[num changes] component] - (let [main-id (:main-instance-id component) - - update-props #(-> (d/nilv % []) - (conj (with-meta {:name property-name - :value (cond fill-values? (str ctv/value-prefix num) - property-value property-value - :else "")} - mdata))) - - update-name #(cond fill-values? (if (str/empty? %) - (str ctv/value-prefix num) - (str % ", " ctv/value-prefix num)) - property-value (if (str/empty? %) - property-value - (str % ", " property-value)) - :else %)] - [(inc num) - (-> changes - (pcb/update-component (:id component) - #(update % :variant-properties update-props) - {:apply-changes-local-library? true}) - (pcb/update-shapes [main-id] #(update % :variant-name update-name)))])) - [1 changes] - related-components)] - changes)) + update-name #(cond fill-values? (if (str/empty? %) + (str ctv/value-prefix num) + (str % ", " ctv/value-prefix num)) + property-value (if (str/empty? %) + property-value + (str % ", " property-value)) + :else %)] + [(inc num) + (-> changes + (pcb/update-component (:id component) + #(update % :variant-properties update-props) + {:apply-changes-local-library? true}) + (pcb/update-shapes [main-id] #(update % :variant-name update-name)))])) + [1 changes] + related-components)] + changes)))) (defn- generate-make-shape-no-variant [changes shape] @@ -175,7 +184,7 @@ props (ctv/add-new-props props (repeat new-props ""))] (if add-name? - (ctv/add-new-prop props (:name component)) + (ctv/add-new-prop props (or (ctv/sanitize-property-text (:name component)) "")) props))) (defn- create-new-properties-from-non-variant diff --git a/common/src/app/common/types/variant.cljc b/common/src/app/common/types/variant.cljc index 660fec5304..f91075a170 100644 --- a/common/src/app/common/types/variant.cljc +++ b/common/src/app/common/types/variant.cljc @@ -55,19 +55,47 @@ [value] (some-> value str/trim)) +(def ^:private forbidden-property-chars-re #"[,=]") + +(defn sanitize-property-text + "Remove `,` and `=` from property name/value text, then trim. + Non-strings (including nil) return nil — never call str/replace on them." + [value] + (when (string? value) + (-> value + (str/replace forbidden-property-chars-re "") + normalize-property-text))) + (defn valid-property-name? [value] (let [value (normalize-property-text value)] (and (string? value) (not (str/blank? value)) + (nil? (re-find forbidden-property-chars-re value)) (<= (count value) property-max-length)))) (defn valid-property-value? [value] (let [value (normalize-property-text value)] (and (string? value) + (nil? (re-find forbidden-property-chars-re value)) (<= (count value) property-max-length)))) +(defn prepare-property-name + "Sanitize then validate a property name. Returns the cleaned string or nil." + [value] + (let [value (sanitize-property-text value)] + (when (valid-property-name? value) + value))) + +(defn prepare-property-value + "Sanitize then validate a property value. nil becomes \"\". + Other non-strings yield nil. Empty string remains valid." + [value] + (let [value (sanitize-property-text (if (nil? value) "" value))] + (when (valid-property-value? value) + value))) + (defn can-remove-property? [properties] (> (count properties) 1)) @@ -112,7 +140,8 @@ ([path properties] (path-to-properties path properties 0)) ([path properties min-props] - (let [cpath (cpn/split-path path) + (let [cpath (->> (cpn/split-path path) + (mapv #(or (sanitize-property-text %) ""))) total-props (max (count cpath) min-props) assigned (mapv #(assoc % :value (nth cpath %2 "")) properties (range)) ;; Add empty strings to the end of cpath to reach the minimum number of properties diff --git a/common/test/common_tests/logic/variants_test.cljc b/common/test/common_tests/logic/variants_test.cljc index c4cfc1b95a..d4a4696336 100644 --- a/common/test/common_tests/logic/variants_test.cljc +++ b/common/test/common_tests/logic/variants_test.cljc @@ -16,7 +16,9 @@ [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] [app.common.test-helpers.variants :as thv] - [clojure.test :as t])) + [app.common.types.variant :as ctv] + [clojure.test :as t] + [cuerdas.core :as str])) (t/use-fixtures :each thi/test-fixture) @@ -234,6 +236,178 @@ (t/is (= (-> comp01' :variant-properties first :value) "NewValue1")) (t/is (= (-> comp02' :variant-properties first :value) "NewValue2")))) +(t/deftest test-update-property-value-sanitizes + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + page (thf/current-page file) + comp01 (thc/get-component file :c01) + before (-> comp01 :variant-properties first :value) + + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-value (:id comp01) 0 "a=b,")) + + file' (thf/apply-changes file changes) + comp01' (thc/get-component file' :c01) + main' (ths/get-shape file' :m01) + + noop (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-value (:id comp01) 0 42)) + file-noop (thf/apply-changes file noop) + comp-noop (thc/get-component file-noop :c01) + + cleared (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-value (:id comp01) 0 "=,=")) + file-cleared (thf/apply-changes file cleared) + comp-cleared (thc/get-component file-cleared :c01) + main-cleared (ths/get-shape file-cleared :m01) + + nilled (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-value (:id comp01) 0 nil)) + file-nilled (thf/apply-changes file nilled) + comp-nilled (thc/get-component file-nilled :c01)] + + (t/is (= "ab" (-> comp01' :variant-properties first :value))) + (t/is (= "ab" (:variant-name main'))) + (t/is (= before (-> comp-noop :variant-properties first :value))) + (t/is (= "" (-> comp-cleared :variant-properties first :value))) + (t/is (not (str/includes? (str (:variant-name main-cleared)) "="))) + (t/is (= "" (-> comp-nilled :variant-properties first :value))))) + +(t/deftest test-update-property-name-sanitizes-and-noops + (let [file (-> (thf/sample-file :file1) + (thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02)) + v-id (-> (ths/get-shape file :v01) :id) + page (thf/current-page file) + before (-> (thc/get-component file :c01) :variant-properties first :name) + + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-name v-id 0 "Size=Big")) + + file' (thf/apply-changes file changes) + + noop (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-name v-id 0 "=,=")) + file-noop (thf/apply-changes file noop)] + + (t/is (= "SizeBig" (-> (thc/get-component file' :c01) :variant-properties first :name))) + (t/is (= before (-> (thc/get-component file-noop :c01) :variant-properties first :name))))) + +(t/deftest test-add-new-property-sanitizes + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + v-id (-> (ths/get-shape file :v01) :id) + page (thf/current-page file) + before-count (-> (thc/get-component file :c01) :variant-properties count) + + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-add-new-property v-id {:property-name "Size=Big" + :property-value "a=b,"})) + + file' (thf/apply-changes file changes) + comp01' (thc/get-component file' :c01) + + noop (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-add-new-property v-id {:property-name "=,="})) + file-noop (thf/apply-changes file noop)] + + (t/is (= "SizeBig" (-> comp01' :variant-properties last :name))) + (t/is (= "ab" (-> comp01' :variant-properties last :value))) + (t/is (= before-count + (-> (thc/get-component file-noop :c01) :variant-properties count))))) + +(t/deftest test-add-new-property-blank-value-does-not-trail-separator + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + v-id (-> (ths/get-shape file :v01) :id) + page (thf/current-page file) + main (ths/get-shape file :m01) + before (:variant-name main) + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-add-new-property v-id {:property-name "Size" + :property-value "=,="})) + file' (thf/apply-changes file changes) + comp01' (thc/get-component file' :c01) + main' (ths/get-shape file' :m01)] + + (t/is (= before (:variant-name main'))) + (t/is (not (str/ends-with? (str (:variant-name main')) ", "))) + (t/is (= "" (-> comp01' :variant-properties last :value))) + (t/is (= "Size" (-> comp01' :variant-properties last :name))))) + +(t/deftest test-update-property-value-from-formula-strips-equal-in-value + ;; Option A / M2: formula parse may leave `=` inside a value; write path strips it. + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02)) + page (thf/current-page file) + comp01 (thc/get-component file :c01) + parsed (ctv/properties-formula->map "border=yes color=yes") + value (-> parsed first :value) + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-update-property-value (:id comp01) 0 value)) + file' (thf/apply-changes file changes) + comp01' (thc/get-component file' :c01) + main' (ths/get-shape file' :m01)] + + (t/is (= "yes color=yes" value)) + (t/is (= "yes coloryes" (-> comp01' :variant-properties first :value))) + (t/is (= "yes coloryes" (:variant-name main'))) + (t/is (not= "yes color=yes" (-> comp01' :variant-properties first :value))))) + +(t/deftest test-make-shapes-variant-sanitizes-component-name-as-value + ;; create-new-properties-from-variant adds (:name component) as a value when + ;; moving a variant into a differently named container. Production always + ;; pairs generate-make-shapes-variant with change-parent. + (let [file (-> (thf/sample-file :file1) + (thv/add-variant :v01 :c01 :m01 :c02 :m02) + (thv/add-variant :v02 :c03 :m03 :c04 :m04) + (thc/update-component :c03 {:name "Big=red"})) + page (thf/current-page file) + container (ths/get-shape file :v01) + shape (ths/get-shape file :m03) + changes (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-library-data (:data file)) + (pcb/with-objects (:objects page)) + (clvp/generate-make-shapes-variant [shape] container) + (pcb/change-parent (:id container) [shape] 0)) + file' (thf/apply-changes file changes) + values (->> (thc/get-component file' :c03) + :variant-properties + (map :value) + set)] + + (t/is (contains? values "Bigred")))) + (t/deftest test-duplicate-variant-container (let [;; ==== Setup diff --git a/common/test/common_tests/variant_test.cljc b/common/test/common_tests/variant_test.cljc index d0a7099dc7..0dea2aa947 100644 --- a/common/test/common_tests/variant_test.cljc +++ b/common/test/common_tests/variant_test.cljc @@ -171,3 +171,45 @@ (t/is (= (ctv/find-boolean-pair ["false" "true"]) {"true" true "false" false})) (t/is (= (ctv/find-boolean-pair ["off" "on" "other"]) nil)) (t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil)))) + + +(t/deftest sanitize-and-prepare-property-text + (t/testing "sanitize-property-text" + (t/is (= (ctv/sanitize-property-text "a=b") "ab")) + (t/is (= (ctv/sanitize-property-text "a,b") "ab")) + (t/is (= (ctv/sanitize-property-text " Big, red ") "Big red")) + (t/is (= (ctv/sanitize-property-text " a=b, ") "ab")) + (t/is (= (ctv/sanitize-property-text ",=") "")) + (t/is (nil? (ctv/sanitize-property-text nil))) + (t/is (nil? (ctv/sanitize-property-text 42)))) + + (t/testing "valid-property-name? rejects forbidden chars" + (t/is (true? (ctv/valid-property-name? "ok"))) + (t/is (false? (ctv/valid-property-name? "a=b"))) + (t/is (false? (ctv/valid-property-name? "a,b"))) + (t/is (false? (ctv/valid-property-name? "")))) + + (t/testing "valid-property-value? rejects forbidden chars" + (t/is (true? (ctv/valid-property-value? "ok"))) + (t/is (true? (ctv/valid-property-value? ""))) + (t/is (false? (ctv/valid-property-value? "a=b"))) + (t/is (false? (ctv/valid-property-value? "a,b")))) + + (t/testing "prepare-property-name" + (t/is (= (ctv/prepare-property-name "Size=Big") "SizeBig")) + (t/is (nil? (ctv/prepare-property-name "=,="))) + (t/is (= (ctv/prepare-property-name " ok ") "ok")) + (t/is (nil? (ctv/prepare-property-name 42)))) + + (t/testing "prepare-property-value" + (t/is (= (ctv/prepare-property-value "a=b,") "ab")) + (t/is (= (ctv/prepare-property-value "=,=") "")) + (t/is (= (ctv/prepare-property-value nil) "")) + (t/is (nil? (ctv/prepare-property-value 42))))) + + +(t/deftest path-to-properties-sanitizes-values + (let [props [{:name "Property 1" :value ""}] + result (ctv/path-to-properties "Button / Big, red" props)] + (t/is (= "Button" (-> result first :value))) + (t/is (= "Big red" (-> result second :value))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 8d46f46a3f..6600b370ab 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -389,7 +389,7 @@ (mf/use-fn (mf/deps component-ids) (fn [pos value] - (let [value (ctv/normalize-property-text (d/nilv value ""))] + (when-let [value (ctv/prepare-property-value value)] (doseq [id component-ids] (st/emit! (ev/event {::ev/name "variant-edit-property-value" ::ev/origin "workspace:combo-design-tab"}) @@ -400,11 +400,11 @@ (mf/use-fn (mf/deps variant-id) (fn [event] - (let [value (ctv/normalize-property-text (dom/get-target-val event)) + (let [value (ctv/prepare-property-name (dom/get-target-val event)) pos (-> (dom/get-current-target event) (dom/get-data "position") int)] - (when (ctv/valid-property-name? value) + (when value (st/emit! (dwv/update-property-name variant-id pos value {:trigger "workspace:design-tab-variant"})))))) @@ -1230,11 +1230,11 @@ (mf/use-fn (mf/deps variant-id) (fn [event] - (let [value (ctv/normalize-property-text (dom/get-target-val event)) + (let [value (ctv/prepare-property-name (dom/get-target-val event)) pos (-> (dom/get-current-target event) (dom/get-data "position") int)] - (when (ctv/valid-property-name? value) + (when value (st/emit! (dwv/update-property-name variant-id pos value {:trigger "workspace:design-tab-component"})))))) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index aff38a654d..c22edd3910 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -779,12 +779,13 @@ :renameProperty (fn [pos name] - (let [nprops (->> (get-variant-components file-id id) first :variant-properties count)] + (let [nprops (->> (get-variant-components file-id id) first :variant-properties count) + prepared (ctv/prepare-property-name name)] (cond (or (not (nat-int? pos)) (>= pos nprops)) (u/not-valid plugin-id :pos pos) - (not (ctv/valid-property-name? name)) + (nil? prepared) (u/not-valid plugin-id :name name) (not (r/check-permission plugin-id "library:write")) @@ -792,7 +793,7 @@ :else (st/emit! - (dwv/update-property-name id pos (ctv/normalize-property-text name) + (dwv/update-property-name id pos prepared {:trigger "plugin:rename-property"}))))))) (set! shape/variant-proxy variant-proxy) @@ -1090,12 +1091,13 @@ :setVariantProperty (fn [pos value] - (let [nprops (-> (u/locate-library-component file-id id) :variant-properties count)] + (let [nprops (-> (u/locate-library-component file-id id) :variant-properties count) + prepared (ctv/prepare-property-value value)] (cond (or (not (nat-int? pos)) (>= pos nprops)) (u/not-valid plugin-id :pos (str pos)) - (not (ctv/valid-property-value? value)) + (nil? prepared) (u/not-valid plugin-id :value value) (not (r/check-permission plugin-id "library:write")) @@ -1104,7 +1106,7 @@ :else (st/emit! (se/event plugin-id "variant-edit-property-value") - (dwv/update-property-value id pos (ctv/normalize-property-text value)))))))) + (dwv/update-property-value id pos prepared))))))) (defn library-proxy? [p] (obj/type-of? p "LibraryProxy")) diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index 839c25e7a7..4ee8ec0357 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -388,6 +388,48 @@ (t/is (= [plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission"] (first @errors))))))) +(t/deftest variant-rename-property-sanitizes-name + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + renamed (atom nil) + errors (atom [])] + (with-redefs [r/check-permission (constantly true) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}]) + dwv/update-property-name (fn [vid pos name opts] + (reset! renamed {:id vid :pos pos :name name :opts opts}) + ::rename-event) + st/emit! mock/noop] + (let [proxy (library/variant-proxy plugin-id file-id id)] + (.renameProperty proxy 0 "a=b") + (t/is (empty? @errors)) + (t/is (= "ab" (:name @renamed))) + (.renameProperty proxy 0 "=,=") + (t/is (= 1 (count @errors))) + (t/is (= :name (second (first @errors)))))))) + +(t/deftest component-set-variant-property-sanitizes-value + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + updated (atom nil) + errors (atom [])] + (with-redefs [r/check-permission (constantly true) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + u/locate-library-component (constantly {:id id :variant-properties [{:name "color"}]}) + dwv/update-property-value (fn [cid pos value] + (reset! updated {:id cid :pos pos :value value}) + ::value-event) + st/emit! mock/noop] + (let [proxy (library/lib-component-proxy plugin-id file-id id)] + (.setVariantProperty proxy 0 "a=b") + (t/is (empty? @errors)) + (t/is (= "ab" (:value @updated))) + (.setVariantProperty proxy 0 "=,=") + (t/is (empty? @errors)) + (t/is (= "" (:value @updated))))))) + ;; --------------------------------------------------------------------------- ;; connectLibrary ;; --------------------------------------------------------------------------- From c0f0b14540983d43724004d86a64c8befb94cf2b Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Thu, 29 Oct 2026 10:35:46 +0100 Subject: [PATCH 36/65] :tada: Add direct MCP feedback channel via dedicated tool (#12551) * :recycle: Move devenv-only tools to a separate folder * :tada: Add an MCP feedback channel Add a user-approved feedback tool and an MCP-token-authenticated backend endpoint that queues plain-text email. Enable the tool through the configured backend URI in both local and multi-user mode. Validate subject and feedback limits, enforce per-profile rate limits, and document configuration. Cover authentication, queueing, tool availability, and failure handling with backend and MCP tests. Closes #12490 AI-assisted-by: gpt-6 * :recycle: Move MCP feedback submission into RPC Use the standard RPC wrappers for feedback and remove the dedicated HTTP endpoint, route wiring, and custom rate limiter. Restrict MCP tokens to opted-in routes and commands. Add a shared RPC call helper to the MCP backend client and adapt the existing tests. AI-assisted-by: gpt-6 * :bug: Harden MCP feedback auth, validation and messages Accept MCP tokens when either the mcp or the access-tokens flag is on, so instances that enable MCP without personal access tokens can send feedback. Personal tokens still need access-tokens. Keep the issuer in one constant, decode tokens without raising to reject unknown issuers, and name the issuer and type checks. Reject control characters in the subject, and in the feedback all but tabs and line breaks, in both the command and the tool, so a NUL answers 400 instead of failing when the mail job is stored. Answer 403 with profile-blocked or profile-not-active for unusable profiles, record only who sent feedback in the audit event, and add doc metadata. Replace on OAuth approval an MCP token issued before MCP tokens had their own issuer, or one this instance cannot decrypt, since the backend rejects both. In the MCP server, mention disabled access tokens on 401, drop the unused 503 case, and say that delivery happens in the background. Document the flags in the README. AI-assisted-by: claude-opus-5-5 * :books: Document the MCP feedback tool and MCP token rules List submit_feedback among the MCP tools and add an MCP feedback section to the configuration guide covering PENPOT_MCP_BACKEND_URI, PENPOT_MCP_FEEDBACK_EMAIL, the flags and the email settings. Add commented examples of both variables to the compose file. In the agent memories, record when OAuth approval replaces an MCP token, how the MCP server reaches the backend, and which flags, routes and commands accept MCP tokens. AI-assisted-by: claude-opus-5-5 --- .../auth-permissions-product-domains.md | 3 + .serena/memories/backend/core.md | 2 +- .serena/memories/mcp/core.md | 9 + .serena/memories/mcp/oauth.md | 4 +- backend/src/app/config.clj | 1 + backend/src/app/http/access_token.clj | 74 +++--- backend/src/app/rpc.clj | 18 +- backend/src/app/rpc/commands/access_token.clj | 14 +- backend/src/app/rpc/commands/feedback.clj | 48 +++- .../test/backend_tests/http_assets_test.clj | 8 +- .../backend_tests/http_middleware_test.clj | 40 +++- .../backend_tests/rpc_access_tokens_test.clj | 39 +++- .../rpc_commands_mcp_feedback_test.clj | 179 ++++++++++++++ backend/test/backend_tests/rpc_test.clj | 25 +- docker/images/docker-compose.yaml | 9 + docs/mcp/index.md | 3 + docs/technical-guide/configuration.md | 24 ++ mcp/README.md | 13 +- mcp/packages/server/src/Feedback.test.ts | 221 ++++++++++++++++++ .../server/src/PenpotBackendClient.ts | 86 +++++++ mcp/packages/server/src/PenpotMcpServer.ts | 16 +- .../server/src/tools/SubmitFeedbackTool.ts | 67 ++++++ .../tools/{ => dev}/CljCheckParentheses.ts | 8 +- .../tools/{ => dev}/CljsCompilerOutputTool.ts | 10 +- .../src/tools/{ => dev}/CljsReplTool.ts | 10 +- .../tools/{ => dev}/ImportPenpotFileTool.ts | 10 +- .../src/tools/{ => dev}/ReadTaigaIssueTool.ts | 8 +- 27 files changed, 871 insertions(+), 78 deletions(-) create mode 100644 backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj create mode 100644 mcp/packages/server/src/Feedback.test.ts create mode 100644 mcp/packages/server/src/PenpotBackendClient.ts create mode 100644 mcp/packages/server/src/tools/SubmitFeedbackTool.ts rename mcp/packages/server/src/tools/{ => dev}/CljCheckParentheses.ts (97%) rename mcp/packages/server/src/tools/{ => dev}/CljsCompilerOutputTool.ts (86%) rename mcp/packages/server/src/tools/{ => dev}/CljsReplTool.ts (89%) rename mcp/packages/server/src/tools/{ => dev}/ImportPenpotFileTool.ts (98%) rename mcp/packages/server/src/tools/{ => dev}/ReadTaigaIssueTool.ts (96%) diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index 2a58e49dfd..d9ee64c171 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -13,6 +13,9 @@ - Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI. - Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation. - When public registration is disabled, invitation-based password registration also requires a matching, non-expired `team_invitation` row; registration revalidates it under a row lock before creating the profile. +- Access tokens (`app.http.access-token`): personal tokens have JWT issuer `access-token` and DB type other than `mcp`; MCP tokens have issuer `urn:penpot:mcp-token` (`actoken/mcp-token-issuer`) and DB type `mcp`. Authentication requires issuer and type to agree, and the claim `uid` to match the row's profile; other issuers and undecryptable tokens are treated as anonymous. +- Personal tokens need flag `:access-tokens`. MCP tokens need `:mcp` or `:access-tokens`, and only on routes whose `actoken/authz` gets `::actoken/allow-mcp true` (the RPC routes); elsewhere (e.g. assets) they are anonymous. +- On RPC, an MCP token reaches only commands with `::rpc/token-type "mcp"` (`wrap-authentication` in `app.rpc`); every other command answers 403 `:token-auth-required`. A command with that metadata also rejects sessions and personal tokens. Currently only `send-mcp-feedback`. Blocking a profile invalidates sessions, not tokens, so such commands check `:is-blocked`/`:is-active` themselves. - HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`. ## Permission model diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 2d44a88b76..7a70eb4669 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -32,7 +32,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m RPC commands are defined with `app.util.services/defmethod` and schemas. Use `get-` prefixes for read operations. Command metadata usually includes auth, docs version, params schema, and result schema. Return plain maps/vectors or raise structured exceptions from `app.common.exceptions`. -Backend RPC command areas without focused memories include access tokens, binfile, demo, feedback, file snapshots, fonts, management, Nitrate, and webhooks beyond the notes in `mem:backend/auth-permissions-product-domains`; inspect nearby command tests and command metadata before changing them. +Backend RPC command areas without focused memories include access tokens (auth rules, incl. MCP tokens, in `mem:backend/auth-permissions-product-domains`), binfile, demo, feedback, file snapshots, fonts, management, Nitrate, and webhooks beyond the notes in `mem:backend/auth-permissions-product-domains`; inspect nearby command tests and command metadata before changing them. ## DB conventions diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md index 551ef961c6..dbc4b85b92 100644 --- a/.serena/memories/mcp/core.md +++ b/.serena/memories/mcp/core.md @@ -113,6 +113,15 @@ From the `mcp/` directory, run - Authorization/discovery URLs, self-contained client IDs, consent and PKCE/code binding, backend Redis state, deployment wiring, and compatibility limits: `mem:mcp/oauth`. +## Backend tools (`submit_feedback`) + +- Tools that call the Penpot backend directly (not through the plugin) register only when `PENPOT_MCP_BACKEND_URI` is nonblank (`PenpotMcpServer.initTools`). Currently only `SubmitFeedbackTool`. +- `PenpotBackendClient` owns the HTTP call and the status-to-message mapping; it posts to `api/main/methods/send-mcp-feedback` with `Authorization: Token `. The token comes from the session context (OAuth bearer or `?userToken=`); without one the tool fails before calling the backend. +- Tool zod schemas mirror the backend malli schema (lengths, blank, control characters); keep both in sync. The backend is the authority. +- Success means the mail job was queued, not delivered; the tool text says so. `formatArgs` hides the feedback from logs. +- Backend side: `send-mcp-feedback` in `app.rpc.commands.feedback`, configured by `PENPOT_MCP_FEEDBACK_EMAIL`; MCP-token auth rules in `mem:backend/auth-permissions-product-domains`. +- User docs: `mcp/README.md` (env tables), `docs/mcp/index.md` (tool list), `docs/technical-guide/configuration.md` (MCP feedback section). + ## Devenv plugin/server wiring In the normal Penpot devenv MCP path, the browser plugin does not discover or route through Postgres. The frontend provides the plugin extension API with `mcp.getServerUrl()`, currently derived from `frontend/src/app/config.cljs` as `penpotMcpServerURI` if set, otherwise `/mcp/ws`. The MCP plugin opens a direct WebSocket to that URL and appends the current MCP access token as a query parameter. diff --git a/.serena/memories/mcp/oauth.md b/.serena/memories/mcp/oauth.md index 097bdc1186..110372cb4c 100644 --- a/.serena/memories/mcp/oauth.md +++ b/.serena/memories/mcp/oauth.md @@ -3,7 +3,7 @@ ## Contract and scope - MCP-specific authorization; existing Penpot login identifies the user on the approval page. No application vetting or persistent consent policy. Access requires user approval; registration alone grants nothing. -- Return the existing fixed MCP user token. On approval, enable `:mcp-enabled` and create the token only if no unexpired one exists; preserve an existing unexpired token. Settings can give MCP tokens an expiry, and an expired one is replaced. No refresh token, `expires_in`, or new token lifetime. +- Return the existing fixed MCP user token. On approval, enable `:mcp-enabled` and create the token only if no usable one exists; preserve an existing usable token. A token is unusable, and replaced, when expired (settings can give MCP tokens an expiry), when its JWT issuer is not `urn:penpot:mcp-token` (tokens issued before MCP tokens had their own issuer), or when this instance cannot decrypt it (e.g. secret key changed); the backend rejects all three. No refresh token, `expires_in`, or new token lifetime. - No new client database table or schema migration. Client IDs must remain usable across separate authorization flows without server-side registration storage. Do not replace them with expiring Redis registrations. - MCP transport continues matching user tokens to connected Penpot plugins; no new per-request token introspection or independent semantic validation. The logged-in Penpot instance remains the content-access boundary. Do not silently add resource-server validation or change token semantics. - README scope: user/developer usage instructions; document `PENPOT_MCP_AUTH_URI` in its environment-variable table. Keep OAuth architecture and protocol details here and non-obvious invariants in source comments. @@ -38,7 +38,7 @@ - `/decision`: check exact Origin, session binding and CSRF, then atomically consume the request. Denial creates no token/enables nothing. Approval calls transactional `access-token/ensure-mcp-access!`, then stores code for 120 seconds. Code state holds token row ID, never the fixed token. Callback echoes OAuth `state` and RFC 9207 `iss`. - `/token`: validate grant, exact client ID/callback/resource and PKCE; resolve current client metadata/authentication method and verify any secret; atomically consume validated code; fetch the unexpired token by original row ID/profile/type. Regeneration deletes the old token row, so a pending code cannot silently yield the replacement token. Return fixed `access_token`, `token_type: Bearer`, scope `mcp`. - `backend/src/app/mcp/oauth_state.clj` and Lua: tenant/kind/hash-prefixed keys; random handles; SET NX EX must succeed before returning handle. Reads retain raw JSON for atomic comparison. Consume/delete and bind/replace compare the validated raw record in Lua; replacement preserves TTL. Redis failures fail closed. Backend Redis is required independently of Node's optional Redis plugin bridge. -- `backend/src/app/rpc/commands/access_token.clj`: lock profile row in both MCP generation and OAuth provisioning, including when no token exists. Serializes simultaneous approvals and settings regeneration. Provisioning must run within a transaction. +- `backend/src/app/rpc/commands/access_token.clj`: `ensure-mcp-access!` decodes the stored token with `ex/ignoring` to apply the usable-token rule above. Lock profile row in both MCP generation and OAuth provisioning, including when no token exists. Serializes simultaneous approvals and settings regeneration. Provisioning must run within a transaction. ## Verification and limits diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 33fde1fd5f..515f3bec49 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -206,6 +206,7 @@ [:allow-demo-users {:optional true} ::sm/boolean] [:error-report-webhook {:optional true} :string] [:user-feedback-destination {:optional true} :string] + [:mcp-feedback-email {:optional true} ::sm/email] [:default-rpc-rlimit {:optional true} [::sm/vec :string]] [:rpc-rlimit-config {:optional true} ::fs/path] diff --git a/backend/src/app/http/access_token.clj b/backend/src/app/http/access_token.clj index 57f19fdda8..017b042bd4 100644 --- a/backend/src/app/http/access_token.clj +++ b/backend/src/app/http/access_token.clj @@ -14,14 +14,20 @@ [app.setup :as-alias setup] [app.tokens :as tokens])) +(def mcp-token-issuer "urn:penpot:mcp-token") + +(def ^:private valid-issuers + #{"access-token" mcp-token-issuer}) + (defn decode-token [cfg token] (try - (tokens/verify cfg {:token token :iss "access-token"}) - (catch Throwable cause - (l/trc :hint "exception on decoding token" - :token token - :cause cause)))) + (let [claims (tokens/verify cfg {:token token})] + (when (contains? valid-issuers (:iss claims)) + claims)) + ;; The cause is not logged because its data carries the token + (catch Throwable _ + (l/trc :hint "exception on decoding token")))) (def sql:get-token-data "SELECT perms, profile_id, expires_at, type @@ -34,32 +40,48 @@ [pool claims] (when-not (db/read-only? pool) (when-let [token-id (get claims :tid)] - (some-> (db/exec-one! pool [sql:get-token-data token-id]) - (update :perms db/decode-pgarray #{}))))) + (let [token (db/exec-one! pool [sql:get-token-data token-id]) + mcp-claims? (= mcp-token-issuer (:iss claims)) + mcp-token? (= "mcp" (:type token))] + (when (and (= (:uid claims) (:profile-id token)) + (= mcp-claims? mcp-token?)) + (update token :perms db/decode-pgarray #{})))))) (defn- wrap-authz - [handler {:keys [::db/pool]}] - (fn [request] - (let [{:keys [type claims]} (get request ::http/auth-data)] - (if (= :token type) - (let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool)) - token-id (get claims :tid)] - (handler (cond-> request - (some? perms) - (assoc ::perms perms) - (some? profile-id) - (assoc ::profile-id profile-id) - (some? expires-at) - (assoc ::expires-at expires-at) - (some? token-id) - (assoc ::id token-id) - (some? type) - (assoc ::type type)))) + "Authenticate requests carrying an access token. Personal tokens need the + `access-tokens` flag. MCP tokens are restricted to specific commands, so + only RPC routes opt in (the RPC wrapper enforces those restrictions); they + need either the `mcp` or the `access-tokens` flag." + [handler {:keys [::db/pool ::allow-mcp]}] + (let [personal-enabled? (contains? cf/flags :access-tokens) + mcp-enabled? (and allow-mcp + (or personal-enabled? + (contains? cf/flags :mcp)))] + (fn [request] + (let [{:keys [type claims]} (get request ::http/auth-data)] + (if (and (= :token type) + (if (= mcp-token-issuer (:iss claims)) + mcp-enabled? + personal-enabled?)) + (let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool)) + token-id (get claims :tid)] + (handler (cond-> request + (some? perms) + (assoc ::perms perms) + (some? profile-id) + (assoc ::profile-id profile-id) + (some? expires-at) + (assoc ::expires-at expires-at) + (some? token-id) + (assoc ::id token-id) + (some? type) + (assoc ::type type)))) - (handler request))))) + (handler request)))))) (def authz {:name ::authz :compile (fn [& _] - (when (contains? cf/flags :access-tokens) + (when (or (contains? cf/flags :access-tokens) + (contains? cf/flags :mcp)) wrap-authz))}) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 7ca7816cfc..8d922822c7 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -124,7 +124,8 @@ (cond-> (and (not (uuid? session-pid)) (uuid? token-pid)) (-> (assoc ::auth-type :token) - (assoc ::token-perms (set (::actoken/perms request #{}))))) + (assoc ::token-perms (set (::actoken/perms request #{}))) + (assoc ::token-type (::actoken/type request)))) (cond-> key-id (assoc ::auth-key-id key-id)) ;; Server-side only: request bodies cannot forge @@ -171,10 +172,12 @@ [_ f mdata] (let [required-auth? (::auth mdata true) required-auth-type (::auth-type mdata) + required-token-type (::token-type mdata) required-perms (into #{} (::perms mdata))] (fn [cfg params] (let [profile-id (::profile-id params) auth-type (::auth-type params) + token-type (::token-type params) token-perms (set (::token-perms params #{}))] (cond (and required-auth? (not (uuid? profile-id))) @@ -182,8 +185,13 @@ :code :authentication-required :hint "authentication required for this endpoint") - (and (= required-auth-type :token) - (not= auth-type :token)) + (or (and (= required-auth-type :token) + (not= auth-type :token)) + (and required-token-type + (or (not= auth-type :token) + (not= required-token-type token-type))) + (and (= token-type "mcp") + (not= required-token-type "mcp"))) (ex/raise :type :authorization :code :token-auth-required :hint "access token authentication required for this endpoint") @@ -522,7 +530,7 @@ {:middleware [[mw/cors] [sec/client-header-check] [session/authz cfg] - [actoken/authz cfg]] + [actoken/authz (assoc cfg ::actoken/allow-mcp true)]] :handler (make-rpc-handler methods)}] (doc/routes :methods methods @@ -540,5 +548,5 @@ {:middleware [[mw/cors] [sec/client-header-check] [session/authz cfg] - [actoken/authz cfg]] + [actoken/authz (assoc cfg ::actoken/allow-mcp true)]] :handler (make-rpc-handler methods)}]]) diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index 6681af3cd7..8474d2ba80 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -6,10 +6,12 @@ (ns app.rpc.commands.access-token (:require + [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] [app.db :as db] + [app.http.access-token :as actoken] [app.main :as-alias main] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] @@ -49,7 +51,7 @@ (let [token-id (uuid/next) expires-at (some-> expiration (ct/in-future)) created-at (ct/now) - token-iss (if (= type "mcp") "urn:penpot:mcp-token" "access-token") + token-iss (if (= type "mcp") actoken/mcp-token-issuer "access-token") token (tokens/generate cfg {:iss token-iss :uid profile-id :iat created-at @@ -79,10 +81,16 @@ Must run in a transaction. Lock the profile even when no token exists, so concurrent approvals and settings regeneration use the same lock. Enabling MCP must not regenerate a token already held by a connected client; an - expired token is replaced because no client can use it." + expired token is replaced because no client can use it, and so is a token + issued before MCP tokens had their own issuer, or one this instance cannot + decrypt, because the backend rejects both." [{:keys [::db/conn] :as cfg} profile-id] (let [profile (profile/get-profile conn profile-id ::db/for-update true) - token (db/exec-one! conn [sql:get-valid-mcp-token profile-id])] + token (db/exec-one! conn [sql:get-valid-mcp-token profile-id]) + ;; A token this instance cannot decrypt is as unusable as an expired one + token (when (= actoken/mcp-token-issuer + (ex/ignoring (:iss (tokens/decode cfg (:token token))))) + token)] (when-not (:mcp-enabled (:props profile)) (profile/update-profile-props cfg profile-id {:mcp-enabled true})) (if token diff --git a/backend/src/app/rpc/commands/feedback.clj b/backend/src/app/rpc/commands/feedback.clj index 7ff4076da7..69354345a1 100644 --- a/backend/src/app/rpc/commands/feedback.clj +++ b/backend/src/app/rpc/commands/feedback.clj @@ -13,11 +13,57 @@ [app.config :as cf] [app.db :as db] [app.email :as eml] + [app.loggers.audit :as-alias audit] [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] - [app.util.services :as sv])) + [app.rpc.helpers :as rph] + [app.util.services :as sv] + [clojure.string :as str])) + +(def schema:send-mcp-feedback + [:map {:title "send-mcp-feedback" :closed true} + [:subject [:and [:string {:min 1 :max 120}] + [:fn #(and (not (str/blank? %)) + (not (re-find #"\p{Cc}" %)))]]] + ;; Line breaks and tabs are the only control characters allowed + [:feedback [:and [:string {:min 1 :max 5000}] + [:fn #(and (not (str/blank? %)) + (not (re-find #"[\p{Cc}&&[^\t\n\r]]" %)))]]]]) + +(sv/defmethod ::send-mcp-feedback + {::rpc/token-type "mcp" + ::db/transaction true + ::doc/added "2.20" + ::sm/params schema:send-mcp-feedback} + [cfg {:keys [::rpc/profile-id subject feedback]}] + (let [profile (profile/get-profile cfg profile-id) + destination (cf/get :mcp-feedback-email)] + ;; Blocking a profile invalidates its sessions but not its access tokens + (when (:is-blocked profile) + (ex/raise :type :authorization + :code :profile-blocked + :hint "profile is marked as blocked")) + (when-not (:is-active profile) + (ex/raise :type :authorization + :code :profile-not-active + :hint "profile is not active")) + (when (str/blank? destination) + (ex/raise :type :restriction + :code :feedback-disabled + :hint "MCP feedback is not configured")) + (eml/send cfg {::eml/reuse-conn true + :to destination + :reply-to (:email profile) + :subject (str "[MCP feedback] " subject) + :body {"text/plain" (str "Submitted by: " (:fullname profile) + " <" (:email profile) ">\n" + "Profile ID: " (:id profile) "\n\n" + "Feedback:\n\n" feedback)}}) + ;; The audit event keeps who sent feedback, not its text + (rph/with-meta {:status "queued"} + {::audit/replace-props {}}))) (declare ^:private send-user-feedback!) diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index 1f6c473a51..c9503f1cca 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -31,7 +31,9 @@ io.prometheus.client.Counter io.prometheus.client.Counter$Child)) -(t/use-fixtures :once th/state-init) +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-access-tokens]) + th/init-system)) (t/use-fixtures :each (th/serial th/database-reset th/clean-storage)) @@ -464,7 +466,7 @@ ;; 2. actoken/authz reads ::http/auth-data and sets ::actoken/profile-id request {::http/auth-data {:type :token :token (:token atoken) - :claims {:tid (:id atoken)}} + :claims (actoken/decode-token th/*system* (:token atoken))} :path-params {:id (str (:id object))}} ;; Apply actoken/authz middleware @@ -517,7 +519,7 @@ (t/testing "access token auth sets ::actoken/profile-id" (let [request {::http/auth-data {:type :token :token (:token atoken) - :claims {:tid (:id atoken)}}} + :claims (actoken/decode-token th/*system* (:token atoken))}} result (with-session request)] (t/is (= (:id profile) (::actoken/profile-id result))))) diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index d2c28857e9..57766ab3b7 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -34,7 +34,9 @@ io.prometheus.client.CollectorRegistry io.undertow.server.RequestTooBigException)) -(t/use-fixtures :once th/state-init) +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-access-tokens]) + th/init-system)) (t/use-fixtures :each th/database-reset) (t/deftest auth-middleware-1 @@ -106,7 +108,8 @@ (let [response (handler nil)] (t/is (nil? response))) - (let [response (handler {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}})] + (let [response (handler {::http/auth-data {:type :token :token (:token token) + :claims (tokens/verify th/*system* {:token (:token token)})}})] (t/is (= #{} (:app.http.access-token/perms response))) (t/is (= (:id profile) (:app.http.access-token/profile-id response)))))) @@ -114,9 +117,14 @@ (let [profile (th/create-profile* 1) token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil "mcp") - handler (#'app.http.access-token/wrap-authz identity th/*system*) - request {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}} + handler (#'app.http.access-token/wrap-authz + identity (assoc th/*system* :app.http.access-token/allow-mcp true)) + request {::http/auth-data {:type :token :token (:token token) + :claims (tokens/verify th/*system* {:token (:token token)})}} response (handler request)] + (t/is (nil? (:app.http.access-token/profile-id + ((#'app.http.access-token/wrap-authz identity th/*system*) request))) + "MCP tokens do not authenticate routes that have not opted in") ;; Must set ::actoken/id from claims :tid (t/is (= (:id token) (:app.http.access-token/id response))) ;; Must set ::actoken/type from database @@ -125,6 +133,30 @@ (t/is (= #{} (:app.http.access-token/perms response))) (t/is (= (:id profile) (:app.http.access-token/profile-id response))))) +(t/deftest access-token-authz-follows-flags + (let [profile (th/create-profile* 1) + create #(db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token + (:id profile) "test" nil %) + request (fn [{:keys [token]}] + {::http/auth-data {:type :token :token token + :claims (tokens/verify th/*system* {:token token})}}) + auth (fn [flags token] + (with-redefs [cf/flags flags] + (let [handler (#'app.http.access-token/wrap-authz + identity (assoc th/*system* :app.http.access-token/allow-mcp true))] + (:app.http.access-token/profile-id (handler (request token)))))) + personal (create nil) + mcp (create "mcp")] + (t/testing "only mcp: MCP tokens authenticate, personal tokens do not" + (t/is (= (:id profile) (auth #{:mcp} mcp))) + (t/is (nil? (auth #{:mcp} personal)))) + (t/testing "only access-tokens: both authenticate" + (t/is (= (:id profile) (auth #{:access-tokens} mcp))) + (t/is (= (:id profile) (auth #{:access-tokens} personal)))) + (t/testing "neither flag: the middleware is not installed" + (with-redefs [cf/flags #{}] + (t/is (nil? ((:compile app.http.access-token/authz)))))))) + (defrecord MethodAwareDummyRequest [req-method headers] yreq/IRequest (method [_] req-method) diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index e5fcaf5348..359bbf66fd 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -6,6 +6,7 @@ (ns backend-tests.rpc-access-tokens-test (:require + [app.common.time :as ct] [app.common.uuid :as uuid] [app.db :as db] [app.http :as http] @@ -13,6 +14,7 @@ [app.rpc :as-alias rpc] [app.rpc.commands.access-token :as access-token] [app.storage :as sto] + [app.tokens :as tokens] [backend-tests.helpers :as th] [clojure.test :as t] [mockery.core :refer [with-mocks]])) @@ -208,7 +210,7 @@ (t/is (not (contains? all-ids (:id second-mcp)))) (t/is (contains? all-ids (:id third-mcp)))))))) -(t/deftest mcp-tokens-cannot-be-used-as-access-tokens +(t/deftest access-token-decoder-accepts-mcp-and-personal-issuers (let [prof (th/create-profile* 1 {:is-active true}) cfg th/*system*] @@ -219,9 +221,9 @@ :name "mcp token"}) mcp-token (:token result) - ;; Try to decode as access token (should fail) decoded (actoken/decode-token cfg mcp-token)] - (t/is (nil? decoded)))) + (t/is (= "urn:penpot:mcp-token" (:iss decoded))) + (t/is (= (:id prof) (:uid decoded))))) (t/testing "Regular access tokens use access-token issuer claim" (let [{:keys [result]} (th/command! {::th/type :create-access-token @@ -264,6 +266,37 @@ (t/is (nil? (:expires-at token))) (t/is (= [(:id token)] (map :id (db/query th/*pool* :access-token {:profile-id (:id prof) :type "mcp"})))))) + (t/testing "approval replaces a token issued with the personal-token issuer" + (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) + (let [token-id (uuid/next) + now (ct/now) + old (tokens/generate cfg {:iss "access-token" :uid (:id prof) + :iat now :tid token-id})] + (db/tx-run! cfg (fn [{:keys [::db/conn]}] + (db/insert! conn :access-token + {:id token-id :name "MCP" :token old :type "mcp" + :profile-id (:id prof) :created-at now :updated-at now + :perms (db/create-array conn "text" [])}))) + (let [token (provision)] + (t/is (not= old (:token token))) + (t/is (= actoken/mcp-token-issuer + (:iss (actoken/decode-token cfg (:token token))))) + (t/is (= [(:id token)] + (map :id (db/query th/*pool* :access-token + {:profile-id (:id prof) :type "mcp"}))))))) + (t/testing "approval replaces a token this instance cannot decrypt" + (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) + (let [now (ct/now)] + (db/tx-run! cfg (fn [{:keys [::db/conn]}] + (db/insert! conn :access-token + {:id (uuid/next) :name "MCP" :token "not-a-jwe" :type "mcp" + :profile-id (:id prof) :created-at now :updated-at now + :perms (db/create-array conn "text" [])}))) + (let [token (provision)] + (t/is (not= "not-a-jwe" (:token token))) + (t/is (= [(:id token)] + (map :id (db/query th/*pool* :access-token + {:profile-id (:id prof) :type "mcp"}))))))) (t/testing "concurrent approvals share one token" (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) (let [results (mapv deref (repeatedly 4 #(future (provision))))] diff --git a/backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj b/backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj new file mode 100644 index 0000000000..7987596481 --- /dev/null +++ b/backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj @@ -0,0 +1,179 @@ +;; 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 SUBSIDIARY SL + +(ns backend-tests.rpc-commands-mcp-feedback-test + (:require + [app.common.json :as json] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.email :as email] + [app.http :as http] + [app.jobs :as jobs] + [app.rpc :as-alias rpc] + [app.rpc.commands.access-token :as access-token] + [app.tokens :as tokens] + [backend-tests.helpers :as th] + [clojure.test :as t] + [yetti.response :as-alias yres])) + +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-access-tokens + :enable-client-header-check-middleware]) + th/init-system)) +(t/use-fixtures :each th/database-reset) + +(def ^:private feedback + {:subject "Export fails" + :feedback "Exporting a valid shape repeatedly fails. Expected a PNG."}) + +(defn- create-identity + [] + (let [profile (th/create-profile* (uuid/next) {:is-active true}) + token (db/tx-run! th/*system* access-token/create-access-token + (:id profile) "MCP" nil "mcp")] + {:profile profile :token token})) + +(defn- request + ([token] (request token feedback)) + ([token params] (request token params {})) + ([token params options] + (with-redefs [cf/config (assoc cf/config :mcp-feedback-email "mcp@example.com")] + ((#'http/router-handler (::http/router th/*system*)) + (th/make-dummy-request + (merge {:path "/api/main/methods/send-mcp-feedback" + :method :post + :headers (cond-> {"content-type" "application/json" + "accept" "application/json" + "x-client" "penpot-mcp"} + token (assoc "authorization" (str "Token " token))) + :body-bytes (json/encode params)} + options)))))) + +(defn- emails + [] + (map #(jobs/decode-params (jobs/get-job-def (::jobs/defs th/*system*) :sendmail) (:params %)) + (db/query th/*system* :job {:name "sendmail"}))) + +(t/deftest queues-plain-text-feedback + (let [{:keys [token profile]} (create-identity) + response (request (:token token)) + email (first (emails))] + (t/is (= 200 (::yres/status response))) + (t/is (= 1 (count (emails)))) + (t/is (= "mcp@example.com" (:to email))) + (t/is (= (:email profile) (:reply-to email))) + (t/is (= "[MCP feedback] Export fails" (:subject email))) + (t/is (= {"text/plain" (str "Submitted by: " (:fullname profile) + " <" (:email profile) ">\n" + "Profile ID: " (:id profile) "\n\n" + "Feedback:\n\n" (:feedback feedback))} + (:body email))) + (t/is (nil? (:attachments email))))) + +(t/deftest rejects-invalid-credentials + (let [{:keys [profile token]} (create-identity)] + (doseq [credential [nil "" "invalid" + (tokens/generate th/*system* {:iss "other" + :uid (:id profile) :tid (:id token)}) + (tokens/generate th/*system* {:iss "access-token" + :uid (:id profile) :tid (:id token)}) + (tokens/generate th/*system* {:iss "urn:penpot:mcp-token" + :uid (uuid/next) :tid (:id token)}) + (tokens/generate th/*system* {:iss "urn:penpot:mcp-token" + :uid (:id profile) :tid (uuid/next)}) + (tokens/generate th/*system* {:iss "urn:penpot:mcp-token" + :uid (:id profile) :tid (:id token) + :exp (ct/minus (ct/now) (ct/duration "1h"))})]] + (t/is (= 401 (::yres/status (request credential))))) + (t/is (empty? (emails))))) + +(t/deftest rejects-expired-revoked-and-wrong-type-tokens + (let [{:keys [token profile]} (create-identity)] + (db/update! th/*system* :access-token + {:expires-at (ct/minus (ct/now) (ct/duration "1h"))} {:id (:id token)}) + (t/is (= 401 (::yres/status (request (:token token))))) + (db/update! th/*system* :access-token {:expires-at nil :type "personal"} {:id (:id token)}) + (t/is (= 401 (::yres/status (request (:token token))))) + (db/update! th/*system* :access-token {:type "mcp"} {:id (:id token)}) + (db/tx-run! th/*system* access-token/create-access-token (:id profile) "Replacement" nil "mcp") + (t/is (= 401 (::yres/status (request (:token token))))) + (t/is (empty? (emails))))) + +(t/deftest rejects-unavailable-profiles + (doseq [changes [{:is-active false} + {:is-blocked true} + {:deleted-at (ct/minus (ct/now) (ct/duration "1h"))}]] + (let [{:keys [token profile]} (create-identity)] + (db/update! th/*system* :profile changes {:id (:id profile)}) + (t/is (= (if (:deleted-at changes) 404 403) + (::yres/status (request (:token token))))))) + (t/is (empty? (emails)))) + +(t/deftest validates-feedback-before-queueing + (let [{:keys [token]} (create-identity)] + (doseq [params [{} + (assoc feedback :subject "") + (assoc feedback :subject " ") + (assoc feedback :subject "a\r\nBcc: victim@example.com") + (assoc feedback :subject "a\tb") + (assoc feedback :subject "a\u0085b") + (assoc feedback :subject "a\u0000b") + (assoc feedback :feedback "a\u0000b") + (assoc feedback :feedback "a\u001bb") + (assoc feedback :subject (apply str (repeat 121 "x"))) + (assoc feedback :feedback " \n") + (assoc feedback :feedback (apply str (repeat 5001 "x"))) + (assoc feedback :to "victim@example.com") + (assoc feedback :attachments {}) + (assoc feedback :feedback 42)]] + (t/is (= 400 (::yres/status (request (:token token) params))))) + (t/is (empty? (emails))) + (t/is (= 200 (::yres/status + (request (:token token) {:subject (apply str (repeat 120 "x")) + :feedback (apply str (repeat 5000 "x"))})))) + (t/is (= 200 (::yres/status + (request (:token token) {:subject "Tabs and breaks" + :feedback "line one\r\n\tline two\n"})))))) + +(t/deftest audit-event-omits-feedback-text + (let [{:keys [profile]} (create-identity)] + (with-redefs [cf/config (assoc cf/config :mcp-feedback-email "mcp@example.com")] + (let [result (th/command-raw! (merge feedback {::th/type :send-mcp-feedback + ::rpc/profile-id (:id profile) + ::rpc/auth-type :token + ::rpc/token-type "mcp"}))] + (t/is (= {} (:app.loggers.audit/replace-props (meta result)))))))) + +(t/deftest disabled-without-feedback-email + (let [{:keys [token]} (create-identity)] + (with-redefs [cf/config (dissoc cf/config :mcp-feedback-email)] + (let [response ((#'http/router-handler (::http/router th/*system*)) + (th/make-dummy-request + {:path "/api/main/methods/send-mcp-feedback" :method :post + :headers {"authorization" (str "Token " (:token token)) + "content-type" "application/json" + "x-client" "penpot-mcp"} + :body-bytes (json/encode feedback)}))] + (t/is (= 400 (::yres/status response))))) + (t/is (empty? (emails))))) + +(t/deftest feedback-is-post-only + (let [{:keys [token]} (create-identity)] + (t/is (= 405 (::yres/status (request (:token token) feedback {:method :get}))))) + (t/is (empty? (emails)))) + +(t/deftest mcp-token-does-not-authorize-ordinary-rpc + (let [{:keys [token]} (create-identity)] + (t/is (= 403 (::yres/status + (request (:token token) {} {:path "/api/rpc/command/get-teams"})))))) + +(t/deftest does-not-acknowledge-a-failed-queue-submission + (let [{:keys [token]} (create-identity)] + (with-redefs [email/send (fn [& _] (throw (ex-info "Queue unavailable" {})))] + (t/is (= 500 (::yres/status (request (:token token))))))) + (t/is (empty? (emails)))) diff --git a/backend/test/backend_tests/rpc_test.clj b/backend/test/backend_tests/rpc_test.clj index 233ffdd8e2..a6e18d24f3 100644 --- a/backend/test/backend_tests/rpc_test.clj +++ b/backend/test/backend_tests/rpc_test.clj @@ -902,6 +902,27 @@ (t/is (some? ex)) (t/is (th/ex-of-type? ex :not-found)))) +;; --- TOKEN TYPE RESTRICTIONS + +(t/deftest mcp-token-authentication + (let [pid (uuid/custom 1 1) + method (fn [mdata] + (rpc/make-rpc-handler + {:test-method [mdata (partial (#'rpc/wrap-authentication + nil (fn [_ params] params) mdata) + nil)]})) + handler (method {::rpc/auth-type :token ::rpc/token-type "mcp"})] + (doseq [extras [{::session/profile-id pid} + {::actoken/profile-id pid} + {::actoken/profile-id pid ::actoken/type "personal"}]] + (t/is (th/ex-of-code? (call-ex handler {:extras extras}) :token-auth-required))) + (let [extras {::actoken/profile-id pid ::actoken/type "mcp"}] + (t/is (= "mcp" (::rpc/token-type (call-body handler {:extras extras})))) + (t/is (th/ex-of-code? (call-ex (method {}) {:extras extras}) :token-auth-required))) + (t/is (= pid (::rpc/profile-id + (call-body (method {}) {:extras {::actoken/profile-id pid + ::actoken/type "personal"}})))))) + ;; --- PARAMS VALIDATION: SERVER AUTH CONTEXT VS CLIENT PARAMS (t/deftest client-qualified-keys-do-not-override-server-auth-context @@ -915,7 +936,8 @@ request {:params {:name "valid" :app.rpc/profile-id attacker-pid :app.rpc/auth-type :token - :app.rpc/token-perms #{"admin"}}} + :app.rpc/token-perms #{"admin"} + :app.rpc/token-type "mcp"}} params (with-meta {::rpc/profile-id server-pid ::rpc/auth-type :session} {::http/request request})] @@ -923,6 +945,7 @@ (t/is (= server-pid (::rpc/profile-id @captured))) (t/is (= :session (::rpc/auth-type @captured))) (t/is (not (contains? @captured ::rpc/token-perms))) + (t/is (not (contains? @captured ::rpc/token-type))) (t/is (= "valid" (:name @captured))))) ;; --- RESPONSE: NIL BODY WITH CUSTOM HEADERS diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 4c69760e24..5bca9bd6a0 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -191,6 +191,10 @@ services: PENPOT_SMTP_TLS: "false" PENPOT_SMTP_SSL: "false" + ## Inbox for the feedback that MCP agents send. Set it together with + ## PENPOT_MCP_BACKEND_URI on the penpot-mcp service. + # PENPOT_MCP_FEEDBACK_EMAIL: mcp-feedback@example.com + penpot-admin-console: image: "penpotapp/admin-console:${PENPOT_VERSION:-2.18}" restart: always @@ -215,6 +219,11 @@ services: networks: - penpot + # environment: + ## Offers the submit_feedback tool, which emails agent feedback through the + ## backend. Set it together with PENPOT_MCP_FEEDBACK_EMAIL on the backend. + # PENPOT_MCP_BACKEND_URI: http://penpot-backend:6060/ + penpot-exporter: image: "penpotapp/exporter:${PENPOT_VERSION:-2.18}" restart: always diff --git a/docs/mcp/index.md b/docs/mcp/index.md index fb9c927106..6b55a3f816 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -104,6 +104,9 @@ Current tools in **local MCP**: * `penpot_api_info` * `export_shape` * `import_image` +* `submit_feedback`, only when the server has `PENPOT_MCP_BACKEND_URI` set (see the [configuration guide](/technical-guide/configuration/#mcp)) + +`submit_feedback` emails plain-text feedback about Penpot MCP to the team that runs your Penpot instance. The agent sends it only when you ask or approve, and the email includes your name and email address. It needs your MCP key: the remote setup already sends it, and in local MCP you add it to the server URL as `userToken`. Because **remote MCP** does not expose local file-system access: diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 91c6ab740d..4759855e14 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -959,6 +959,30 @@ The defaults match the service name used in the official `docker-compose.yaml`. them only if your MCP service has a different name or listens on other ports. Both variables are ignored when the `enable-mcp` flag is not set. +#### MCP feedback + +Agents can email feedback about Penpot MCP to an inbox you choose, through the +`submit_feedback` tool. Set the backend URI on the MCP container to offer the tool, +and the inbox on the backend to accept the emails: + +```bash +# MCP +PENPOT_MCP_BACKEND_URI: http://penpot-backend:6060/ + +# Backend +PENPOT_MCP_FEEDBACK_EMAIL: mcp-feedback@example.com +``` + +- `PENPOT_MCP_BACKEND_URI`: The URI the MCP server uses to reach the backend. When + unset, the MCP server does not offer the tool. +- `PENPOT_MCP_FEEDBACK_EMAIL`: The address that receives the feedback. When unset, + the backend rejects submissions. + +The backend accepts feedback when the `enable-mcp` or `enable-access-tokens` flag is +set, and sends it through the [email configuration](#email-configuration). Each +email carries the name and email address of the user who sent it, and uses that +address as reply-to. + ### Internal resolver The frontend container resolves the backend, exporter and MCP service names with the diff --git a/mcp/README.md b/mcp/README.md index b38b60d502..4c206d9e35 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -290,7 +290,7 @@ The Penpot MCP server can be configured using environment variables. | Environment Variable | Description | Default | |--------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------| | `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | -| `PENPOT_MCP_SERVER_PORT` | Port for the MCP server | `4401` | +| `PENPOT_MCP_SERVER_PORT` | Port for the MCP server | `4401` | | `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | | `PENPOT_MCP_REPL_HOST` | Address on which the REPL server listens (binds to) | `localhost` | @@ -301,6 +301,17 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | | `PENPOT_MCP_AUTH_URI` | Public base URL of the MCP authorization service, e.g. `https://penpot.example/api/mcp/oauth`. Set to enable OAuth; requires `--multi-user`. Use HTTPS (HTTP is allowed on loopback for development), without credentials, query parameters, or a fragment. | (unset; OAuth disabled) | | `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | +| `PENPOT_MCP_BACKEND_URI` | Penpot instance base URI. Setting a nonblank value enables tools that communicate with the Penpot backend (currently only the `submit_feedback` tool). The MCP server appends suffixes such as `api/main/methods/send-mcp-feedback`. Backend interactions require MCP user tokens to be provided. | (unset) | + +### Backend Configuration + +If `PENPOT_MCP_BACKEND_URI` is set, the backend must itself be configured correspondingly, i.e. set +the following variables for the backend process: + +| Environment Variable | Description | Default | +|----------------------|-------------|---------| +| `PENPOT_MCP_FEEDBACK_EMAIL` | Selects the inbox for MCP feedback. Unset means the command rejects submissions. Requires the `enable-mcp` or `enable-access-tokens` flag and uses the backend's existing SMTP configuration. | (unset) | + ### Logging Configuration diff --git a/mcp/packages/server/src/Feedback.test.ts b/mcp/packages/server/src/Feedback.test.ts new file mode 100644 index 0000000000..b5b6460250 --- /dev/null +++ b/mcp/packages/server/src/Feedback.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import { once } from "node:events"; +import { afterEach, beforeEach, test } from "node:test"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { PenpotMcpServer } from "./PenpotMcpServer"; +import { PenpotBackendClient } from "./PenpotBackendClient"; + +let previousEnv: NodeJS.ProcessEnv; +let backend: Server; +let backendUri: string; +let server: PenpotMcpServer | undefined; +let client: Client | undefined; +let requests: { url: string; authorization?: string; client?: string; body: unknown }[]; +let responseStatus: number; +let nextPort = 17_700; + +beforeEach(async () => { + previousEnv = { ...process.env }; + process.env.PENPOT_MCP_SERVER_HOST = "127.0.0.1"; + process.env.PENPOT_MCP_SERVER_PORT = String(nextPort++); + process.env.PENPOT_MCP_WEBSOCKET_PORT = "0"; + process.env.PENPOT_MCP_DEVENV = "false"; + process.env.PENPOT_MCP_REPL_ENABLE = "false"; + delete process.env.PENPOT_MCP_REDIS_URI; + delete process.env.PENPOT_MCP_BACKEND_URI; + requests = []; + responseStatus = 200; + backend = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + requests.push({ + url: req.url!, + authorization: req.headers.authorization, + client: req.headers["x-client"] as string, + body: JSON.parse(body), + }); + res.writeHead(responseStatus, { + "content-type": "application/json", + ...(responseStatus === 307 ? { location: `${backendUri}/redirected` } : {}), + }); + res.end(JSON.stringify({ status: "queued", detail: "private backend diagnostics" })); + }); + backend.listen(0, "127.0.0.1"); + await once(backend, "listening"); + const address = backend.address(); + assert.ok(address && typeof address === "object"); + backendUri = `http://127.0.0.1:${address.port}/penpot`; +}); + +afterEach(async () => { + await client?.close(); + await server?.stop(); + client = undefined; + server = undefined; + backend.closeAllConnections(); + await new Promise((resolve, reject) => backend.close((error) => (error ? reject(error) : resolve()))); + process.env = previousEnv; +}); + +async function connect(multiUser: boolean, token?: string) { + server = new PenpotMcpServer(multiUser); + await server.start(); + client = new Client({ name: "feedback-test", version: "1" }); + const url = new URL(`http://127.0.0.1:${server.port}/mcp`); + if (token) url.searchParams.set("userToken", token); + await client.connect(new StreamableHTTPClientTransport(url)); + return client; +} + +const feedback = { subject: "Export fails", feedback: "Expected a PNG, received an error." }; + +for (const multiUser of [false, true]) { + test(`feedback is absent without a backend URI (multi-user=${multiUser})`, async () => { + const connected = await connect(multiUser, "alice"); + assert.ok(!(await connected.listTools()).tools.some((tool) => tool.name === "submit_feedback")); + }); + + test(`feedback works without a plugin (multi-user=${multiUser})`, async () => { + process.env.PENPOT_MCP_BACKEND_URI = backendUri; + const connected = await connect(multiUser, "alice"); + const tool = (await connected.listTools()).tools.find((tool) => tool.name === "submit_feedback"); + assert.ok(tool); + assert.deepEqual(tool.inputSchema.required, ["subject", "feedback"]); + const result = await connected.callTool({ name: "submit_feedback", arguments: feedback }); + assert.deepEqual(result.content, [ + { type: "text", text: "Feedback queued. It is emailed in the background, so delivery is not confirmed." }, + ]); + assert.deepEqual(requests, [ + { + url: "/penpot/api/main/methods/send-mcp-feedback", + authorization: "Token alice", + client: "penpot-mcp", + body: feedback, + }, + ]); + }); + + test(`missing credentials leave the tool listed but prevent submission (multi-user=${multiUser})`, async () => { + process.env.PENPOT_MCP_BACKEND_URI = backendUri; + const connected = await connect(multiUser); + assert.ok((await connected.listTools()).tools.some((tool) => tool.name === "submit_feedback")); + const result = await connected.callTool({ name: "submit_feedback", arguments: feedback }); + assert.match(JSON.stringify(result.content), /userToken.*MCP.*URL/); + assert.equal(requests.length, 0); + }); +} + +test("blank backend URI does not enable feedback", async () => { + process.env.PENPOT_MCP_BACKEND_URI = " "; + const connected = await connect(false); + assert.ok(!(await connected.listTools()).tools.some((tool) => tool.name === "submit_feedback")); +}); + +test("rejects invalid input without contacting the backend", async () => { + process.env.PENPOT_MCP_BACKEND_URI = backendUri; + const connected = await connect(false, "alice"); + for (const args of [ + { ...feedback, subject: "" }, + { ...feedback, subject: " " }, + { ...feedback, subject: "a\r\nBcc: other@example.com" }, + { ...feedback, subject: "a\tb" }, + { ...feedback, subject: "a\u0085b" }, + { ...feedback, feedback: "a\u0000b" }, + { ...feedback, feedback: "a\u001bb" }, + { ...feedback, subject: "x".repeat(121) }, + { ...feedback, feedback: " \n" }, + { ...feedback, feedback: "x".repeat(5001) }, + ]) { + // SDK versions may return a tool error or reject with a protocol error. + try { + const result = await connected.callTool({ name: "submit_feedback", arguments: args }); + assert.equal(result.isError, true); + } catch (error) { + assert.match(String(error), /invalid|validation/i); + } + } + assert.equal(requests.length, 0); +}); + +test("preserves deployment prefixes with and without a trailing slash", async () => { + for (const uri of [backendUri, backendUri + "/"]) { + await new PenpotBackendClient(uri).submitFeedback("alice", feedback); + } + assert.deepEqual( + requests.map((request) => request.url), + ["/penpot/api/main/methods/send-mcp-feedback", "/penpot/api/main/methods/send-mcp-feedback"] + ); +}); + +test("keeps credentials isolated across concurrent submissions", async () => { + const backendClient = new PenpotBackendClient(backendUri); + await Promise.all( + ["alice", "bob"].map((token) => + backendClient.submitFeedback(token, { + subject: token, + feedback: "A concrete problem.", + }) + ) + ); + assert.deepEqual( + requests + .map((request) => ({ + authorization: request.authorization, + subject: (request.body as typeof feedback).subject, + })) + .sort((a, b) => a.subject.localeCompare(b.subject)), + [ + { authorization: "Token alice", subject: "alice" }, + { authorization: "Token bob", subject: "bob" }, + ] + ); +}); + +for (const status of [400, 401, 403, 404, 429, 503, 500, 307]) { + test(`does not retry or expose response bodies on status ${status}`, async () => { + responseStatus = status; + const backendClient = new PenpotBackendClient(backendUri); + await assert.rejects(backendClient.submitFeedback("secret-token", feedback), (error: Error) => { + assert.doesNotMatch(error.message, /private backend diagnostics|secret-token/); + return true; + }); + assert.equal(requests.length, 1); + }); +} + +test("bounds network waits and does not retry an uncertain submission", async (t) => { + backend.removeAllListeners("request"); + let count = 0; + backend.on("request", (_req, _res) => { + count++; + }); + const timeout = AbortSignal.timeout.bind(AbortSignal); + t.mock.method(AbortSignal, "timeout", (milliseconds: number) => { + assert.equal(milliseconds, 10_000); + return timeout(50); + }); + await assert.rejects( + new PenpotBackendClient(backendUri).submitFeedback("secret-token", feedback), + /could not be confirmed.*Do not retry automatically/ + ); + assert.ok(count <= 1); +}); + +test("rejects invalid backend configuration without echoing credentials", () => { + for (const uri of [ + "invalid", + "file:///tmp", + "https://user:secret@example.com", + "https://example.com/?secret=value", + "https://example.com/#fragment", + ]) { + assert.throws( + () => new PenpotBackendClient(uri), + (error: Error) => { + assert.doesNotMatch(error.message, /secret|value|user:/); + return true; + } + ); + } +}); diff --git a/mcp/packages/server/src/PenpotBackendClient.ts b/mcp/packages/server/src/PenpotBackendClient.ts new file mode 100644 index 0000000000..07bebddf97 --- /dev/null +++ b/mcp/packages/server/src/PenpotBackendClient.ts @@ -0,0 +1,86 @@ +import { createLogger } from "./logger"; + +/** A plain-text MCP feedback report. */ +export interface Feedback { + subject: string; + feedback: string; +} + +/** Backend operations available to MCP tools. */ +export interface PenpotBackend { + submitFeedback(userToken: string, feedback: Feedback): Promise; +} + +/** Client for the configured Penpot instance's backend services. */ +export class PenpotBackendClient implements PenpotBackend { + private readonly logger = createLogger("PenpotBackendClient"); + private readonly baseUri: URL; + + constructor(baseUri: string) { + try { + this.baseUri = new URL(baseUri); + } catch { + throw new Error("PENPOT_MCP_BACKEND_URI must be an absolute HTTP(S) instance URI."); + } + if ( + !["http:", "https:"].includes(this.baseUri.protocol) || + this.baseUri.username || + this.baseUri.password || + this.baseUri.search || + this.baseUri.hash + ) { + throw new Error( + "PENPOT_MCP_BACKEND_URI must be an HTTP(S) instance URI without credentials, query or fragment." + ); + } + if (!this.baseUri.pathname.endsWith("/")) this.baseUri.pathname += "/"; + } + + public async submitFeedback(userToken: string, feedback: Feedback): Promise { + await this.callRpc("send-mcp-feedback", userToken, feedback); + } + + /** Calls a backend RPC method whose result body is not needed. */ + private async callRpc(methodName: string, userToken: string, params: object): Promise { + let response: Response; + try { + response = await fetch(new URL(`api/main/methods/${encodeURIComponent(methodName)}`, this.baseUri), { + method: "POST", + headers: { + Authorization: `Token ${userToken}`, + "X-Client": "penpot-mcp", + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(params), + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + // discard backend diagnostics, which may contain private data + await response.body?.cancel(); + } catch (error) { + this.logger.error(error, "Penpot RPC request failed"); + throw new Error("The backend operation could not be confirmed. Do not retry automatically."); + } + + if (response.ok) return; + + switch (response.status) { + case 401: + throw new Error( + "The backend did not accept the MCP user token. It may be outdated, " + + "or the Penpot instance may not have access tokens enabled." + ); + case 403: + throw new Error("The MCP user token is not authorized for this backend operation."); + case 400: + throw new Error("The backend rejected the request parameters or the operation is disabled."); + case 429: + throw new Error("The backend request limit has been reached. Do not retry automatically."); + default: + throw new Error( + `The backend operation could not be confirmed (HTTP ${response.status}). Do not retry automatically.` + ); + } + } +} diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 2ec5cbe146..445fb4ee4e 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -8,17 +8,19 @@ import { ExecuteCodeTool } from "./tools/ExecuteCodeTool"; import { PluginBridge } from "./PluginBridge"; import { RedisBridge } from "./RedisBridge"; import { ConfigurationLoader } from "./ConfigurationLoader"; +import { PenpotBackendClient } from "./PenpotBackendClient"; +import { SubmitFeedbackTool } from "./tools/SubmitFeedbackTool"; import { createLogger } from "./logger"; import { Tool } from "./Tool"; import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool"; import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool"; import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; -import { CljsReplTool } from "./tools/CljsReplTool"; -import { ImportPenpotFileTool } from "./tools/ImportPenpotFileTool"; -import { CljsCompilerOutputTool } from "./tools/CljsCompilerOutputTool"; -import { CljCheckParentheses } from "./tools/CljCheckParentheses"; -import { ReadTaigaIssueTool } from "./tools/ReadTaigaIssueTool"; +import { CljsReplTool } from "./tools/dev/CljsReplTool"; +import { ImportPenpotFileTool } from "./tools/dev/ImportPenpotFileTool"; +import { CljsCompilerOutputTool } from "./tools/dev/CljsCompilerOutputTool"; +import { CljCheckParentheses } from "./tools/dev/CljCheckParentheses"; +import { ReadTaigaIssueTool } from "./tools/dev/ReadTaigaIssueTool"; import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; @@ -264,6 +266,10 @@ export class PenpotMcpServer { if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } + const backendUri = process.env.PENPOT_MCP_BACKEND_URI?.trim(); + if (backendUri) { + toolInstances.push(new SubmitFeedbackTool(this, new PenpotBackendClient(backendUri))); + } if (shouldRegisterDeveloperTools(this.isDevEnv(), this.isMultiUserMode())) { const nreplClient = new NreplClient(); toolInstances.push(new CljsReplTool(this, nreplClient)); diff --git a/mcp/packages/server/src/tools/SubmitFeedbackTool.ts b/mcp/packages/server/src/tools/SubmitFeedbackTool.ts new file mode 100644 index 0000000000..4c7b92f952 --- /dev/null +++ b/mcp/packages/server/src/tools/SubmitFeedbackTool.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import type { Feedback, PenpotBackend } from "../PenpotBackendClient"; +import type { PenpotMcpServer } from "../PenpotMcpServer"; +import { Tool } from "../Tool"; +import { TextResponse, type ToolResponse } from "../ToolResponse"; + +/** Arguments for submitting a plain-text feedback report. */ +export class SubmitFeedbackArgs implements Feedback { + static schema = { + subject: z + .string() + .min(1) + .max(120) + .regex(/\S/) + .regex(/^\P{Cc}*$/u) + .describe("Short, specific summary without line breaks or other control characters (max 120 characters)."), + feedback: z + .string() + .min(1) + .max(5000) + .regex(/\S/) + // line breaks and tabs are the only control characters allowed + .regex(/^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]*$/) + .describe("Concise plain-text feedback (max 5,000 characters)."), + }; + + subject!: string; + feedback!: string; +} + +/** Tool for user-approved feedback about the Penpot MCP integration. */ +export class SubmitFeedbackTool extends Tool { + constructor( + mcpServer: PenpotMcpServer, + private readonly backend: PenpotBackend + ) { + super(mcpServer, SubmitFeedbackArgs.schema); + } + + public getToolName(): string { + return "submit_feedback"; + } + + public getToolDescription(): string { + return ( + "Submit feedback about Penpot MCP, provided that the user explicitly requests or approves submission " + + "and there is a concrete, actionable issue to report. " + + "Focus on bugs, limitations, or unclear documentation that blocked work or caused substantial rework. " + + "Briefly propose what to report and ask the user at a natural pause. " + + "Skip vague dissatisfaction, routine transient errors, and your own coding mistakes. " + + "Describe the operation, expected and actual behavior, and any workaround. Exclude sensitive content." + ); + } + + protected formatArgs(_args: SubmitFeedbackArgs): string { + return "[feedback omitted]"; + } + + protected async executeCore(args: SubmitFeedbackArgs): Promise { + const userToken = this.getSessionContext()?.userToken; + if (!userToken?.trim()) { + throw new Error("Include userToken in the MCP server URL to submit feedback."); + } + await this.backend.submitFeedback(userToken, { subject: args.subject, feedback: args.feedback }); + return new TextResponse("Feedback queued. It is emailed in the background, so delivery is not confirmed."); + } +} diff --git a/mcp/packages/server/src/tools/CljCheckParentheses.ts b/mcp/packages/server/src/tools/dev/CljCheckParentheses.ts similarity index 97% rename from mcp/packages/server/src/tools/CljCheckParentheses.ts rename to mcp/packages/server/src/tools/dev/CljCheckParentheses.ts index 81e2a9628c..d7a5af6fd2 100644 --- a/mcp/packages/server/src/tools/CljCheckParentheses.ts +++ b/mcp/packages/server/src/tools/dev/CljCheckParentheses.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { Tool } from "../Tool"; +import { Tool } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import type { PenpotMcpServer } from "../PenpotMcpServer"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import type { PenpotMcpServer } from "../../PenpotMcpServer"; import * as fs from "fs"; /** diff --git a/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts b/mcp/packages/server/src/tools/dev/CljsCompilerOutputTool.ts similarity index 86% rename from mcp/packages/server/src/tools/CljsCompilerOutputTool.ts rename to mcp/packages/server/src/tools/dev/CljsCompilerOutputTool.ts index 11989c6303..772d9461df 100644 --- a/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts +++ b/mcp/packages/server/src/tools/dev/CljsCompilerOutputTool.ts @@ -1,9 +1,9 @@ -import { Tool, EmptyToolArgs } from "../Tool"; +import { Tool, EmptyToolArgs } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import { PenpotMcpServer } from "../PenpotMcpServer"; -import { NreplClient } from "../NreplClient"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; +import { NreplClient } from "../../NreplClient"; /** * Reports the compiler status of the shadow-cljs `:main` build. diff --git a/mcp/packages/server/src/tools/CljsReplTool.ts b/mcp/packages/server/src/tools/dev/CljsReplTool.ts similarity index 89% rename from mcp/packages/server/src/tools/CljsReplTool.ts rename to mcp/packages/server/src/tools/dev/CljsReplTool.ts index bd894caef3..ab2babf56d 100644 --- a/mcp/packages/server/src/tools/CljsReplTool.ts +++ b/mcp/packages/server/src/tools/dev/CljsReplTool.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -import { Tool } from "../Tool"; +import { Tool } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import { PenpotMcpServer } from "../PenpotMcpServer"; -import { NreplClient } from "../NreplClient"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; +import { NreplClient } from "../../NreplClient"; /** * Arguments for the CljsReplTool. diff --git a/mcp/packages/server/src/tools/ImportPenpotFileTool.ts b/mcp/packages/server/src/tools/dev/ImportPenpotFileTool.ts similarity index 98% rename from mcp/packages/server/src/tools/ImportPenpotFileTool.ts rename to mcp/packages/server/src/tools/dev/ImportPenpotFileTool.ts index f55ae194f2..50d18f5ae5 100644 --- a/mcp/packages/server/src/tools/ImportPenpotFileTool.ts +++ b/mcp/packages/server/src/tools/dev/ImportPenpotFileTool.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -import { Tool } from "../Tool"; -import { TextResponse, ToolResponse } from "../ToolResponse"; +import { Tool } from "../../Tool"; +import { TextResponse, ToolResponse } from "../../ToolResponse"; import "reflect-metadata"; -import { PenpotMcpServer } from "../PenpotMcpServer"; -import { NreplClient } from "../NreplClient"; -import { createLogger } from "../logger"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; +import { NreplClient } from "../../NreplClient"; +import { createLogger } from "../../logger"; import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; diff --git a/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts b/mcp/packages/server/src/tools/dev/ReadTaigaIssueTool.ts similarity index 96% rename from mcp/packages/server/src/tools/ReadTaigaIssueTool.ts rename to mcp/packages/server/src/tools/dev/ReadTaigaIssueTool.ts index 29e8bea460..decd926182 100644 --- a/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts +++ b/mcp/packages/server/src/tools/dev/ReadTaigaIssueTool.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { Tool } from "../Tool"; +import { Tool } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import { PenpotMcpServer } from "../PenpotMcpServer"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; /** * Arguments for the {@link ReadTaigaIssueTool}. From 2607ec89fa2a000c410b33b303912925d3fc777d Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 12:09:56 +0100 Subject: [PATCH 37/65] :recycle: Move the profile page to the DS and make the Update overlay a button (#12651) * :recycle: Make the profile photo Update overlay a button The "Update" overlay on the profile photo was a span with an on-click, so the keyboard could not reach it. It is now a button: it shows on keyboard focus as well as on hover. The button centers its line in the circle, so a 4px bottom padding keeps the label where the span drew it (0px difference). New ds visual test "Profile page controls": the page at rest, the overlay on hover and on focus, Save enabled, and the Change email link on hover. AI-assisted-by: claude-opus-5-5 * :recycle: Move profile page styles off the deprecated styles profile.scss imported the whole deprecated module and kept a long copy of shared settings rules. It now uses the DS tokens, sizes, borders, z-index and typography. The deprecated import stays only for %button-primary: the save button is the shared forms submit button. Deleted selectors that match nothing in profile.cljs: .btn-secondary (both), .field, .field-title, .field-text, .custom-input, .custom-select, .two-columns, .subtitle, .notification-icon, .notification-text, .notification-text-email, .newsletter-subs, the h1, h3, p and hr rules, the form rule (both forms set their own flex) and the link hover underline (the global link rule already does it). Typographies: the page title uses title-large (line-height 1.4 instead of 1.15), the Change email link title-small (line-height 1.2 instead of the default), and the Update overlay title-large with its own line-height. Visible change: the taller title line moves the title 3px down and the rest of the page 6px down. Nothing else changes. AI-assisted-by: claude-opus-5-5 * :recycle: Give the profile page links, title and photo classes The links and the profile photo get their own classes instead of bare a and img selectors, and the page title uses the DS heading with title-large (it already used that typography). The link wrapper drops a class with no styles, and the rule for the file input goes: the shared uploader hides that input inline, so the rule did nothing. No visual change. AI-assisted-by: claude-opus-5-5 * :lipstick: Use one DS typography and the accent color for the profile links "Change email" took title-small from its container, overridden by the global link styles (14px, weight 500), and "Want to remove your account?" had no typography and inherited 16px. Both links now use body-medium and --color-accent-primary, as design asked in the review. The "Change email" line is taller with the 1.4 line height, so Save settings and the link below it sit 3px lower. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 36 ++ .../src/app/main/ui/settings/profile.cljs | 25 +- .../src/app/main/ui/settings/profile.scss | 443 +++++------------- 3 files changed, 168 insertions(+), 336 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 878a02d347..932ebb7061 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -833,6 +833,42 @@ test.describe("Onboarding questions controls", () => { }); }); +test.describe("Profile page controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Profile page avatar, form and links", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + await dashboardPage.goToAccount(); + + const container = page.locator( + "[class*='settings_profile__form-container']", + ); + await expect(page.getByText("Change email")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(container).toHaveScreenshot(shot); + + // The "Update" overlay shows when the pointer is on the avatar. + await page.locator("[class*='image-change-field']").hover(); + await expect(container).toHaveScreenshot(shot); + + // Save turns enabled once the name changes. + await page.getByLabel("Your name").fill("Renamed user"); + await page.mouse.move(10, 700); + await expect(container).toHaveScreenshot(shot); + + await page.getByText("Change email").hover(); + await expect(container).toHaveScreenshot(shot); + + // The "Update" overlay is a button: it shows on keyboard focus too. + await page.mouse.move(10, 700); + await page.getByRole("button", { name: "Update" }).focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(container).toHaveScreenshot(shot); + }); +}); + test.describe("Templates section controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; diff --git a/frontend/src/app/main/ui/settings/profile.cljs b/frontend/src/app/main/ui/settings/profile.cljs index 1c1add422b..74f073ae36 100644 --- a/frontend/src/app/main/ui/settings/profile.cljs +++ b/frontend/src/app/main/ui/settings/profile.cljs @@ -17,6 +17,8 @@ [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.forms :as fm] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -71,7 +73,8 @@ [:div {:class (stl/css :options)} [:div.change-email - [:a {:on-click on-show-change-email} + [:a {:class (stl/css :link) + :on-click on-show-change-email} (tr "dashboard.change-email")]]]] [:> fm/submit-button* @@ -80,8 +83,9 @@ :class (stl/css :btn-primary)}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :link-item)} - [:a {:on-click on-show-delete-account + [:div + [:a {:class (stl/css :link) + :on-click on-show-delete-account :data-testid "remove-acount-btn"} (tr "dashboard.remove-account")]]]])) @@ -119,9 +123,12 @@ [:form {:class (stl/css :avatar-form)} [:div {:class (stl/css :image-change-field)} - [:span {:class (stl/css :update-overlay) - :on-click on-image-click} (tr "labels.update")] - [:img {:src photo}] + [:button {:type "button" + :class (stl/css :update-overlay) + :on-click on-image-click} + (tr "labels.update")] + [:img {:class (stl/css :avatar-image) + :src photo}] (when has-photo? [:button {:type "button" :class (stl/css :delete-overlay) @@ -146,6 +153,10 @@ [:section {:class (stl/css :dashboard-settings) :aria-labelledby "profile-section-title"} [:div {:class (stl/css :form-container)} - [:h2 {:id "profile-section-title"} (tr "labels.profile")] + [:> heading* {:level 2 + :typography t/title-large + :id "profile-section-title" + :class (stl/css :page-title)} + (tr "labels.profile")] [:> profile-photo-form*] [:> profile-form*]]]) diff --git a/frontend/src/app/main/ui/settings/profile.scss b/frontend/src/app/main/ui/settings/profile.scss index 1a6fca03c6..0c2b954a18 100644 --- a/frontend/src/app/main/ui/settings/profile.scss +++ b/frontend/src/app/main/ui/settings/profile.scss @@ -4,316 +4,83 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "common/refactor/common-refactor" as *; +// Only for %button-primary: the save button is the shared forms submit +// button, which moves to the DS with the forms component. +@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .dashboard-settings { display: flex; - width: 100%; + inline-size: 100%; justify-content: center; align-items: center; +} - a:not(.button-primary, .link) { - color: var(--color-foreground-secondary); - } +.link { + @include use-typography("body-medium"); + + color: var(--color-accent-primary); } .form-container { display: flex; flex-direction: column; - max-width: $s-500; - width: $s-580; - margin: $s-80 auto $s-120 auto; + max-inline-size: $sz-500; + inline-size: px2rem(580); + margin-block: px2rem(80) px2rem(120); + margin-inline: auto; justify-content: center; - - form { - display: flex; - flex-direction: column; - - .btn-secondary { - width: 100%; - font-size: $fs-11; - text-transform: uppercase; - background-color: var(--color-background-tertiary); - color: var(--color-foreground-primary); - - &:hover { - color: var(--color-accent-primary); - background-color: var(--color-background-quaternary); - } - } - - hr { - display: none; - } - } - - .fields-row { - --input-height: #{$s-40}; - - margin-bottom: $s-20; - flex-direction: column; - - .options { - display: flex; - justify-content: flex-end; - font-size: $fs-14; - margin-top: $s-12; - } - } - - .field { - margin-bottom: $s-20; - } - - .field-title { - color: var(--color-foreground-primary); - - &:not(:first-child) { - margin-top: $s-64; - } - } - - .field-text { - color: var(--color-foreground-secondary); - } - - .custom-input, - .custom-select { - flex-direction: column-reverse; - - label { - position: relative; - text-transform: uppercase; - color: var(--color-foreground-primary); - font-size: $fs-11; - margin-bottom: $s-12; - margin-left: calc(-1 * $s-4); - } - - input, - select { - background-color: var(--color-background-tertiary); - border-radius: $br-8; - border-color: transparent; - color: var(--color-foreground-primary); - padding: 0 $s-16; - - &:focus { - outline: $s-1 solid var(--color-accent-primary); - } - - ::placeholder { - color: var(--color-foreground-secondary); - } - } - - .help-icon { - bottom: $s-12; - top: auto; - - svg { - fill: var(--color-foreground-secondary); - } - } - - &.disabled { - input { - background-color: var(--input-background-color-disabled); - border-color: var(--color-background-quaternary); - color: var(--color-foreground-secondary); - } - } - - .input-container { - background-color: var(--color-background-tertiary); - border-radius: $br-8; - border-color: transparent; - margin-top: $s-24; - - .main-content { - label { - position: absolute; - top: calc(-1 * $s-24); - } - - span { - color: var(--color-foreground-primary); - } - } - - &:focus { - border: $s-1 solid var(--color-accent-primary); - } - } - - textarea { - border-radius: $br-8; - padding: $s-12 $s-16; - background-color: var(--color-background-tertiary); - color: var(--color-foreground-primary); - border: none; - - &:focus { - outline: $s-1 solid var(--color-accent-primary); - } - } - } - - &.two-columns { - max-width: $s-520; - justify-content: space-between; - flex-direction: row; - } - - h1 { - font-size: $fs-36; - color: var(--color-background-tertiary); - margin-bottom: $s-20; - } - - .subtitle { - font-size: $fs-24; - color: var(--color-background-tertiary); - margin-bottom: $s-20; - } - - .notification-icon { - justify-content: center; - display: flex; - margin-bottom: $s-48; - - svg { - fill: var(--color-background-primary); - height: 40%; - width: 40%; - } - } - - .notification-text { - font-size: $fs-16; - color: var(--color-background-primary); - margin-bottom: $s-20; - } - - .notification-text-email { - background: var(--color-foreground-primary); - border-radius: $br-4; - color: var(--color-background-primary); - font-size: $fs-16; - font-weight: $fw500; - margin: $s-24 0 $s-40 0; - padding: $s-16; - text-align: center; - } - - h2 { - font-size: $fs-24; - font-weight: $fw400; - color: var(--color-foreground-primary); - display: flex; - align-items: center; - margin: $s-16 0; - } - - h3 { - font-size: $fs-12; - font-weight: $fw400; - color: var(--color-foreground-primary); - display: flex; - align-items: center; - margin: $s-8 0; - text-transform: uppercase; - } - - a:not(.button-primary) { - &:hover { - text-decoration: underline; - } - } - - p { - color: var(--color-background-primary); - } - - hr { - border-color: var(--color-foreground-secondary); - } - - .links { - margin-top: $s-12; - } } -form.avatar-form { +.page-title { + color: var(--color-foreground-primary); + display: flex; + align-items: center; + margin-block: var(--sp-l); + margin-inline: 0; +} + +.fields-row { + --input-height: #{$sz-40}; + + margin-block-end: var(--sp-xl); + flex-direction: column; +} + +.options { + display: flex; + justify-content: flex-end; + margin-block-start: var(--sp-m); +} + +.links { + margin-block-start: var(--sp-m); +} + +.avatar-form { display: flex; flex-direction: column; - width: $s-148; - height: $s-148; - margin: $s-16 0; + inline-size: px2rem(148); + block-size: px2rem(148); + margin-block: var(--sp-l); + margin-inline: 0; +} - img { - border-radius: 50%; - flex-shrink: 0; - height: 100%; - margin-right: $s-16; - width: 100%; - } +.avatar-image { + border-radius: $br-circle; + flex-shrink: 0; + block-size: 100%; + margin-inline-end: var(--sp-l); + inline-size: 100%; } .image-change-field { position: relative; - width: 100%; - height: 100%; - - .update-overlay { - opacity: 0; - cursor: pointer; - position: absolute; - width: 100%; - height: 100%; - border-radius: 50%; - font-size: $fs-24; - color: var(--color-foreground-primary); - line-height: 6; - text-align: center; - background: var(--color-accent-tertiary); - z-index: $z-index-modal; - } - - .delete-overlay { - position: absolute; - top: $s-4; - inset-inline-end: $s-4; - display: flex; - align-items: center; - justify-content: center; - width: $s-32; - height: $s-32; - padding: 0; - border: none; - border-radius: 50%; - background: var(--color-background-primary); - color: var(--color-foreground-primary); - cursor: pointer; - opacity: 0; - transition: opacity 0.15s ease-in-out; - z-index: calc(#{$z-index-modal} + 1); - - &:hover { - background: var(--color-background-quaternary); - color: var(--color-accent-primary); - } - } - - input[type="file"] { - width: 100%; - height: 100%; - position: absolute; - opacity: 0; - cursor: pointer; - top: 0; - z-index: $z-index-modal; - } + inline-size: 100%; + block-size: 100%; &:hover { .update-overlay { @@ -326,52 +93,70 @@ form.avatar-form { } } +.update-overlay { + @include use-typography("title-large"); + + opacity: 0; + cursor: pointer; + position: absolute; + inline-size: 100%; + block-size: 100%; + border: none; + border-radius: $br-circle; + color: var(--color-foreground-primary); + + // Draws the label in the middle of the circle. + line-height: 6; + text-align: center; + background: var(--color-accent-tertiary); + + // The button centers its 144px line in the 148px circle; the bottom + // padding keeps the label where the old span drew it. + padding: 0 0 var(--sp-xs); + z-index: var(--z-index-set); + + &:focus-visible { + opacity: 0.8; + } +} + +.delete-overlay { + --delete-overlay-background-color: var(--color-background-primary); + --delete-overlay-foreground-color: var(--color-foreground-primary); + + position: absolute; + inset-block-start: var(--sp-xs); + inset-inline-end: var(--sp-xs); + display: flex; + align-items: center; + justify-content: center; + inline-size: $sz-32; + block-size: $sz-32; + padding: 0; + border: none; + border-radius: $br-circle; + background: var(--delete-overlay-background-color); + color: var(--delete-overlay-foreground-color); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease-in-out; + z-index: calc(var(--z-index-set) + 1); + + &:hover { + --delete-overlay-background-color: var(--color-background-quaternary); + --delete-overlay-foreground-color: var(--color-accent-primary); + } +} + .profile-form { display: flex; flex-direction: column; - max-width: $s-368; - width: 100%; -} - -.newsletter-subs { - border-bottom: $s-1 solid var(--color-foreground-secondary); - border-top: $s-1 solid var(--color-foreground-secondary); - padding: $s-32 0; - margin-bottom: $s-32; - - .newsletter-title { - font-family: "worksans", "vazirmatn", sans-serif; - color: var(--color-foreground-secondary); - font-size: $fs-14; - } - - label { - font-family: "worksans", "vazirmatn", sans-serif; - color: var(--color-background-primary); - font-size: $fs-12; - margin-right: calc(-1 * $s-16); - margin-bottom: $s-12; - } - - .info { - color: var(--color-foreground-secondary); - font-size: $fs-12; - margin-bottom: $s-8; - } - - .input-checkbox label { - align-items: flex-start; - } -} - -.btn-secondary { - @extend %button-secondary; - - height: $s-32; + max-inline-size: px2rem(368); + inline-size: 100%; } .btn-primary { @extend %button-primary; - height: $s-32; + block-size: $sz-32; } From 755bb175cdb47adac78633437a112af367cb8d98 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 13:23:36 +0100 Subject: [PATCH 38/65] :bug: Remove wrong flag (#12698) --- common/src/app/common/flags.cljc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index a3f3cf44be..164d8c78a9 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -235,8 +235,7 @@ :enable-stroke-per-side :enable-token-combobox :enable-custom-shortcuts - :enable-token-lib-sync - :enable-link-unfurl]) + :enable-token-lib-sync]) (defn parse [& flags] From 76d403cfdbc8c30b988bec2900b85187a22464ad Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 29 Oct 2026 13:34:32 +0100 Subject: [PATCH 39/65] :arrow_up: Update npm dependencies on mcp and exporter --- exporter/package.json | 8 +- exporter/pnpm-lock.yaml | 201 ++++---- mcp/package.json | 4 +- mcp/packages/plugin/package.json | 2 +- mcp/packages/server/package.json | 22 +- mcp/pnpm-lock.yaml | 787 ++++++++++++++++--------------- 6 files changed, 509 insertions(+), 515 deletions(-) diff --git a/exporter/package.json b/exporter/package.json index d04dafe2a5..db033dbf2f 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -13,7 +13,7 @@ "dependencies": { "@penpot/svgo": "penpot/svgo#3.3.0", "archiver": "8.0.0", - "cookies": "^0.9.1", + "cookies": "^0.9.2", "date-fns": "^4.4.0", "generic-pool": "^3.9.0", "inflation": "^2.1.0", @@ -21,12 +21,12 @@ "playwright": "1.62.1", "raw-body": "^4.0.0", "source-map-support": "^0.5.21", - "undici": "^8.10.0", + "undici": "^8.12.3", "xml-js": "^1.6.11", - "xregexp": "^5.1.2" + "xregexp": "^5.1.3" }, "devDependencies": { - "ws": "^8.21.3" + "ws": "^8.22.0" }, "scripts": { "clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target", diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index e30ff1e950..ec66e081a4 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -179,8 +179,8 @@ importers: specifier: 8.0.0 version: 8.0.0 cookies: - specifier: ^0.9.1 - version: 0.9.1 + specifier: ^0.9.2 + version: 0.9.2 date-fns: specifier: ^4.4.0 version: 4.4.0 @@ -203,23 +203,23 @@ importers: specifier: ^0.5.21 version: 0.5.21 undici: - specifier: ^8.10.0 - version: 8.10.0 + specifier: ^8.12.3 + version: 8.12.3 xml-js: specifier: ^1.6.11 version: 1.6.11 xregexp: - specifier: ^5.1.2 - version: 5.1.2 + specifier: ^5.1.3 + version: 5.1.3 devDependencies: ws: - specifier: ^8.21.3 - version: 8.21.3 + specifier: ^8.22.0 + version: 8.22.0 packages: - '@babel/runtime-corejs3@7.29.7': - resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==} + '@babel/runtime-corejs3@7.29.10': + resolution: {integrity: sha512-R4FKrg4Vy0hB7PfAxf9LOorEPd5/36SCIXPXAvjlQaQmPvtrjwoDXV15jsSm2ecsqBW1Iewwb7mEnG83xIEApg==} engines: {node: '>=6.9.0'} '@ioredis/commands@2.0.0': @@ -240,8 +240,8 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - b4a@1.8.1: - resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + b4a@1.9.0: + resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==} peerDependencies: react-native-b4a: '*' peerDependenciesMeta: @@ -252,32 +252,28 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - bare-events@2.9.1: - resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} peerDependencies: bare-abort-controller: '*' peerDependenciesMeta: bare-abort-controller: optional: true - bare-fs@4.7.2: - resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==} - engines: {bare: '>=1.16.0'} + bare-fs@4.8.4: + resolution: {integrity: sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==} + engines: {bare: '>=1.28.0'} peerDependencies: bare-buffer: '*' peerDependenciesMeta: bare-buffer: optional: true - bare-os@3.9.1: - resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} - engines: {bare: '>=1.14.0'} + bare-path@3.1.2: + resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==} - bare-path@3.0.1: - resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==} - - bare-stream@2.13.1: - resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + bare-stream@2.13.4: + resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} peerDependencies: bare-abort-controller: '*' bare-buffer: '*' @@ -290,8 +286,8 @@ packages: bare-events: optional: true - bare-url@2.4.5: - resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + bare-url@2.5.5: + resolution: {integrity: sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -300,8 +296,8 @@ packages: resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} engines: {node: '>=20.19.0'} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} buffer-crc32@1.0.0: @@ -326,12 +322,12 @@ packages: resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} engines: {node: '>=18'} - cookies@0.9.1: - resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + cookies@0.9.2: + resolution: {integrity: sha512-8BIbcC6tPZMv2/PD4PrB3CtKks7LmAWhA7FRZK/6/b/WJ2RUz00KCga7gq50S4/RhZ22kxefRFGgsuts0v3eoA==} engines: {node: '>= 0.8'} - core-js-pure@3.49.0: - resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==} + core-js-pure@3.51.0: + resolution: {integrity: sha512-NE4SFDJ+qzQeqHG2vVSNwopzS6zZNPYJ5kinIqs+OofTqGzOmICgT89E/yvS9XY7rwb3eU1utWXTJ9nVFqv74A==} core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -401,8 +397,8 @@ packages: resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} engines: {node: '>=20.19.0'} - entities@8.0.0: - resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} engines: {node: '>=20.19.0'} event-target-shim@5.0.1: @@ -470,8 +466,8 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} ms@2.1.3: @@ -509,8 +505,8 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - readable-stream@4.7.0: - resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + readable-stream@4.8.0: + resolution: {integrity: sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} readdir-glob@3.0.0: @@ -527,15 +523,15 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - sax@1.6.0: - resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map-support@0.5.21: @@ -552,8 +548,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - streamx@2.27.0: - resolution: {integrity: sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA==} + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -561,8 +557,8 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - tar-stream@3.2.0: - resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + tar-stream@3.2.2: + resolution: {integrity: sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==} teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -578,15 +574,15 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - undici@8.10.0: - resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + undici@8.12.3: + resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==} engines: {node: '>=22.19.0'} util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - ws@8.21.3: - resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -601,8 +597,8 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true - xregexp@5.1.2: - resolution: {integrity: sha512-6hGgEMCGhqCTFEJbqmWrNIPqfpdirdGWkqshu7fFZddmTSfgv5Sn9D2SaKloR79s5VUiUlpwzg3CM3G6D3VIlw==} + xregexp@5.1.3: + resolution: {integrity: sha512-7uOg8BVk9kt6HnkeBdyW3VEq7KXzs4un/Efge4AN1FsAMzM6fYexggbWWkQcfHExQpQEoNkWXnr4wSWpqtx49Q==} zip-stream@7.0.5: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} @@ -610,9 +606,9 @@ packages: snapshots: - '@babel/runtime-corejs3@7.29.7': + '@babel/runtime-corejs3@7.29.10': dependencies: - core-js-pure: 3.49.0 + core-js-pure: 3.51.0 '@ioredis/commands@2.0.0': {} @@ -622,7 +618,7 @@ snapshots: css-tree: 3.2.1 csso: 5.0.5 lodash: 4.18.1 - sax: 1.6.0 + sax: 1.6.1 abort-controller@3.0.0: dependencies: @@ -635,9 +631,9 @@ snapshots: is-stream: 4.0.1 lazystream: 1.0.1 normalize-path: 3.0.0 - readable-stream: 4.7.0 + readable-stream: 4.8.0 readdir-glob: 3.0.0 - tar-stream: 3.2.0 + tar-stream: 3.2.2 zip-stream: 7.0.5 transitivePeerDependencies: - bare-abort-controller @@ -646,47 +642,44 @@ snapshots: async@3.2.6: {} - b4a@1.8.1: {} + b4a@1.9.0: {} balanced-match@4.0.4: {} - bare-events@2.9.1: {} + bare-events@2.9.2: {} - bare-fs@4.7.2: + bare-fs@4.8.4: dependencies: - bare-events: 2.9.1 - bare-path: 3.0.1 - bare-stream: 2.13.1(bare-events@2.9.1) - bare-url: 2.4.5 + bare-events: 2.9.2 + bare-path: 3.1.2 + bare-stream: 2.13.4(bare-events@2.9.2) + bare-url: 2.5.5 fast-fifo: 1.3.2 transitivePeerDependencies: - bare-abort-controller - react-native-b4a - bare-os@3.9.1: {} + bare-path@3.1.2: {} - bare-path@3.0.1: + bare-stream@2.13.4(bare-events@2.9.2): dependencies: - bare-os: 3.9.1 - - bare-stream@2.13.1(bare-events@2.9.1): - dependencies: - streamx: 2.27.0 + b4a: 1.9.0 + streamx: 2.28.1 teex: 1.0.1 optionalDependencies: - bare-events: 2.9.1 + bare-events: 2.9.2 transitivePeerDependencies: - react-native-b4a - bare-url@2.4.5: + bare-url@2.5.5: dependencies: - bare-path: 3.0.1 + bare-path: 3.1.2 base64-js@1.5.1: {} boolbase@2.0.0: {} - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -709,14 +702,14 @@ snapshots: crc32-stream: 7.0.1 is-stream: 4.0.1 normalize-path: 3.0.0 - readable-stream: 4.7.0 + readable-stream: 4.8.0 - cookies@0.9.1: + cookies@0.9.2: dependencies: depd: 2.0.0 keygrip: 1.1.0 - core-js-pure@3.49.0: {} + core-js-pure@3.51.0: {} core-util-is@1.0.3: {} @@ -725,7 +718,7 @@ snapshots: crc32-stream@7.0.1: dependencies: crc-32: 1.2.2 - readable-stream: 4.7.0 + readable-stream: 4.8.0 css-select@7.0.0: dependencies: @@ -738,12 +731,12 @@ snapshots: css-tree@2.2.1: dependencies: mdn-data: 2.0.28 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-tree@3.2.1: dependencies: mdn-data: 2.27.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-what@8.0.0: {} @@ -765,7 +758,7 @@ snapshots: dependencies: domelementtype: 3.0.0 domhandler: 6.0.1 - entities: 8.0.0 + entities: 8.1.0 domelementtype@3.0.0: {} @@ -779,13 +772,13 @@ snapshots: domelementtype: 3.0.0 domhandler: 6.0.1 - entities@8.0.0: {} + entities@8.1.0: {} event-target-shim@5.0.1: {} events-universal@1.0.1: dependencies: - bare-events: 2.9.1 + bare-events: 2.9.2 transitivePeerDependencies: - bare-abort-controller @@ -841,9 +834,9 @@ snapshots: mdn-data@2.27.1: {} - minimatch@10.2.5: + minimatch@10.2.6: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 ms@2.1.3: {} @@ -880,7 +873,7 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 - readable-stream@4.7.0: + readable-stream@4.8.0: dependencies: abort-controller: 3.0.0 buffer: 6.0.3 @@ -890,7 +883,7 @@ snapshots: readdir-glob@3.0.0: dependencies: - minimatch: 10.2.5 + minimatch: 10.2.6 redis-errors@1.2.0: {} @@ -898,11 +891,11 @@ snapshots: safe-buffer@5.2.1: {} - sax@1.6.0: {} + sax@1.6.1: {} setprototypeof@1.2.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map-support@0.5.21: dependencies: @@ -915,7 +908,7 @@ snapshots: statuses@2.0.2: {} - streamx@2.27.0: + streamx@2.28.1: dependencies: events-universal: 1.0.1 fast-fifo: 1.3.2 @@ -932,12 +925,12 @@ snapshots: dependencies: safe-buffer: 5.2.1 - tar-stream@3.2.0: + tar-stream@3.2.2: dependencies: - b4a: 1.8.1 - bare-fs: 4.7.2 + b4a: 1.9.0 + bare-fs: 4.8.4 fast-fifo: 1.3.2 - streamx: 2.27.0 + streamx: 2.28.1 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -945,14 +938,14 @@ snapshots: teex@1.0.1: dependencies: - streamx: 2.27.0 + streamx: 2.28.1 transitivePeerDependencies: - bare-abort-controller - react-native-b4a text-decoder@1.2.7: dependencies: - b4a: 1.8.1 + b4a: 1.9.0 transitivePeerDependencies: - react-native-b4a @@ -960,22 +953,22 @@ snapshots: tsscmp@1.0.6: {} - undici@8.10.0: {} + undici@8.12.3: {} util-deprecate@1.0.2: {} - ws@8.21.3: {} + ws@8.22.0: {} xml-js@1.6.11: dependencies: - sax: 1.6.0 + sax: 1.6.1 - xregexp@5.1.2: + xregexp@5.1.3: dependencies: - '@babel/runtime-corejs3': 7.29.7 + '@babel/runtime-corejs3': 7.29.10 zip-stream@7.0.5: dependencies: compress-commons: 7.0.1 normalize-path: 3.0.0 - readable-stream: 4.7.0 + readable-stream: 4.8.0 diff --git a/mcp/package.json b/mcp/package.json index 58eedd05f6..2b89cf1abc 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -25,7 +25,7 @@ }, "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", "devDependencies": { - "concurrently": "^10.0.5", - "prettier": "^3.9.6" + "concurrently": "^10.0.6", + "prettier": "^3.9.10" } } diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 33b37f2442..6155889516 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -19,7 +19,7 @@ "devDependencies": { "cross-env": "^10.1.0", "typescript": "^6.0.3", - "vite": "^8.2.0", + "vite": "^8.3.5", "vite-live-preview": "^0.4.0" } } diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index c30679f6f6..4fb0e12d41 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -26,31 +26,31 @@ "license": "MIT", "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.32.2", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", - "express": "^5.2.1", + "express": "^5.3.0", "ioredis": "^6.0.0", - "js-yaml": "^5.2.3", + "js-yaml": "^5.5.0", "nrepl-client": "^0.3.0", - "pino": "^10.3.1", + "pino": "^10.4.0", "pino-loki": "^3.0.0", - "pino-pretty": "^13.1.3", + "pino-pretty": "^13.2.0", "reflect-metadata": "^0.2.2", - "sharp": "^0.35.3", - "ws": "^8.21.1", - "zod": "^4.4.3" + "sharp": "^0.35.5", + "ws": "^8.22.0", + "zod": "^4.6.7" }, "devDependencies": { "@penpot/mcp-common": "workspace:../common", "@types/express": "^5.0.6", "@types/js-yaml": "^4.0.9", - "@types/node": "^26.1.2", - "@types/ws": "^8.18.1", + "@types/node": "^26.6.5", + "@types/ws": "^8.18.2", "cross-env": "^10.1.0", "esbuild": "^0.28.1", "ts-node": "^10.9.2", - "tsx": "^4.23.5", + "tsx": "^4.23.15", "typescript": "^6.0.3" }, "ts-node": { diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 13321c823a..0e558241f2 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -170,11 +170,11 @@ importers: .: devDependencies: concurrently: - specifier: ^10.0.5 - version: 10.0.5 + specifier: ^10.0.6 + version: 10.0.6 prettier: - specifier: ^3.9.6 - version: 3.9.6 + specifier: ^3.9.10 + version: 3.9.10 packages/common: devDependencies: @@ -198,17 +198,17 @@ importers: specifier: ^6.0.3 version: 6.0.3 vite: - specifier: ^8.2.0 - version: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12) + specifier: ^8.3.5 + version: 8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15) vite-live-preview: specifier: ^0.4.0 - version: 0.4.0(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12)) + version: 0.4.0(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15)) packages/server: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.29.0 - version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) + specifier: ^1.32.2 + version: 1.32.2(supports-color@10.2.2)(zod@4.6.7) class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -216,38 +216,38 @@ importers: specifier: ^0.15.1 version: 0.15.1 express: - specifier: ^5.2.1 - version: 5.2.1(supports-color@10.2.2) + specifier: ^5.3.0 + version: 5.3.0(supports-color@10.2.2) ioredis: specifier: ^6.0.0 version: 6.0.0(supports-color@10.2.2) js-yaml: - specifier: ^5.2.3 - version: 5.4.1 + specifier: ^5.5.0 + version: 5.5.0 nrepl-client: specifier: ^0.3.0 version: 0.3.0 pino: - specifier: ^10.3.1 - version: 10.3.1 + specifier: ^10.4.0 + version: 10.4.0 pino-loki: specifier: ^3.0.0 version: 3.0.0 pino-pretty: - specifier: ^13.1.3 - version: 13.1.3 + specifier: ^13.2.0 + version: 13.2.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 sharp: - specifier: ^0.35.3 - version: 0.35.4(@types/node@26.4.0) + specifier: ^0.35.5 + version: 0.35.5(@types/node@26.6.5) ws: - specifier: ^8.21.1 - version: 8.21.3 + specifier: ^8.22.0 + version: 8.22.0 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.7 + version: 4.6.7 devDependencies: '@penpot/mcp-common': specifier: workspace:../common @@ -259,11 +259,11 @@ importers: specifier: ^4.0.9 version: 4.0.9 '@types/node': - specifier: ^26.1.2 - version: 26.4.0 + specifier: ^26.6.5 + version: 26.6.5 '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 + specifier: ^8.18.2 + version: 8.18.2 cross-env: specifier: ^10.1.0 version: 10.1.0 @@ -272,10 +272,10 @@ importers: version: 0.28.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@26.4.0)(typescript@6.0.3) + version: 10.9.2(@types/node@26.6.5)(typescript@6.0.3) tsx: - specifier: ^4.23.5 - version: 4.23.12 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -448,170 +448,170 @@ packages: cpu: [x64] os: [win32] - '@hono/node-server@2.1.1': - resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + '@hono/node-server@2.1.5': + resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} engines: {node: '>=20'} peerDependencies: - hono: ^4 + hono: ^4 || ^5.0.0-0 '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -623,14 +623,14 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@modelcontextprotocol/sdk@1.30.0': - resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + '@modelcontextprotocol/sdk@1.32.2': + resolution: {integrity: sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -639,8 +639,8 @@ packages: '@cfworker/json-schema': optional: true - '@oxc-project/types@0.147.0': - resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} '@penpot/plugin-styles@1.5.0': resolution: {integrity: sha512-rwXFBRPd0IZs3ens+eRT8oA5WqkQgF6xlW6fCfH7U1NHRjej9HH7vTGXmfjoIVml6Xx1sbyYHZVxkQC8Ub8+zA==} @@ -651,98 +651,98 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@rolldown/binding-android-arm-eabi@1.2.6': - resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + '@rolldown/binding-android-arm-eabi@1.2.15': + resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.6': - resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + '@rolldown/binding-android-arm64@1.2.15': + resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.6': - resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + '@rolldown/binding-darwin-arm64@1.2.15': + resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.6': - resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + '@rolldown/binding-darwin-x64@1.2.15': + resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.6': - resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + '@rolldown/binding-freebsd-x64@1.2.15': + resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.6': - resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.6': - resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + '@rolldown/binding-linux-arm64-gnu@1.2.15': + resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.6': - resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + '@rolldown/binding-linux-arm64-musl@1.2.15': + resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.6': - resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.6': - resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + '@rolldown/binding-linux-s390x-gnu@1.2.15': + resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.6': - resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + '@rolldown/binding-linux-x64-gnu@1.2.15': + resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.6': - resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + '@rolldown/binding-linux-x64-musl@1.2.15': + resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.6': - resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + '@rolldown/binding-openharmony-arm64@1.2.15': + resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.6': - resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + '@rolldown/binding-win32-arm64-msvc@1.2.15': + resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.6': - resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + '@rolldown/binding-win32-x64-msvc@1.2.15': + resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -786,8 +786,8 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/node@26.4.0': - resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -804,8 +804,8 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -815,8 +815,8 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.18.0: - resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} engines: {node: '>=0.4.0'} hasBin: true @@ -831,8 +831,8 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} engines: {node: '>=12'} ansi-styles@6.2.3: @@ -842,8 +842,8 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.4: + resolution: {integrity: sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==} atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} @@ -852,8 +852,8 @@ packages: bencode@2.0.3: resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} - body-parser@2.3.0: - resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + body-parser@2.4.0: + resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==} engines: {node: '>=18'} bytes@3.1.2: @@ -889,13 +889,13 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - concurrently@10.0.5: - resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} + concurrently@10.0.6: + resolution: {integrity: sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==} engines: {node: '>=22'} hasBin: true - content-disposition@1.1.0: - resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} engines: {node: '>=18'} content-type@1.0.5: @@ -1015,18 +1015,18 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - express-rate-limit@8.6.2: - resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} engines: {node: '>= 18'} - fast-copy@4.0.4: - resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==} + fast-copy@4.3.1: + resolution: {integrity: sha512-CRzQaTFOFUs6PuhsAq7lk9Zqr995soJBcFNZlfJS/5keTSl1awREV9dZW4xS2nX4pWbhIIR0xp4h+J8PQxCkvg==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1034,8 +1034,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.6: - resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1070,8 +1070,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.6.0: - resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -1097,8 +1097,8 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hono@4.13.5: - resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} + hono@4.13.14: + resolution: {integrity: sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -1116,8 +1116,8 @@ packages: resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} engines: {node: '>=20.0.0'} - ip-address@10.5.0: - resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + ip-address@10.7.5: + resolution: {integrity: sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -1130,15 +1130,15 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jose@6.2.10: - resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-yaml@5.4.1: - resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} + js-yaml@5.5.0: + resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==} hasBin: true json-schema-traverse@1.0.0: @@ -1147,8 +1147,8 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - libphonenumber-js@1.13.11: - resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==} + libphonenumber-js@1.13.15: + resolution: {integrity: sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==} lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} @@ -1253,8 +1253,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1277,8 +1277,8 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + on-finished@2.4.2: + resolution: {integrity: sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==} engines: {node: '>= 0.8'} once@1.4.0: @@ -1310,42 +1310,42 @@ packages: engines: {node: '>=20'} hasBin: true - pino-pretty@13.1.3: - resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + pino-pretty@13.2.0: + resolution: {integrity: sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg==} hasBin: true pino-std-serializers@7.1.0: resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - pino@10.3.1: - resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + pino@10.4.0: + resolution: {integrity: sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==} hasBin: true pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} engines: {node: ^10 || ^12 || >=14} - prettier@3.9.6: - resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} engines: {node: '>=14'} hasBin: true - process-warning@5.1.0: - resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + process-warning@5.1.1: + resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.17.0: + resolution: {integrity: sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==} engines: {node: '>=0.6'} quick-format-unescaped@4.0.4: @@ -1359,10 +1359,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} @@ -1377,8 +1373,8 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - rolldown@1.2.6: - resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + rolldown@1.2.15: + resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1396,8 +1392,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + secure-json-parse@4.1.1: + resolution: {integrity: sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==} semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} @@ -1415,8 +1411,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -1432,8 +1428,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.9.0: - resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + shell-quote@1.12.0: + resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} engines: {node: '>= 0.4'} side-channel-list@1.0.1: @@ -1455,8 +1451,11 @@ packages: sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + sonic-boom@5.0.2: + resolution: {integrity: sha512-/JEruzo6yhNVhsLblTRchgTQHIt7SsdJP0YyrfWaNKjIOQDfrdZv9gwYraYCAsmKV39/LAdV31FLL5NCDpoL/A==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} split2@4.2.0: @@ -1519,8 +1518,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.12: - resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} hasBin: true @@ -1533,8 +1532,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -1556,13 +1555,13 @@ packages: peerDependencies: vite: '>=5.4.0' - vite@8.2.2: - resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 || ^0.5.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -1611,8 +1610,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.3: - resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -1644,8 +1643,8 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} snapshots: @@ -1738,130 +1737,130 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@hono/node-server@2.1.1(hono@4.13.5)': + '@hono/node-server@2.1.5(hono@4.13.14)': dependencies: - hono: 4.13.5 + hono: 4.13.14 '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@ioredis/commands@2.0.0': {} '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - '@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.32.2(supports-color@10.2.2)(zod@4.6.7)': dependencies: - '@hono/node-server': 2.1.1(hono@4.13.5) + '@hono/node-server': 2.1.5(hono@4.13.14) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -1869,19 +1868,19 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.1 - express: 5.2.1(supports-color@10.2.2) - express-rate-limit: 8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2) - hono: 4.13.5 - jose: 6.2.10 + express: 5.3.0(supports-color@10.2.2) + express-rate-limit: 8.7.1(express@5.3.0(supports-color@10.2.2))(supports-color@10.2.2) + hono: 4.13.14 + jose: 6.2.13 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) transitivePeerDependencies: - supports-color - '@oxc-project/types@0.147.0': {} + '@oxc-project/types@0.155.0': {} '@penpot/plugin-styles@1.5.0': {} @@ -1889,49 +1888,49 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@rolldown/binding-android-arm-eabi@1.2.6': + '@rolldown/binding-android-arm-eabi@1.2.15': optional: true - '@rolldown/binding-android-arm64@1.2.6': + '@rolldown/binding-android-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-arm64@1.2.6': + '@rolldown/binding-darwin-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-x64@1.2.6': + '@rolldown/binding-darwin-x64@1.2.15': optional: true - '@rolldown/binding-freebsd-x64@1.2.6': + '@rolldown/binding-freebsd-x64@1.2.15': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.6': + '@rolldown/binding-linux-arm64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.6': + '@rolldown/binding-linux-arm64-musl@1.2.15': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.6': + '@rolldown/binding-linux-ppc64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.6': + '@rolldown/binding-linux-s390x-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.6': + '@rolldown/binding-linux-x64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-musl@1.2.6': + '@rolldown/binding-linux-x64-musl@1.2.15': optional: true - '@rolldown/binding-openharmony-arm64@1.2.6': + '@rolldown/binding-openharmony-arm64@1.2.15': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.6': + '@rolldown/binding-win32-arm64-msvc@1.2.15': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.6': + '@rolldown/binding-win32-x64-msvc@1.2.15': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1951,15 +1950,15 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 26.4.0 + '@types/node': 26.6.5 '@types/connect@3.4.38': dependencies: - '@types/node': 26.4.0 + '@types/node': 26.6.5 '@types/express-serve-static-core@5.1.3': dependencies: - '@types/node': 26.4.0 + '@types/node': 26.6.5 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -1974,9 +1973,9 @@ snapshots: '@types/js-yaml@4.0.9': {} - '@types/node@26.4.0': + '@types/node@26.6.5': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 '@types/qs@6.15.1': {} @@ -1984,18 +1983,18 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 26.4.0 + '@types/node': 26.6.5 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 26.4.0 + '@types/node': 26.6.5 '@types/validator@13.15.10': {} - '@types/ws@8.18.1': + '@types/ws@8.18.2': dependencies: - '@types/node': 26.4.0 + '@types/node': 26.6.5 accepts@2.0.0: dependencies: @@ -2004,9 +2003,9 @@ snapshots: acorn-walk@8.3.5: dependencies: - acorn: 8.18.0 + acorn: 8.19.0 - acorn@8.18.0: {} + acorn@8.19.0: {} ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: @@ -2015,31 +2014,31 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.6 + fast-uri: 3.1.9 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-regex@6.2.2: {} + ansi-regex@6.4.0: {} ansi-styles@6.2.3: {} arg@4.1.3: {} - argparse@2.0.1: {} + argparse@3.0.4: {} atomic-sleep@1.0.0: {} bencode@2.0.3: {} - body-parser@2.3.0(supports-color@10.2.2): + body-parser@2.4.0(supports-color@10.2.2): dependencies: bytes: 3.1.2 content-type: 2.1.0 debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 iconv-lite: 0.7.3 - on-finished: 2.4.1 - qs: 6.15.3 + on-finished: 2.4.2 + qs: 6.17.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -2064,7 +2063,7 @@ snapshots: class-validator@0.15.1: dependencies: '@types/validator': 13.15.10 - libphonenumber-js: 1.13.11 + libphonenumber-js: 1.13.15 validator: 13.15.35 cliui@9.0.1: @@ -2077,16 +2076,16 @@ snapshots: colorette@2.0.20: {} - concurrently@10.0.5: + concurrently@10.0.6: dependencies: chalk: 5.6.2 rxjs: 7.8.2 - shell-quote: 1.9.0 + shell-quote: 1.12.0 supports-color: 10.2.2 tree-kill: 1.2.2 yargs: 18.0.0 - content-disposition@1.1.0: {} + content-disposition@2.0.1: {} content-type@1.0.5: {} @@ -2197,20 +2196,20 @@ snapshots: dependencies: eventsource-parser: 3.1.1 - express-rate-limit@8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2): + express-rate-limit@8.7.1(express@5.3.0(supports-color@10.2.2))(supports-color@10.2.2): dependencies: debug: 4.4.3(supports-color@10.2.2) - express: 5.2.1(supports-color@10.2.2) - ip-address: 10.5.0 + express: 5.3.0(supports-color@10.2.2) + ip-address: 10.7.5 transitivePeerDependencies: - supports-color - express@5.2.1(supports-color@10.2.2): + express@5.3.0(supports-color@10.2.2): dependencies: accepts: 2.0.0 - body-parser: 2.3.0(supports-color@10.2.2) - content-disposition: 1.1.0 - content-type: 1.0.5 + body-parser: 2.4.0(supports-color@10.2.2) + content-disposition: 2.0.1 + content-type: 2.1.0 cookie: 0.7.2 cookie-signature: 1.2.2 debug: 4.4.3(supports-color@10.2.2) @@ -2223,11 +2222,11 @@ snapshots: http-errors: 2.0.1 merge-descriptors: 2.0.0 mime-types: 3.0.2 - on-finished: 2.4.1 + on-finished: 2.4.2 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.3 + proxy-addr: 2.0.8 + qs: 6.17.0 range-parser: 1.3.0 router: 2.2.0(supports-color@10.2.2) send: 1.2.1(supports-color@10.2.2) @@ -2238,13 +2237,13 @@ snapshots: transitivePeerDependencies: - supports-color - fast-copy@4.0.4: {} + fast-copy@4.3.1: {} fast-deep-equal@3.1.3: {} fast-safe-stringify@2.1.1: {} - fast-uri@3.1.6: {} + fast-uri@3.1.9: {} fdir@6.5.0(picomatch@4.0.7): optionalDependencies: @@ -2255,7 +2254,7 @@ snapshots: debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 - on-finished: 2.4.1 + on-finished: 2.4.2 parseurl: 1.3.3 statuses: 2.0.2 transitivePeerDependencies: @@ -2272,7 +2271,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.6.0: {} + get-east-asian-width@1.7.0: {} get-intrinsic@1.3.0: dependencies: @@ -2302,7 +2301,7 @@ snapshots: help-me@5.0.0: {} - hono@4.13.5: {} + hono@4.13.14: {} http-errors@2.0.1: dependencies: @@ -2329,7 +2328,7 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.5.0: {} + ip-address@10.7.5: {} ipaddr.js@1.9.1: {} @@ -2337,19 +2336,19 @@ snapshots: isexe@2.0.0: {} - jose@6.2.10: {} + jose@6.2.13: {} joycon@3.1.1: {} - js-yaml@5.4.1: + js-yaml@5.5.0: dependencies: - argparse: 2.0.1 + argparse: 3.0.4 json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} - libphonenumber-js@1.13.11: {} + libphonenumber-js@1.13.15: {} lightningcss-android-arm64@1.33.0: optional: true @@ -2418,7 +2417,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.18: {} + nanoid@3.3.20: {} negotiator@1.1.0: dependencies: @@ -2435,7 +2434,7 @@ snapshots: on-exit-leak-free@2.1.2: {} - on-finished@2.4.1: + on-finished@2.4.2: dependencies: ee-first: 1.1.1 @@ -2462,11 +2461,11 @@ snapshots: pino-abstract-transport: 3.0.0 pump: 3.0.4 - pino-pretty@13.1.3: + pino-pretty@13.2.0: dependencies: colorette: 2.0.20 dateformat: 4.6.3 - fast-copy: 4.0.4 + fast-copy: 4.3.1 fast-safe-stringify: 2.1.1 help-me: 5.0.0 joycon: 3.1.1 @@ -2474,39 +2473,39 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 pump: 3.0.4 - secure-json-parse: 4.1.0 - sonic-boom: 4.2.1 + secure-json-parse: 4.1.1 + sonic-boom: 5.0.2 strip-json-comments: 5.0.3 pino-std-serializers@7.1.0: {} - pino@10.3.1: + pino@10.4.0: dependencies: '@pinojs/redact': 0.4.0 atomic-sleep: 1.0.0 on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 pino-std-serializers: 7.1.0 - process-warning: 5.1.0 + process-warning: 5.1.1 quick-format-unescaped: 4.0.4 - real-require: 0.2.0 + real-require: 1.0.0 safe-stable-stringify: 2.5.0 sonic-boom: 4.2.1 thread-stream: 4.2.0 pkce-challenge@5.0.1: {} - postcss@8.5.26: + postcss@8.5.32: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 - prettier@3.9.6: {} + prettier@3.9.10: {} - process-warning@5.1.0: {} + process-warning@5.1.1: {} - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -2516,7 +2515,7 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 - qs@6.15.3: + qs@6.17.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -2532,8 +2531,6 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 - real-require@0.2.0: {} - real-require@1.0.0: {} redis-errors@1.2.0: {} @@ -2542,26 +2539,26 @@ snapshots: require-from-string@2.0.2: {} - rolldown@1.2.6: + rolldown@1.2.15: dependencies: - '@oxc-project/types': 0.147.0 + '@oxc-project/types': 0.155.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.6 - '@rolldown/binding-android-arm64': 1.2.6 - '@rolldown/binding-darwin-arm64': 1.2.6 - '@rolldown/binding-darwin-x64': 1.2.6 - '@rolldown/binding-freebsd-x64': 1.2.6 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 - '@rolldown/binding-linux-arm64-gnu': 1.2.6 - '@rolldown/binding-linux-arm64-musl': 1.2.6 - '@rolldown/binding-linux-ppc64-gnu': 1.2.6 - '@rolldown/binding-linux-s390x-gnu': 1.2.6 - '@rolldown/binding-linux-x64-gnu': 1.2.6 - '@rolldown/binding-linux-x64-musl': 1.2.6 - '@rolldown/binding-openharmony-arm64': 1.2.6 - '@rolldown/binding-win32-arm64-msvc': 1.2.6 - '@rolldown/binding-win32-x64-msvc': 1.2.6 + '@rolldown/binding-android-arm-eabi': 1.2.15 + '@rolldown/binding-android-arm64': 1.2.15 + '@rolldown/binding-darwin-arm64': 1.2.15 + '@rolldown/binding-darwin-x64': 1.2.15 + '@rolldown/binding-freebsd-x64': 1.2.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.15 + '@rolldown/binding-linux-arm64-gnu': 1.2.15 + '@rolldown/binding-linux-arm64-musl': 1.2.15 + '@rolldown/binding-linux-ppc64-gnu': 1.2.15 + '@rolldown/binding-linux-s390x-gnu': 1.2.15 + '@rolldown/binding-linux-x64-gnu': 1.2.15 + '@rolldown/binding-linux-x64-musl': 1.2.15 + '@rolldown/binding-openharmony-arm64': 1.2.15 + '@rolldown/binding-win32-arm64-msvc': 1.2.15 + '@rolldown/binding-win32-x64-msvc': 1.2.15 router@2.2.0(supports-color@10.2.2): dependencies: @@ -2581,7 +2578,7 @@ snapshots: safer-buffer@2.1.2: {} - secure-json-parse@4.1.0: {} + secure-json-parse@4.1.1: {} semver@7.8.5: {} @@ -2595,7 +2592,7 @@ snapshots: http-errors: 2.0.1 mime-types: 3.0.2 ms: 2.1.3 - on-finished: 2.4.1 + on-finished: 2.4.2 range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: @@ -2612,38 +2609,38 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.4(@types/node@26.4.0): + sharp@0.35.5(@types/node@26.6.5): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 - '@types/node': 26.4.0 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 26.6.5 shebang-command@2.0.0: dependencies: @@ -2651,7 +2648,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.9.0: {} + shell-quote@1.12.0: {} side-channel-list@1.0.1: dependencies: @@ -2685,7 +2682,11 @@ snapshots: dependencies: atomic-sleep: 1.0.0 - source-map-js@1.2.1: {} + sonic-boom@5.0.2: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.2: {} split2@4.2.0: {} @@ -2696,12 +2697,12 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 strip-ansi@7.2.0: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.4.0 strip-json-comments@5.0.3: {} @@ -2720,15 +2721,15 @@ snapshots: tree-kill@1.2.2: {} - ts-node@10.9.2(@types/node@26.4.0)(typescript@6.0.3): + ts-node@10.9.2(@types/node@26.6.5)(typescript@6.0.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.13 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 26.4.0 - acorn: 8.18.0 + '@types/node': 26.6.5 + acorn: 8.19.0 acorn-walk: 8.3.5 arg: 4.1.3 create-require: 1.1.1 @@ -2740,7 +2741,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.12: + tsx@4.23.15: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -2754,7 +2755,7 @@ snapshots: typescript@6.0.3: {} - undici-types@8.3.0: {} + undici-types@8.9.0: {} unpipe@1.0.0: {} @@ -2764,31 +2765,31 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.4.0(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12)): + vite-live-preview@0.4.0(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15)): dependencies: '@seahax/deep-copy': 0.1.0 '@seahax/semaphore': 0.5.1 - '@types/ws': 8.18.1 + '@types/ws': 8.18.2 escape-goat: 4.0.0 strip-ansi: 7.2.0 - vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12) - ws: 8.21.3 + vite: 8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15) + ws: 8.22.0 transitivePeerDependencies: - bufferutil - utf-8-validate - vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12): + vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.26 - rolldown: 1.2.6 + postcss: 8.5.32 + rolldown: 1.2.15 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.4.0 + '@types/node': 26.6.5 esbuild: 0.28.2 fsevents: 2.3.3 - tsx: 4.23.12 + tsx: 4.23.15 which@2.0.2: dependencies: @@ -2802,7 +2803,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.3: {} + ws@8.22.0: {} y18n@5.0.8: {} @@ -2819,8 +2820,8 @@ snapshots: yn@3.1.1: {} - zod-to-json-schema@3.25.2(zod@4.4.3): + zod-to-json-schema@3.25.2(zod@4.6.7): dependencies: - zod: 4.4.3 + zod: 4.6.7 - zod@4.4.3: {} + zod@4.6.7: {} From d07a54562f7948e4066dc04073030e016d8e7d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 29 Oct 2026 13:46:21 +0100 Subject: [PATCH 40/65] :bug: Add feature flag text-editor-wasm/v1-html-paste (#12689) --- common/src/app/common/features.cljc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index c29b99aee3..e038356b4a 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -56,6 +56,7 @@ "text-editor/v2-html-paste" "text-editor/v2" "text-editor-wasm/v1" + "text-editor-wasm/v1-html-paste" "render-wasm/v1" "variants/v1"}) @@ -81,6 +82,7 @@ "text-editor/v2-html-paste" "text-editor/v2" "text-editor-wasm/v1" + "text-editor-wasm/v1-html-paste" "tokens/numeric-input" "render-wasm/v1"}) From c373e8da8839caa21c8b9cf9161047c9e0c3ab80 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 14:50:20 +0100 Subject: [PATCH 41/65] :wrench: Run the DS visual specs on the dark theme (#12707) Since accounts without a theme follow the system one (#12480), the ds Playwright project renders the light theme: headless Chromium reports a light color scheme. Its screenshots stopped matching the ones taken before, and the app is reviewed on the dark theme. Set colorScheme to dark for the ds project only; the default and render-wasm projects keep their behavior. AI-assisted-by: claude-opus-5-5 --- frontend/playwright.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js index 1e74d525eb..087e6ba56f 100644 --- a/frontend/playwright.config.js +++ b/frontend/playwright.config.js @@ -65,7 +65,9 @@ export default defineConfig({ }, { name: "ds", - use: { ...devices["Desktop Chrome"] }, + // Accounts without a theme follow the system one; keep the visual + // specs on the dark theme so their screenshots stay comparable. + use: { ...devices["Desktop Chrome"], colorScheme: "dark" }, testDir: "./playwright/ui/visual-specs", expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.005 }, From c70cc5629988d3ad415734d82244226b30e390b3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 29 Oct 2026 14:09:43 +0000 Subject: [PATCH 42/65] :wrench: Skip build output when stamping the pnpm version The script walks the filesystem, so it stamped generated copies too, such as media-processor/target/package.json: --check counted files git does not track and failed whenever one of those copies was stale. Prune target/ like the other generated trees, with a test for it. Memory: the JDK and ImageMagick image pins have traps the code cannot show, now in workflow/docker-images; memory-maintenance gains the rule that a memory must not restate what the code already says; updating-pnpm notes the lockfile-only refresh. AI-assisted-by: deepseek-v4.1-flash --- .serena/memories/critical-info.md | 1 + .serena/memories/memory-maintenance.md | 1 + .serena/memories/workflow/docker-images.md | 6 ++++++ .serena/memories/workflow/updating-pnpm.md | 5 +++-- scripts/sync-pnpm-version | 5 +++-- scripts/test_sync_pnpm_version.py | 17 +++++++++++++++++ 6 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 .serena/memories/workflow/docker-images.md diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 63bbce922a..fc9133326a 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -16,6 +16,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type) - Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line) - Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace layout, `scripts/sync-pnpm-version` flow, the stamp-missing-field and ignored-builds gotchas, verification steps) +- Before bumping a non-JS dependency pinned in `docker/images/` (JDK, ImageMagick) → `mem:workflow/docker-images` - **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked. - **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request. - You have access to the GitHub CLI `gh` or corresponding MCP tools. diff --git a/.serena/memories/memory-maintenance.md b/.serena/memories/memory-maintenance.md index fbb800b99c..67a480a770 100644 --- a/.serena/memories/memory-maintenance.md +++ b/.serena/memories/memory-maintenance.md @@ -26,6 +26,7 @@ No manual line wrapping: one line per bullet or paragraph, however long. Memorie ## Add/update threshold Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future. +Do not restate what the code already says (comments included): keep only what code cannot represent — decisions and their reasons, cross-file invariants, ordering constraints, environment and tool behavior, and traps that break builds. Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon. ## Maintenance Actions diff --git a/.serena/memories/workflow/docker-images.md b/.serena/memories/workflow/docker-images.md new file mode 100644 index 0000000000..28d42bc9e7 --- /dev/null +++ b/.serena/memories/workflow/docker-images.md @@ -0,0 +1,6 @@ +# Non-JS dependencies in the production images + +Node and pnpm pins: `mem:workflow/updating-node` and `mem:workflow/updating-pnpm`. Only the parts the Dockerfiles cannot show: + +- JDK: the image tracks a non-LTS line, so there is no minor to pick — the last build of the line is the ceiling; moving line is a major and needs a real build to validate the `jlink` module list. +- ImageMagick: publish `penpotapp/imagemagick:` **before** bumping the `COPY --from` refs in the backend, exporter and devenv Dockerfiles; otherwise both builds break on the missing tag. diff --git a/.serena/memories/workflow/updating-pnpm.md b/.serena/memories/workflow/updating-pnpm.md index 1bb1596959..e355c47450 100644 --- a/.serena/memories/workflow/updating-pnpm.md +++ b/.serena/memories/workflow/updating-pnpm.md @@ -27,8 +27,8 @@ file (never pipe tool output through filters). members) must carry a `packageManager` field with the identical `pnpm@+sha512.` value. Do not let them drift. `external/` (vendored trees with their own lifecycles), `.opencode/`, - `.pnpm-store/`, `bundles/` and `docker/images/bundle-*` copies are - never stamped. + `.pnpm-store/`, `bundles/`, `target/` build output and + `docker/images/bundle-*` copies are never stamped. - pnpm is a system binary everywhere (devenv image, CI runners, Docker images). Nothing may call corepack: it is gone from Node 25+. pnpm auto-downloads the `packageManager` version on mismatch @@ -60,6 +60,7 @@ file (never pipe tool output through filters). 4. Run `scripts/sync-pnpm-version --install` (plain `pnpm install` in each of the 11 workspace roots) to refresh lockfile metadata, then fix any failing workspace (see gotchas) and re-run that directory. + For a version-only bump, `pnpm install --lockfile-only` in each root is enough: it rewrites only the pnpm self-reference entries and needs no node_modules. The repo-root `pnpm-lock.yaml` is rewritten by itself on the first `pnpm` call that self-installs the target version. ## Gotchas diff --git a/scripts/sync-pnpm-version b/scripts/sync-pnpm-version index e87cf17500..149b072955 100755 --- a/scripts/sync-pnpm-version +++ b/scripts/sync-pnpm-version @@ -27,7 +27,8 @@ Options: -h, --help Show this help. external/ (vendored trees with their own lifecycles), .opencode/, -.pnpm-store/, bundles/ and docker build-context copies are never stamped. +.pnpm-store/, bundles/, build output (target/) and docker build-context +copies are never stamped. EOF } @@ -91,7 +92,7 @@ mapfile -t files < <( find . \ \( -name .git -o -name node_modules -o -name .pnpm-store \ -o -name external -o -name .opencode -o -name bundles \ - -o -name .angular \) -type d -prune \ + -o -name target -o -name .angular \) -type d -prune \ -o -path './docker/images/bundle-*' -prune \ -o -name package.json -type f -print | sort ) diff --git a/scripts/test_sync_pnpm_version.py b/scripts/test_sync_pnpm_version.py index 889250f433..91089632f2 100644 --- a/scripts/test_sync_pnpm_version.py +++ b/scripts/test_sync_pnpm_version.py @@ -134,6 +134,23 @@ class SyncPnpmVersionTests(unittest.TestCase): self.assertNotEqual(proc.returncode, 0) self.assertIn("missing/package.json", proc.stdout + proc.stderr) + def test_target_dirs_are_never_stamped(self): + generated = self.tmp / "module" / "target" + generated.mkdir(parents=True) + package = generated / "package.json" + package.write_text( + '{\n' + ' "name": "generated",\n' + ' "packageManager": "pnpm@10.0.0+sha512.' + "00" * 64 + '"\n' + '}\n' + ) + before = package.read_bytes() + + self.assertEqual(self.stamp().returncode, 0) + self.assertEqual(package.read_bytes(), before) + # The stale copy must not make --check fail either. + self.assertEqual(self.stamp("--check").returncode, 0) + def test_stamp_is_idempotent(self): self.assertEqual(self.stamp().returncode, 0) first = { From 857c6660223a8a570612851b036804a21e401c54 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 29 Oct 2026 14:09:54 +0000 Subject: [PATCH 43/65] :arrow_up: Update pnpm to 12.16.1 and node to 24.22.2 pnpm 12.5.1 -> 12.16.1: the tarball pin of docker/devenv and of the media-processor, exporter and mcp images, the packageManager field of every workspace manifest, and the lockfile metadata of the 11 workspaces. The new pnpm also normalizes one engine range per lockfile (>=18.* to >=18). node 24.21.0 -> 24.22.2: devenv NODE_VERSION and both NODE_SHA256, .nvmrc, and the runtime pin of the two plugins-deploy workflows. The dhi.io image tags stay as they are until their digests are available. AI-assisted-by: deepseek-v4.1-flash --- .github/workflows/plugins-deploy-api-doc.yml | 2 +- .../workflows/plugins-deploy-styles-doc.yml | 2 +- .nvmrc | 2 +- backend/package.json | 2 +- backend/pnpm-lock.yaml | 124 +++++++++--------- common/package.json | 2 +- common/pnpm-lock.yaml | 124 +++++++++--------- docker/devenv/Dockerfile | 12 +- docker/images/Dockerfile.exporter | 6 +- docker/images/Dockerfile.mcp | 6 +- docker/images/Dockerfile.media-processor | 6 +- docs/package.json | 2 +- docs/pnpm-lock.yaml | 124 +++++++++--------- exporter/package.json | 2 +- exporter/pnpm-lock.yaml | 124 +++++++++--------- frontend/package.json | 2 +- frontend/packages/draft-js/package.json | 2 +- frontend/packages/mousetrap/package.json | 2 +- frontend/packages/tokenscript/package.json | 2 +- frontend/packages/ui/package.json | 2 +- frontend/pnpm-lock.yaml | 124 +++++++++--------- frontend/text-editor/package.json | 2 +- library/package.json | 2 +- library/pnpm-lock.yaml | 124 +++++++++--------- mcp/package.json | 2 +- mcp/packages/common/package.json | 2 +- mcp/packages/plugin/package.json | 2 +- mcp/packages/server/package.json | 2 +- mcp/pnpm-lock.yaml | 124 +++++++++--------- media-processor/package.json | 2 +- media-processor/pnpm-lock.yaml | 124 +++++++++--------- package.json | 2 +- .../apps/colors-to-tokens-plugin/package.json | 2 +- .../apps/composable-test-suite/package.json | 2 +- plugins/apps/contrast-plugin/package.json | 2 +- .../apps/create-palette-plugin/package.json | 2 +- plugins/apps/e2e/package.json | 2 +- plugins/apps/example-styles/package.json | 2 +- plugins/apps/icons-plugin/package.json | 2 +- plugins/apps/lorem-ipsum-plugin/package.json | 2 +- .../apps/plugin-api-test-suite/package.json | 2 +- plugins/apps/poc-state-plugin/package.json | 2 +- plugins/apps/poc-tokens-plugin/package.json | 2 +- .../apps/rename-layers-plugin/package.json | 2 +- plugins/apps/table-plugin/package.json | 2 +- plugins/libs/plugin-types/package.json | 2 +- plugins/libs/plugins-runtime/package.json | 2 +- plugins/libs/plugins-styles/package.json | 2 +- plugins/package.json | 2 +- plugins/pnpm-lock.yaml | 124 +++++++++--------- pnpm-lock.yaml | 124 +++++++++--------- render-wasm/package.json | 2 +- render-wasm/pnpm-lock.yaml | 124 +++++++++--------- 53 files changed, 735 insertions(+), 735 deletions(-) diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index f513ba6af9..ed7ec0e978 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -51,7 +51,7 @@ jobs: uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: working-directory: plugins - runtime: node@24.22.1 + runtime: node@24.22.2 install: false - name: Install deps diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 65ed5c80f3..72b67fa1cb 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -49,7 +49,7 @@ jobs: uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: working-directory: plugins - runtime: node@24.22.1 + runtime: node@24.22.2 install: false - name: Install deps diff --git a/.nvmrc b/.nvmrc index 166aae1151..86456ddf02 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.21.0 +v24.22.2 diff --git a/backend/package.json b/backend/package.json index 6b65767aec..8b9dab8ed4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index ce36558530..8d3278fb84 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/common/package.json b/common/package.json index c149915f43..44dd497a55 100644 --- a/common/package.json +++ b/common/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "type": "module", "repository": { "type": "git", diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 6026b397e6..dd35c2b992 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 81fae7a791..73294cb15d 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -66,8 +66,8 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.22.1 \ - PNPM_VERSION=12.15.0 \ +ENV NODE_VERSION=v24.22.2 \ + PNPM_VERSION=12.16.1 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -76,16 +76,16 @@ RUN set -eux; \ aarch64|arm64) \ OPENSSL_ARCH='linux-aarch64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ - NODE_SHA256='33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3'; \ + NODE_SHA256='95c264150aceb7f3437a29fd6984924f5f8d1511ea3cbd6c508b83f6c555dae2'; \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ - NODE_SHA256='eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c'; \ + NODE_SHA256='361718c444e49af2ca5a110c83ba6a8901143f23ab285ebbef0341f391f482a9'; \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 97daca2dfe..30186f3a9b 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -95,17 +95,17 @@ WORKDIR /opt/penpot/exporter # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); the generated ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index f018302d33..475df43098 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -11,7 +11,7 @@ WORKDIR /opt/penpot/mcp # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install curl ca-certificates gzip; \ @@ -20,11 +20,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index ee0694bc34..6d00d1eba4 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -58,7 +58,7 @@ WORKDIR /opt/penpot/media-processor # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install gzip; \ @@ -67,11 +67,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docs/package.json b/docs/package.json index 8a9c016f80..7d1040f05e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -39,5 +39,5 @@ "markdown-it-anchor": "^9.2.1", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 0ca49e8270..cd379d7e6f 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/exporter/package.json b/exporter/package.json index db033dbf2f..3293d3c825 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index ec66e081a4..9ab3e227ce 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/frontend/package.json b/frontend/package.json index 894e6e651d..bf5d259f09 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "browserslist": [ "defaults" ], diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index d257b4bec2..6531f4ca78 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -4,7 +4,7 @@ "description": "Penpot Draft-JS Wrapper", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index e71ccbf825..a194a05ba3 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -4,7 +4,7 @@ "description": "Simple library for handling keyboard shortcuts", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/tokenscript/package.json b/frontend/packages/tokenscript/package.json index 32d7442bf5..a7d149ce62 100644 --- a/frontend/packages/tokenscript/package.json +++ b/frontend/packages/tokenscript/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index 38657cbad1..bff1b5d7b9 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "types": "./dist/index.d.ts", "type": "module", - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "exports": { ".": { "import": "./dist/index.js" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index eb187d1c4a..9b8c98ab5a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index 94d61aaaaa..7da3b29ec6 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -28,5 +28,5 @@ "vite": "^8.2.0", "vitest": "^4.1.10" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/library/package.json b/library/package.json index deb8096254..bb55132370 100644 --- a/library/package.json +++ b/library/package.json @@ -3,7 +3,7 @@ "version": "1.2.0", "license": "MPL-2.0", "author": "KALEIDOS SUBSIDIARY SL", - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "type": "module", "repository": { "type": "git", diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index 8b8daa71b9..d9519ec0ac 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/mcp/package.json b/mcp/package.json index 2b89cf1abc..97012b38b0 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,7 +23,7 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "devDependencies": { "concurrently": "^10.0.6", "prettier": "^3.9.10" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index 4e3dda0cc8..6ddd0bd605 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 6155889516..68ed69afa9 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "type": "module", - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "start": "vite build --watch --config vite.config.ts", "start:multi-user": "pnpm run start", diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 4fb0e12d41..9bd11569a7 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -24,7 +24,7 @@ ], "author": "", "license": "MIT", - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "class-transformer": "^0.5.1", diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 0e558241f2..00c59046a8 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/media-processor/package.json b/media-processor/package.json index 8190c530bf..110fdbb7c3 100644 --- a/media-processor/package.json +++ b/media-processor/package.json @@ -14,7 +14,7 @@ "fmt:check": "prettier --check src/ test/", "clean": "rm -rf dist/" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "dependencies": { "express": "^5.2.1", "multer": "^2.2.0", diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index 98803e8f77..f09b091862 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/package.json b/package.json index 150214c26f..5779c600b5 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index 30715f65e5..4c9690e2a2 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index dbdc016c54..15d544f0af 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -27,5 +27,5 @@ "vite": "^8.2.2", "vite-live-preview": "^0.4.0" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index ee854f007f..641618631d 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index c99b93bf8c..d52baaa757 100644 --- a/plugins/apps/create-palette-plugin/package.json +++ b/plugins/apps/create-palette-plugin/package.json @@ -12,5 +12,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index c0501e2733..d9c64e6bac 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -7,5 +7,5 @@ "test": "vitest", "lint": "eslint ." }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index 714fe4228d..227f8b7a9f 100644 --- a/plugins/apps/example-styles/package.json +++ b/plugins/apps/example-styles/package.json @@ -11,5 +11,5 @@ "serve": "vite preview", "lint": "eslint ." }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index 4c6eee5406..31f25c5e8a 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index 6e1dcc3de4..f5d5be5541 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index e49ce3dcf1..177c514477 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -20,5 +20,5 @@ "devDependencies": { "playwright": "^1.62.1" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index 7c593ede9f..df5bc13b27 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index 06fcd3e9cc..41337275df 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "exit 0" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index ca69e49f14..38eb1e0939 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index be103b05ff..f2bc2d696b 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 0f0da1e1e3..e4959293fa 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -7,5 +7,5 @@ "build": "node ../../tools/scripts/build-types.mjs", "lint": "tsc -p . --noEmit" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index cf15cc95aa..ac294ebe8d 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -16,5 +16,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 301ac323ef..5badd11056 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -6,5 +6,5 @@ "build": "node ../../tools/scripts/build-css.mjs", "lint": "echo 0" }, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/package.json b/plugins/package.json index 912a0a7f70..f953a80d17 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "start": "pnpm run start:app:runtime", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index 0f3df9cca1..b3bfec17f4 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ef1b2c144..a52d2188bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/render-wasm/package.json b/render-wasm/package.json index 4b56c15f73..f1f4b196ab 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index c00e4d1b27..a0180171fa 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.5.1 - version: 12.5.1 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.5.1': - resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.5.1': - resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.5.1': - resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.5.1': - resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.5.1': - resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.5.1': - resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.5.1': - resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.5.1': - resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.5.1': - resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.5.1': - resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.5.1': - resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.5.1': - resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.5.1': - resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.5.1': - resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.5.1: - resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} - engines: {node: '>=18.*'} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.5.1': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.5.1': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.5.1': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.5.1': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.5.1': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.5.1': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.5.1': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.5.1': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.5.1': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.5.1': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.5.1': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.5.1': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.5.1': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.5.1': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.5.1: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.5.1 - '@pnpm/exe.android-x64': 12.5.1 - '@pnpm/exe.darwin-arm64': 12.5.1 - '@pnpm/exe.darwin-x64': 12.5.1 - '@pnpm/exe.freebsd-x64': 12.5.1 - '@pnpm/exe.linux-arm64': 12.5.1 - '@pnpm/exe.linux-arm64-musl': 12.5.1 - '@pnpm/exe.linux-ppc64': 12.5.1 - '@pnpm/exe.linux-riscv64': 12.5.1 - '@pnpm/exe.linux-s390x': 12.5.1 - '@pnpm/exe.linux-x64': 12.5.1 - '@pnpm/exe.linux-x64-musl': 12.5.1 - '@pnpm/exe.win32-arm64': 12.5.1 - '@pnpm/exe.win32-x64': 12.5.1 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' From c0af48f8730f7ccac7e02ec3d692f3b13ec185c4 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:24:22 +0100 Subject: [PATCH 44/65] :arrow_up: Update DHI base images (#12713) - dhi.io/debian-base trixie-debian13-dev@cc4e05f (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13-dev@cc4e05f (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13@2664ef2 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@cc4e05f (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@cc4e05f (docker/devenv/Dockerfile) - dhi.io/nginx 1.31.8-alpine3.24@83489ce (docker/images/Dockerfile.storybook) - dhi.io/nginx 1.31.8-debian13-dev@fbff190 (docker/images/Dockerfile.frontend) - dhi.io/node 24.22.2@ (docker/devenv/Dockerfile) - dhi.io/node 24.22.2@ (.nvmrc) - dhi.io/node 24.22.2-debian13-dev@53af56f (docker/images/Dockerfile.media-processor) - dhi.io/node 24.22.2-debian13@24fa625 (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.2-debian13-dev@53af56f (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.2-debian13-dev@53af56f (docker/images/Dockerfile.exporter) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- .nvmrc | 2 +- docker/devenv/Dockerfile | 8 ++++---- docker/imagemagick/Dockerfile | 4 ++-- docker/images/Dockerfile.backend | 4 ++-- docker/images/Dockerfile.exporter | 2 +- docker/images/Dockerfile.frontend | 2 +- docker/images/Dockerfile.mcp | 4 ++-- docker/images/Dockerfile.media-processor | 2 +- docker/images/Dockerfile.storybook | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.nvmrc b/.nvmrc index 474d750b50..86456ddf02 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.22.1 +v24.22.2 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index ba6b51f2ca..b385f0983d 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ @@ -66,7 +66,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.22.1 \ +ENV NODE_VERSION=v24.22.2 \ PNPM_VERSION=12.15.0 \ PATH=/opt/node/bin:$PATH @@ -76,14 +76,14 @@ RUN set -eux; \ aarch64|arm64) \ OPENSSL_ARCH='linux-aarch64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ - NODE_SHA256='33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3'; \ + NODE_SHA256='95c264150aceb7f3437a29fd6984924f5f8d1511ea3cbd6c508b83f6c555dae2'; \ PNPM_ARCH='arm64'; \ PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ - NODE_SHA256='eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c'; \ + NODE_SHA256='361718c444e49af2ca5a110c83ba6a8901143f23ab285ebbef0341f391f482a9'; \ PNPM_ARCH='x64'; \ PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index f875c09a95..cdef6f2f3e 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -74,7 +74,7 @@ RUN set -ex; \ rm -rf /var/lib/apt/lists/* -FROM dhi.io/debian-base:trixie-debian13@sha256:de80b468d8c492508f24b30e529accfaa8c59fe4505ddd72e969b324d276ad21 AS image +FROM dhi.io/debian-base:trixie-debian13@sha256:2664ef25f3f63f5545a121a6a4676bf70da18df2bcadece1a913d6daaa19fdfb AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 32e8c2c1fa..4618c1ba91 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -53,7 +53,7 @@ RUN set -eux; \ --output /opt/jre; -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 97daca2dfe..6c15b6c5a1 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 +FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 26f84d4640..05939a1982 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:940ee353dd807f5c5e32720923bb9b34ef9e631ae567320f3eaea7797d6c7964 +FROM dhi.io/nginx:1.31.8-debian13-dev@sha256:fbff190f9162256975ed225adcc9a294e333897de543fd18be630de1d6c8de17 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index f018302d33..fac387760d 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 AS build +FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.22.1-debian13@sha256:32a2fa6af5209698a9354cef0b19ada3dbfac3090b1010b70afa9f1a298ba2d9 AS image +FROM dhi.io/node:24.22.2-debian13@sha256:24fa6258b891ef66e6c3b7ed82daad550b81cdfcd76f6cc64eee4d2478366747 AS image LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index ee0694bc34..89a582e8d0 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 +FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 2b259ffb61..4832c60054 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.7-alpine3.24@sha256:6d3811fbc76c5d0652752b39a003769759ccfeb2c1e874bacf99e3d44e5da3b9 +FROM dhi.io/nginx:1.31.8-alpine3.24@sha256:83489ce440fa56cb5149c088dbf75422362e45c532c814100253e5ab71871366 LABEL maintainer="Penpot " ARG BUNDLE_PATH="./bundle-storybook/" From 936371d9a6d4e51f4ab9e51714a10085388de856 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:26:20 +0100 Subject: [PATCH 45/65] :arrow_up: Update dependency pnpm to v12.16.1 (#12714) - pnpm 12.16.1 (docker/images/Dockerfile.media-processor) - pnpm 12.16.1 (docker/images/Dockerfile.mcp) - pnpm 12.16.1 (docker/images/Dockerfile.exporter) - pnpm 12.16.1 (docker/devenv/Dockerfile) - pnpm 12.16.1 (render-wasm/package.json) - pnpm 12.16.1 (plugins/package.json) - pnpm 12.16.1 (plugins/libs/plugins-styles/package.json) - pnpm 12.16.1 (plugins/libs/plugins-runtime/package.json) - pnpm 12.16.1 (plugins/libs/plugin-types/package.json) - pnpm 12.16.1 (plugins/apps/table-plugin/package.json) - pnpm 12.16.1 (plugins/apps/rename-layers-plugin/package.json) - pnpm 12.16.1 (plugins/apps/poc-tokens-plugin/package.json) - pnpm 12.16.1 (plugins/apps/poc-state-plugin/package.json) - pnpm 12.16.1 (plugins/apps/plugin-api-test-suite/package.json) - pnpm 12.16.1 (plugins/apps/lorem-ipsum-plugin/package.json) - pnpm 12.16.1 (plugins/apps/icons-plugin/package.json) - pnpm 12.16.1 (plugins/apps/example-styles/package.json) - pnpm 12.16.1 (plugins/apps/e2e/package.json) - pnpm 12.16.1 (plugins/apps/create-palette-plugin/package.json) - pnpm 12.16.1 (plugins/apps/contrast-plugin/package.json) - pnpm 12.16.1 (plugins/apps/composable-test-suite/package.json) - pnpm 12.16.1 (plugins/apps/colors-to-tokens-plugin/package.json) - pnpm 12.16.1 (package.json) - pnpm 12.16.1 (media-processor/package.json) - pnpm 12.16.1 (mcp/packages/server/package.json) - pnpm 12.16.1 (mcp/packages/plugin/package.json) - pnpm 12.16.1 (mcp/packages/common/package.json) - pnpm 12.16.1 (mcp/package.json) - pnpm 12.16.1 (library/package.json) - pnpm 12.16.1 (frontend/text-editor/package.json) - pnpm 12.16.1 (frontend/packages/ui/package.json) - pnpm 12.16.1 (frontend/packages/mousetrap/package.json) - pnpm 12.16.1 (frontend/packages/draft-js/package.json) - pnpm 12.16.1 (frontend/package.json) - pnpm 12.16.1 (exporter/package.json) - pnpm 12.16.1 (docs/package.json) - pnpm 12.16.1 (common/package.json) - pnpm 12.16.1 (backend/package.json) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> Signed-off-by: Andrey Antukh Co-authored-by: Andrey Antukh --- backend/package.json | 2 +- backend/pnpm-lock.yaml | 122 +++++++++--------- common/package.json | 2 +- common/pnpm-lock.yaml | 122 +++++++++--------- docker/devenv/Dockerfile | 6 +- docker/images/Dockerfile.exporter | 6 +- docker/images/Dockerfile.mcp | 6 +- docker/images/Dockerfile.media-processor | 6 +- docs/package.json | 2 +- docs/pnpm-lock.yaml | 122 +++++++++--------- exporter/package.json | 2 +- exporter/pnpm-lock.yaml | 122 +++++++++--------- frontend/package.json | 2 +- frontend/packages/draft-js/package.json | 2 +- frontend/packages/mousetrap/package.json | 2 +- frontend/packages/ui/package.json | 2 +- frontend/pnpm-lock.yaml | 122 +++++++++--------- frontend/text-editor/package.json | 2 +- library/package.json | 2 +- library/pnpm-lock.yaml | 122 +++++++++--------- mcp/package.json | 2 +- mcp/packages/common/package.json | 2 +- mcp/packages/plugin/package.json | 2 +- mcp/packages/server/package.json | 2 +- mcp/pnpm-lock.yaml | 122 +++++++++--------- media-processor/package.json | 2 +- media-processor/pnpm-lock.yaml | 122 +++++++++--------- package.json | 2 +- .../apps/colors-to-tokens-plugin/package.json | 2 +- .../apps/composable-test-suite/package.json | 2 +- plugins/apps/contrast-plugin/package.json | 2 +- .../apps/create-palette-plugin/package.json | 2 +- plugins/apps/e2e/package.json | 2 +- plugins/apps/example-styles/package.json | 2 +- plugins/apps/icons-plugin/package.json | 2 +- plugins/apps/lorem-ipsum-plugin/package.json | 2 +- .../apps/plugin-api-test-suite/package.json | 2 +- plugins/apps/poc-state-plugin/package.json | 2 +- plugins/apps/poc-tokens-plugin/package.json | 2 +- .../apps/rename-layers-plugin/package.json | 2 +- plugins/apps/table-plugin/package.json | 2 +- plugins/libs/plugin-types/package.json | 2 +- plugins/libs/plugins-runtime/package.json | 2 +- plugins/libs/plugins-styles/package.json | 2 +- plugins/package.json | 2 +- plugins/pnpm-lock.yaml | 122 +++++++++--------- pnpm-lock.yaml | 122 +++++++++--------- render-wasm/package.json | 2 +- render-wasm/pnpm-lock.yaml | 122 +++++++++--------- 49 files changed, 717 insertions(+), 717 deletions(-) diff --git a/backend/package.json b/backend/package.json index f877761df9..f573defead 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index ec457e4c24..8d3278fb84 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/common/package.json b/common/package.json index 2c63d73b31..2569c0fb8c 100644 --- a/common/package.json +++ b/common/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "type": "module", "repository": { "type": "git", diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 182b225f53..cd80e3a302 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index b385f0983d..0d8f85586b 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -67,7 +67,7 @@ RUN set -ex; \ FROM base AS setup-node ENV NODE_VERSION=v24.22.2 \ - PNPM_VERSION=12.15.0 \ + PNPM_VERSION=12.16.1 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -78,14 +78,14 @@ RUN set -eux; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ NODE_SHA256='95c264150aceb7f3437a29fd6984924f5f8d1511ea3cbd6c508b83f6c555dae2'; \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ NODE_SHA256='361718c444e49af2ca5a110c83ba6a8901143f23ab285ebbef0341f391f482a9'; \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 6c15b6c5a1..b427e0bc80 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -95,17 +95,17 @@ WORKDIR /opt/penpot/exporter # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); the generated ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index fac387760d..aac0ff2a38 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -11,7 +11,7 @@ WORKDIR /opt/penpot/mcp # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install curl ca-certificates gzip; \ @@ -20,11 +20,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index 89a582e8d0..b2e9eb4af2 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -58,7 +58,7 @@ WORKDIR /opt/penpot/media-processor # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install gzip; \ @@ -67,11 +67,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docs/package.json b/docs/package.json index 076c461693..4d1cae270b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -39,5 +39,5 @@ "markdown-it-anchor": "^10.0.0", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 460dc46c55..e647b1e78b 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/exporter/package.json b/exporter/package.json index 40c8f6e923..d4eeab2c7f 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 843416db40..1f88853fc8 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/frontend/package.json b/frontend/package.json index 4e1433e206..4a73f7a7ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "browserslist": [ "defaults" ], diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 293e69d269..6531f4ca78 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -4,7 +4,7 @@ "description": "Penpot Draft-JS Wrapper", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 3eb605929d..3c22c33ff5 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -7,7 +7,7 @@ "scripts": { "test": "node --test \"test/*.test.js\"" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index cd8c59aad1..aea882f601 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "types": "./dist/index.d.ts", "type": "module", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "exports": { ".": { "import": "./dist/index.js" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6a0901cc8c..3efa68def5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index 02cbcf2349..45fc013e71 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -28,5 +28,5 @@ "vite": "^8.2.0", "vitest": "^5.0.2" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/library/package.json b/library/package.json index e41f4a6480..6b1911e2bd 100644 --- a/library/package.json +++ b/library/package.json @@ -3,7 +3,7 @@ "version": "1.2.0", "license": "MPL-2.0", "author": "KALEIDOS SUBSIDIARY SL", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "type": "module", "repository": { "type": "git", diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index d8a67b195d..6a7c1982b3 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/mcp/package.json b/mcp/package.json index a62d084e1f..f80b14da00 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,7 +23,7 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "devDependencies": { "concurrently": "^10.0.5", "prettier": "^3.9.9" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index b87f8ce17b..6ddd0bd605 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index ffa4f3837a..add5b088f8 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "type": "module", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "start": "vite build --watch --config vite.config.ts", "start:multi-user": "pnpm run start", diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index ee816a759b..e2c82726fb 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -27,7 +27,7 @@ "engines": { "node": ">=20" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "dependencies": { "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 7b7f98e50e..f087bae740 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/media-processor/package.json b/media-processor/package.json index 4a79f0ae66..29001cae44 100644 --- a/media-processor/package.json +++ b/media-processor/package.json @@ -14,7 +14,7 @@ "fmt:check": "prettier --check src/ test/", "clean": "rm -rf dist/" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "dependencies": { "express": "^5.2.1", "multer": "^2.4.0", diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index 147a963c3e..eb00e8a1b9 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/package.json b/package.json index 05df2e97cd..5a14003bcd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index a762e7494c..4c9690e2a2 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 1f21539f19..f12e809fe0 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -27,5 +27,5 @@ "vite": "^8.3.2", "vite-live-preview": "^0.4.0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 8402969c20..641618631d 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index de963120f4..d52baaa757 100644 --- a/plugins/apps/create-palette-plugin/package.json +++ b/plugins/apps/create-palette-plugin/package.json @@ -12,5 +12,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index 97405221de..d9c64e6bac 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -7,5 +7,5 @@ "test": "vitest", "lint": "eslint ." }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index f1fc7219c9..227f8b7a9f 100644 --- a/plugins/apps/example-styles/package.json +++ b/plugins/apps/example-styles/package.json @@ -11,5 +11,5 @@ "serve": "vite preview", "lint": "eslint ." }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index 841c979c14..31f25c5e8a 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index 5868285101..f5d5be5541 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index 1c6ba084c1..0927a7388f 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -20,5 +20,5 @@ "devDependencies": { "playwright": "^1.63.0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index 3e225d2f41..df5bc13b27 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index 83d30f08a9..41337275df 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "exit 0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index 90faf2acea..38eb1e0939 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index b77a35b327..f2bc2d696b 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 87546804f9..e4959293fa 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -7,5 +7,5 @@ "build": "node ../../tools/scripts/build-types.mjs", "lint": "tsc -p . --noEmit" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index f40741cf5e..23aeeb2ee5 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -16,5 +16,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index aab41a3eaf..5badd11056 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -6,5 +6,5 @@ "build": "node ../../tools/scripts/build-css.mjs", "lint": "echo 0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/package.json b/plugins/package.json index f6b6d8901c..01d2a885b5 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "start": "pnpm run start:app:runtime", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index faf9371961..b84286c8f0 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03d4f60051..2b8e0f539b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/render-wasm/package.json b/render-wasm/package.json index 96253a6356..f1f4b196ab 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index 9ef64d85bd..a0180171fa 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' From 41be5bfb5f0df0f6aa97fb8347e2fc5e620834c8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 29 Oct 2026 15:28:19 +0100 Subject: [PATCH 46/65] :arrow_up: Bump docker base images --- docker/images/Dockerfile.backend | 4 ++-- docker/images/Dockerfile.exporter | 2 +- docker/images/Dockerfile.frontend | 2 +- docker/images/Dockerfile.mcp | 4 ++-- docker/images/Dockerfile.media-processor | 2 +- docker/images/Dockerfile.storybook | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 32e8c2c1fa..4618c1ba91 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -53,7 +53,7 @@ RUN set -eux; \ --output /opt/jre; -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 30186f3a9b..b427e0bc80 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 +FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 26f84d4640..05939a1982 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:940ee353dd807f5c5e32720923bb9b34ef9e631ae567320f3eaea7797d6c7964 +FROM dhi.io/nginx:1.31.8-debian13-dev@sha256:fbff190f9162256975ed225adcc9a294e333897de543fd18be630de1d6c8de17 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index 475df43098..aac0ff2a38 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 AS build +FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.22.1-debian13@sha256:32a2fa6af5209698a9354cef0b19ada3dbfac3090b1010b70afa9f1a298ba2d9 AS image +FROM dhi.io/node:24.22.2-debian13@sha256:24fa6258b891ef66e6c3b7ed82daad550b81cdfcd76f6cc64eee4d2478366747 AS image LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index 6d00d1eba4..b2e9eb4af2 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 +FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 2b259ffb61..4832c60054 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.7-alpine3.24@sha256:6d3811fbc76c5d0652752b39a003769759ccfeb2c1e874bacf99e3d44e5da3b9 +FROM dhi.io/nginx:1.31.8-alpine3.24@sha256:83489ce440fa56cb5149c088dbf75422362e45c532c814100253e5ab71871366 LABEL maintainer="Penpot " ARG BUNDLE_PATH="./bundle-storybook/" From 04de86f9393263f896fdd318e8e7a59ca711e00f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 29 Oct 2026 15:06:43 +0000 Subject: [PATCH 47/65] :sparkles: Add a build task to the local CI orchestrator `./scripts/ci --build` compiles each module the way production does, without going through `manage.sh build-bundle`, which wipes node_modules and reinstalls dependencies. It runs the release commands already defined per module and reuses existing caches, so a broken release build (shadow-cljs optimizations, the wasm targets, the backend uberjar, tsc/typedoc) shows up before a bundle is built. The task is opt-in and runs last, after lint, test and fmt, so the default flow keeps its duration. `common` has no build of its own and reports as skipped. `media-processor` joins as the ninth module, with its lint skipped and `tsc --noEmit` folded into its build. All build outputs are gitignored. `scripts/test_ci.py` covers the flag wiring and one command per module through `--dry-run`; it never runs a real build. AI-assisted-by: deepseek-v4.1-flash --- .agents/skills/local-ci/SKILL.md | 22 ++++- .serena/memories/scripts/ci.md | 72 +++++++++++--- scripts/ci | 38 +++++++- scripts/test_ci.py | 159 +++++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+), 18 deletions(-) create mode 100644 scripts/test_ci.py diff --git a/.agents/skills/local-ci/SKILL.md b/.agents/skills/local-ci/SKILL.md index 5cda6acf89..d04f581dc1 100644 --- a/.agents/skills/local-ci/SKILL.md +++ b/.agents/skills/local-ci/SKILL.md @@ -1,6 +1,6 @@ --- name: local-ci -description: Run local CI-style checks with ./scripts/ci (lint, tests, format) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, or repairing Clojure delimiter errors. +description: Run local CI-style checks with ./scripts/ci (lint, tests, format, and on request production builds) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, repairing Clojure delimiter errors, or checking that a module still compiles. --- # Local CI @@ -29,10 +29,12 @@ Run from the repo root: ``` Modules: `frontend` `backend` `common` `render-wasm` `exporter` `mcp` -`plugins` `library`, or `--all` for every module. +`plugins` `library` `media-processor`, or `--all` for every module. With no task flags it runs three tasks per module, in order: **lint**, -**test**, **fmt** (format check; `--fix` formats files instead). +**test**, **fmt** (format check; `--fix` formats files instead). **build** +is opt-in: it compiles the module the way production does and is the slow +one, so it never runs unless you ask for it. | Flag | Effect | |------|--------| @@ -41,6 +43,7 @@ With no task flags it runs three tasks per module, in order: **lint**, | `--lint` / `--no-lint` | Run only lint / drop lint | | `--test` / `--no-test` | Run only tests / drop tests | | `--fmt` / `--no-fmt` | Run only format check / drop it | +| `--build` / `--no-build` | Run only the production build / drop it | | `--fix` | Format files instead of checking (other tasks unaffected) | | `--paren-repair` | Fix delimiter errors in Clojure/CLJS files | | `--fail-fast` | Stop at the first failure | @@ -72,6 +75,12 @@ log path. # Format the module without running the test suite ./scripts/ci --fix --no-test frontend +# Check that the module still compiles (slow: minutes) +./scripts/ci --build frontend + +# Compile every module (8 builds, ~4 min with warm caches) +./scripts/ci --build --all + # Broke delimiters in Clojure/CLJS files: repair first, then lint ./scripts/ci --paren-repair frontend ./scripts/ci --lint frontend @@ -89,7 +98,12 @@ log path. - Test tasks are long-running (backend runs `clojure -M:dev:test`); give the bash call a generous timeout (10–20 minutes) instead of letting it time out mid-run. -- `mcp` has no lint task — it shows as skipped, not failed. +- `mcp` and `media-processor` have no lint task — they show as skipped, + not failed. `common` has no build task for the same reason. +- Build runs are the slow ones (frontend ~2 min even with warm caches; + cold caches roughly double it). Give the shell a generous timeout and + do not run two builds at once in the same checkout: `render-wasm` takes + a build lock. - `--paren-repair` only fixes delimiters; run lint afterwards to catch what remains. See `mem:scripts/paren-repair`. - What to run and how to read test results: `mem:testing`. diff --git a/.serena/memories/scripts/ci.md b/.serena/memories/scripts/ci.md index e3ee25c614..d236bad383 100644 --- a/.serena/memories/scripts/ci.md +++ b/.serena/memories/scripts/ci.md @@ -1,8 +1,9 @@ # CI (scripts/ci) -`scripts/ci` runs CI-style checks — lint, tests, format — for one or more -monorepo modules and prints a per-task summary. It is the local equivalent -of CI; use it to verify changes before declaring work done. +`scripts/ci` runs CI-style checks — lint, tests, format — and, on request, +the production build of each module, for one or more monorepo modules, and +prints a per-task summary. It is the local equivalent of CI; use it to +verify changes before declaring work done. ## When to use @@ -19,6 +20,7 @@ Run from the repo root: ```bash ./scripts/ci MODULE... # lint + test + fmt per module +./scripts/ci --build MODULE # production build only ./scripts/ci --all --no-test # lint + fmt on all modules ./scripts/ci --lint frontend # lint only ./scripts/ci --fix --no-test frontend # format files, skip tests @@ -26,14 +28,15 @@ Run from the repo root: ./scripts/ci --dry-run --all # preview what would run ``` -Modules: `frontend backend common render-wasm exporter mcp plugins library`. +Modules: `frontend backend common render-wasm exporter mcp plugins library +media-processor`. Flags: - Default tasks: `lint`, `test`, `fmt` (format check; `--fix` formats - instead). -- `--lint` / `--test` / `--fmt` run one task only; `--no-lint` / - `--no-test` / `--no-fmt` drop one task from the default set. + instead). Build is **not** in the default set. +- `--lint` / `--test` / `--fmt` / `--build` run one task only; `--no-lint` / + `--no-test` / `--no-fmt` / `--no-build` drop one task from the default set. - `--paren-repair` runs only the delimiter repair — it wraps `scripts/paren-repair` over each module's Clojure/CLJS sources; see `mem:scripts/paren-repair`. @@ -42,6 +45,49 @@ Flags: output; `--dry-run` prints commands without running; `--clean` removes the log directory. +## Build task + +`--build` compiles each module the way production does, reusing caches +(`.shadow-cljs`, `target/`, `node_modules`) instead of clearing them. It +does not package bundles: `manage.sh build-bundle` remains the way to +produce deployable artifacts. + +| Module | Command | Output | +|---|---|---| +| `frontend` | `NODE_ENV=production pnpm run build:app:main && pnpm run build:app:libs && pnpm run build:app:assets` | `frontend/resources/public/`, `frontend/target/` | +| `backend` | `clojure -T:build jar` | `backend/target/penpot.jar` | +| `common` | *(none; skipped)* | — | +| `render-wasm` | `./build` (targets `frontend` and `export`) | `render-wasm/target/` | +| `exporter` | `NODE_ENV=production pnpm run build:app` | `exporter/target/` | +| `mcp` | `pnpm run build` | `mcp/*/dist` | +| `plugins` | `pnpm run build:runtime && pnpm run build:plugins && pnpm run build:styles-example && pnpm run build:doc` | `plugins/dist/` | +| `library` | `NODE_ENV=production clojure -M:dev:shadow-cljs release library` | `library/target/library/` | +| `media-processor` | `pnpm run types:check && pnpm run build` | `media-processor/dist/` | + +- Every output path above is gitignored, so a build leaves `git status` + clean. +- The module `build` scripts in each `scripts/build` are **not** used: they + delete `node_modules`, wipe `resources/public` and run `pnpm install`, + which belongs to the bundle pipeline, not to a local check. +- `frontend` and `library` have a `build` script that clears the shadow-cljs + cache first; the task calls the inner commands to stay incremental. + `exporter` uses `build:app` for the same reason. +- `common` has no artifact of its own: its compilation is exercised by its + `test` task and by the builds of `frontend`, `backend` and `exporter`. +- `media-processor` is the ninth module. It has no lint, so that task shows + as skipped; its build runs `tsc --noEmit` before esbuild because esbuild + does not type-check. +- Cold-cache cost measured on the devenv: `library` ~120 s, `frontend` ~120 s, + `exporter` ~80 s, `backend` ~35 s. With warm caches the whole set (8 + builds) takes ~4 min. The first run of `clojure -T:build jar` downloads + `tools.build` if it is not in `~/.m2`. +- `pnpm run` may trigger a dependency check/install when `node_modules` + drifts from the lockfile (`verify-deps-before-run`), so the first run + after a dependency change can need network. +- `render-wasm/build` takes a lock (`_build_lock`): never run two builds at + once in the same checkout. + + ## Logs and exit codes - Full output of every task: `.ci-logs/-.log`. @@ -53,12 +99,16 @@ Flags: ## Notes -- `mcp` has no lint task (shows as skipped). `render-wasm` uses `./lint`, - `./test`, and `cargo fmt`. +- `mcp` has no lint task and `media-processor` has no lint task; both show + as skipped. `render-wasm` uses `./lint`, `./test`, and `cargo fmt`. +- `scripts/test_ci.py` (unittest, `python3 scripts/test_ci.py`) covers the + flag wiring, one build command per module and the help text through + `--dry-run`; it executes no real build. - Exports `PENPOT_FLAGS` with `enable-backend-asserts` appended to any existing value, so backend test runs compile in and exercise precondition asserts; see `mem:backend/testing`. -- Test tasks are long-running (backend: `clojure -M:dev:test`); use a - generous timeout when calling it from an agent shell. +- Test and build tasks are long-running (backend: `clojure -M:dev:test`; + frontend build: ~2 min warm); use a generous timeout when calling it from + an agent shell. - Skill entry point: `.agents/skills/local-ci/SKILL.md`. - Testing principles and output discipline: `mem:testing`. diff --git a/scripts/ci b/scripts/ci index 4428b673f9..db58292ae9 100755 --- a/scripts/ci +++ b/scripts/ci @@ -24,7 +24,7 @@ BOLD='\033[1m' NC='\033[0m' # Available modules -ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library") +ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library" "media-processor") # Module commands declare -A LINT_CMD=( @@ -36,6 +36,7 @@ declare -A LINT_CMD=( [mcp]="" [plugins]="pnpm run lint" [library]="pnpm run lint" + [media-processor]="" ) declare -A TEST_CMD=( @@ -47,6 +48,7 @@ declare -A TEST_CMD=( [mcp]="pnpm run test" [plugins]="pnpm run test" [library]="pnpm run test" + [media-processor]="pnpm run test" ) declare -A FMT_CHECK_CMD=( @@ -58,6 +60,7 @@ declare -A FMT_CHECK_CMD=( [mcp]="pnpm run fmt:check" [plugins]="pnpm run format:check" [library]="pnpm run check-fmt:clj" + [media-processor]="pnpm run fmt:check" ) declare -A FMT_FIX_CMD=( @@ -69,6 +72,19 @@ declare -A FMT_FIX_CMD=( [mcp]="pnpm run fmt" [plugins]="pnpm run format" [library]="pnpm run fmt:clj" + [media-processor]="pnpm run fmt" +) + +declare -A BUILD_CMD=( + [frontend]="NODE_ENV=production pnpm run build:app:main && pnpm run build:app:libs && pnpm run build:app:assets" + [backend]="clojure -T:build jar" + [common]="" + [render-wasm]="./build" + [exporter]="NODE_ENV=production pnpm run build:app" + [mcp]="pnpm run build" + [plugins]="pnpm run build:runtime && pnpm run build:plugins && pnpm run build:styles-example && pnpm run build:doc" + [library]="NODE_ENV=production clojure -M:dev:shadow-cljs release library" + [media-processor]="pnpm run types:check && pnpm run build" ) declare -A PAREN_REPAIR_CMD=( @@ -80,6 +96,7 @@ declare -A PAREN_REPAIR_CMD=( [mcp]="" [plugins]="" [library]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" + [media-processor]="" ) # Default options @@ -197,6 +214,9 @@ run_module() { cmd="${FMT_CHECK_CMD[$module]:-}" fi ;; + build) + cmd="${BUILD_CMD[$module]:-}" + ;; paren-repair) cmd="${PAREN_REPAIR_CMD[$module]:-}" ;; @@ -282,8 +302,8 @@ Usage: $(basename "$0") [OPTIONS] [MODULES...] CI orchestration script for Penpot monorepo. MODULES: - frontend, backend, common, render-wasm, exporter, mcp, plugins, library - Use --all to run all modules. + frontend, backend, common, render-wasm, exporter, mcp, plugins, library, + media-processor. Use --all to run all modules. OPTIONS: --all Run all modules @@ -294,6 +314,8 @@ OPTIONS: --no-test Skip tests --fmt Run format check only --no-fmt Skip format check + --build Run production build only + --no-build Skip build --fix Run format fix instead of check --paren-repair Fix delimiter errors in Clojure/CLJS files --fail-fast Stop on first failure @@ -307,6 +329,8 @@ EXAMPLES: $(basename "$0") frontend backend # Run lint, test, fmt on frontend and backend $(basename "$0") --all --no-test # Run lint and fmt on all modules $(basename "$0") --lint common # Run only lint on common + $(basename "$0") --build frontend # Run only the production build of frontend + $(basename "$0") --build --all # Build every module $(basename "$0") --fix frontend # Fix formatting in frontend $(basename "$0") --paren-repair frontend # Fix delimiter errors in frontend $(basename "$0") --paren-repair --all # Fix delimiter errors in all Clojure modules @@ -353,6 +377,14 @@ main() { TASKS=("${TASKS[@]/fmt/}") shift ;; + --build) + TASKS=("build") + shift + ;; + --no-build) + TASKS=("${TASKS[@]/build/}") + shift + ;; --paren-repair) TASKS=("paren-repair") shift diff --git a/scripts/test_ci.py b/scripts/test_ci.py new file mode 100644 index 0000000000..405f385d8e --- /dev/null +++ b/scripts/test_ci.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Tests for scripts/ci. + +Run with: + + python3 scripts/test_ci.py + +Covers the wiring of the orchestrator around the `build` task and the +`media-processor` module: the flag set, one build command per module, the +help text, the dry-run mode (which must execute and write nothing), and the +default task list staying free of build. +""" + +import pathlib +import re +import subprocess +import sys +import unittest + +sys.dont_write_bytecode = True + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +CI = REPO_ROOT / "scripts" / "ci" +LOG_DIR = REPO_ROOT / ".ci-logs" + +ANSI = re.compile(r"\x1b\[[0-9;]*m") + +# Modules with a production build. `common` has no build of its own: its +# compilation happens in its test task and in the modules consuming it. +BUILD_MODULES = ( + "frontend", + "backend", + "render-wasm", + "exporter", + "mcp", + "plugins", + "library", + "media-processor", +) + +ALL_MODULES = BUILD_MODULES + ("common",) + + +def run_ci(*args, cwd=REPO_ROOT): + """Run scripts/ci and return (exit_code, output without ANSI escapes).""" + proc = subprocess.run( + [str(CI), *args], + cwd=cwd, + capture_output=True, + text=True, + ) + return proc.returncode, ANSI.sub("", proc.stdout + proc.stderr) + + +def log_snapshot(): + """Names and mtimes of every entry under .ci-logs, or None when absent.""" + if not LOG_DIR.exists(): + return None + return sorted((p.name, p.stat().st_mtime_ns) for p in LOG_DIR.iterdir()) + + +class BuildTaskTests(unittest.TestCase): + def test_build_all_has_one_command_per_module_without_common(self): + code, out = run_ci("--build", "--dry-run", "--all") + + self.assertEqual(code, 0, out) + self.assertEqual(out.count("build: would run"), len(BUILD_MODULES), out) + self.assertIn("build: not defined for common, skipping", out) + for module in BUILD_MODULES: + self.assertIn(f" in {module}/", out) + + def test_build_is_not_part_of_the_default_tasks(self): + code, out = run_ci("--dry-run", "--all") + + self.assertEqual(code, 0, out) + self.assertNotIn("build:", out) + for task in ("lint", "test", "fmt"): + self.assertIn(f"{task}: would run", out) + + def test_build_flag_runs_build_only(self): + code, out = run_ci("--build", "--dry-run", "frontend") + + self.assertEqual(code, 0, out) + self.assertIn("build: would run", out) + for task in ("lint", "test", "fmt"): + self.assertNotIn(f"{task}:", out) + + def test_no_build_flag_is_accepted(self): + code, out = run_ci("--no-build", "--dry-run", "frontend") + + self.assertEqual(code, 0, out) + self.assertIn("lint: would run", out) + self.assertNotIn("build:", out) + + def test_dry_run_executes_nothing(self): + before = log_snapshot() + + run_ci("--build", "--dry-run", "mcp") + + self.assertEqual(before, log_snapshot()) + + +class MediaProcessorModuleTests(unittest.TestCase): + def test_module_is_accepted_and_lint_is_skipped(self): + code, out = run_ci("--dry-run", "media-processor") + + self.assertEqual(code, 0, out) + self.assertIn( + "lint: not defined for media-processor, skipping", + out, + ) + for task in ("test", "fmt"): + self.assertIn(f"{task}: would run", out) + + def test_module_is_included_in_all(self): + code, out = run_ci("--dry-run", "--all") + + self.assertEqual(code, 0, out) + self.assertIn("media-processor", out) + self.assertIn("media-processor:test", out) + + def test_build_runs_types_check_before_esbuild(self): + code, out = run_ci("--build", "--dry-run", "media-processor") + + self.assertEqual(code, 0, out) + self.assertIn( + "pnpm run types:check && pnpm run build", + out, + ) + + +class HelpTests(unittest.TestCase): + def help_options(self, out): + """Option tokens from the OPTIONS section, e.g. '--build'.""" + options = [] + for line in out.splitlines(): + stripped = line.strip() + if stripped.startswith("--"): + options.append(stripped.split()[0]) + return options + + def test_help_documents_the_build_flags(self): + code, out = run_ci("--help") + + self.assertEqual(code, 0, out) + options = self.help_options(out) + self.assertIn("--build", options) + self.assertIn("--no-build", options) + + def test_help_lists_every_module(self): + code, out = run_ci("--help") + + self.assertEqual(code, 0, out) + for module in ALL_MODULES: + self.assertIn(module, out) + + +if __name__ == "__main__": + unittest.main() From af2e5921feab4c2919048cdf74208286052e7fba Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 29 Oct 2026 15:15:34 +0000 Subject: [PATCH 48/65] :books: Document the CI build task in the memory index and AGENTS `critical-info` is the first memory every agent reads, and its `scripts/ci` entry still described the orchestrator as lint + tests + format over eight modules. `AGENTS.md` repeated the same incomplete description in its scripts section. Both now mention the opt-in production build and list `media-processor` among the modules. AI-assisted-by: deepseek-v4.1-flash --- .serena/memories/critical-info.md | 2 +- AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index a9a7564cdf..867eecdbd9 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -63,7 +63,7 @@ The memory is structured in a way that you can get the critical information abou - `scripts/error-reports.mjs` — Query error reports via RPC API with token authentication. Supports list/get operations with filtering and pagination. See `mem:scripts/error-reports`. - `scripts/clean-node-modules` — Remove stale `node_modules` from all pnpm workspaces (root, modules, member packages). Keeps the shared pnpm store at `/.pnpm-store` unless `--store`; ignores `external/` and `.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`. - `scripts/setup-opencode` — Install the opencode CLI in-container as a global pnpm package in `$PNPM_HOME`. Not part of the devenv image; see `mem:devenv/core`. -- `scripts/ci` — CI orchestration script: runs lint, tests, and format checks per module (`frontend backend common render-wasm exporter mcp plugins library`). Logs go to `.ci-logs/`; read the log file on failure. See `mem:scripts/ci`. +- `scripts/ci` — CI orchestration script: runs lint, tests and format checks per module, plus the production build on request (`--build`). Modules: `frontend backend common render-wasm exporter mcp plugins library media-processor`. Logs go to `.ci-logs/`; read the log file on failure. See `mem:scripts/ci`. # Dependency graph diff --git a/AGENTS.md b/AGENTS.md index e900e32518..c95fb06920 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,6 +152,6 @@ precision while maintaining a strong focus on maintainability and performance. - `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend). - `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines. - `scripts/check-fmt-clj` — Check Clojure formatting without modifying files. -- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `mem:scripts/ci`. +- `scripts/ci` — CI orchestration script for running lint, tests, format checks and, on request (`--build`), production builds across modules. See `mem:scripts/ci`. - `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`. From cb8f563bb2abe762f5c8b028974f14b3a05e9135 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Thu, 29 Oct 2026 17:30:16 +0100 Subject: [PATCH 49/65] :bug: Keep swapped and moved children in place when a component syncs (#12716) * :bug: Keep swapped and moved children in place when a component syncs A sync resets an untouched shape of a copy to the main's geometry and the layout then moves it back. Children the sync keeps, because they are swapped or the user moved them, did not take the first move but did take the second, so each sync pushed them away from their parent by the gap between the copy and the main (e.g. a swapped icon in a copy that fills a wider layout). The sync now moves those children by the offset it applies to their parent. Flex-child attributes still sync as before, so swapped children keep their absolute position. Closes #12657 * :bug: Place swapped children where the swap puts them on sync and switch A swapped child that the user never moved now goes to the top-left of the shape it replaced, as a swap does: on a sync, the place of the main's slot occupant; on a variant switch, the place of the new variant's occupant. A swapped child the user moved keeps its offset inside its parent. This stops the variant switch from leaving the swapped child at its old position while the new variant is created at the main's size, and puts back children that drifted before the sync fix the next time their copy syncs. * :bug: Make kept children follow their parent's constraints on component sync When a sync resets the size of a copy's shape, for example a copy root that fills a wider layout, the layout then grows it back and moves its children by their constraints. Children the sync keeps (moved by the user, or swapped) only followed the parent's top-left, so with a right, center or scale constraint they drifted on every sync, and a swapped child with a scale constraint grew on every sync. The sync now passes the parent's geometry before and after down the tree, and kept children follow that change through their constraints. Untouched swapped children also undo the change through their constraints before they go to the swap place, so they keep their size. The default constraint modifier returns empty modifiers instead of an empty vector, which add-modifiers could not take. * :bug: Make swapped children follow their parent's constraints on switch A variant switch only moved a swapped child by the parent's position change. When the new variant's root had a different size, a user-moved child with a right constraint drifted, and a scale-constrained one kept its grown size. The switch now applies the parent change from the old parent to the new one through the child's constraints. An untouched swapped child then takes the place of the shape it replaces, as a swap does. * :bug: Sync nested swapped copies before the copies that hold them A swapped child is a copy of its own component, so one sync handles it twice: once on its own, and once when the copy that holds it places it. Each step read the shape from the data as it was before the sync, so the step that ran last won, and the order came from the order of shape ids. When the swapped child came last, it wrote its old size and position back over the placement, leaving its x and selrect apart, and a swapped child with a scale constraint grew on every sync. The sync now visits the most deeply nested shapes first, and each step reads the shapes as the earlier steps left them, so the placement always comes last and builds on the child's own sync. * :bug: Keep moved children with a scale constraint at their size on switch A variant switch carries the size of a child the user moved as an override. In a copy that fills a wider layout, a child with a scale constraint already has the size the layout gave it, so the switch carried that size onto the new variant, created at the main's size, and the layout then scaled it again: the child grew on every switch. Before comparing, the switch now resizes the previous child the way its constraints follow the change from its old parent to its new one. A child that only grew with the layout then matches the main and is not carried as an override. * :bug: Keep the content of swapped children at its size on sync and switch When a sync or a variant switch undid the parent change on a swapped child through its constraints, it applied the child's resize to every shape inside it. The layout then grows the child back through its own constraint, but its content follows its own constraints (left and top by default), so it stayed shrunk: the shape inside a swapped icon with a scale constraint lost most of its width on every switch. The change of the swapped child now goes down its subtree the way a layout update does, each shape through its own constraints. * :bug: Accept mixed and empty values in the layout size constraints menu With several shapes or a group selected, the sidebar merges the layout item attributes and tokens of the shapes: nil when a shape lacks one, :multiple when they differ. The props schema of layout-size-constraints* only accepted the values of a single shape, so selecting such shapes with fill sizing raised "invalid props". The schema now accepts nil and :multiple, as the numeric inputs those values go to do. * :recycle: Share the swapped child follow-parent step between sync and switch The component sync and the variant switch both make a swapped child and its children follow the parent change through their constraints and then move them to the swap place. Both now use generate-follow-parent, so the two paths stay the same. The helper that writes geometry without marking it touched is renamed to update-geometry-untouched. Adds a test for a swapped child without absolute position in a slot without layout, and fixes the test comments on why constraints are set again after a swap. --- .../app/common/geom/shapes/constraints.cljc | 2 +- common/src/app/common/logic/libraries.cljc | 135 +++++++++- common/src/app/common/logic/variants.cljc | 22 +- .../app/common/test_helpers/compositions.cljc | 28 ++ .../geom_shapes_constraints_test.cljc | 18 +- .../logic/comp_flex_interactions_test.cljc | 246 ++++++++++++++++-- .../logic/variants_switch_test.cljc | 144 ++++++++++ .../sidebar/options/menus/layout_item.cljs | 33 ++- 8 files changed, 563 insertions(+), 65 deletions(-) diff --git a/common/src/app/common/geom/shapes/constraints.cljc b/common/src/app/common/geom/shapes/constraints.cljc index ca92c93cc7..325a6d3069 100644 --- a/common/src/app/common/geom/shapes/constraints.cljc +++ b/common/src/app/common/geom/shapes/constraints.cljc @@ -235,7 +235,7 @@ (ctm/move-modifiers (displacement center-before center-after before-side-vector after-side-vector)))) (defmethod constraint-modifier :default [_ _ _ _ _ _] - []) + (ctm/empty)) (def const->type+axis {:left :start diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index ac5a516156..3a87e923c4 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -13,6 +13,7 @@ [app.common.files.helpers :as cfh] [app.common.files.variant :as cfv] [app.common.geom.matrix :as gmt] + [app.common.geom.modifiers :as gm] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] @@ -27,6 +28,7 @@ [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.library :as ctl] + [app.common.types.modifiers :as ctm] [app.common.types.page :as ctp] [app.common.types.pages-list :as ctpl] [app.common.types.path.segment :as segment] @@ -577,15 +579,19 @@ (container-log :debug (:id container) :msg "Sync page in local file" :page-id (:id container)) (container-log :debug (:id container) :msg "Sync component in local library" :component-id (:id container))) - (let [linked-shapes (->> (vals (:objects container)) - (filter #(uses-assets? asset-type asset-id % library-id)))] + (let [objects (:objects container) + ;; Deepest first, so a copy places its swapped children after their own sync + linked-shapes (->> (vals objects) + (filter #(uses-assets? asset-type asset-id % library-id)) + (sort-by #(- (count (cfh/get-parent-ids objects (:id %))))))] (loop [shapes (seq linked-shapes) changes (-> changes (pcb/with-container container) - (pcb/with-objects (:objects container)))] + (pcb/with-objects objects))] (if-let [shape (first shapes)] (recur (next shapes) - (let [objects' (pcb/get-objects changes) + (let [changes (pcb/apply-changes-local changes) + objects' (pcb/get-objects changes) shape' (get objects' (:id shape))] ;; The shape could have been deleted in previous changes, if this nested component ;; comes from components-v1 era or if there has been some error with the swap slot. @@ -594,7 +600,7 @@ (generate-sync-shape asset-type changes library-id - container + (assoc container :objects objects') shape' libraries current-file-id) @@ -832,7 +838,8 @@ root-inst root-main reset? - initial-root?) + initial-root? + nil) ;; If the component is not found, because the master component has been ;; deleted or the library unlinked, do nothing. @@ -866,8 +873,88 @@ (recur shape-inst' next-data component') nil)))))) +(defn- geometry-sync-delta + "Offset the sync applies to the top-left of `dest-shape` when it copies the + geometry of `origin-shape`." + [dest-shape origin-shape dest-root origin-root] + (gpt/subtract (grc/rect->point (:selrect (reposition-shape origin-shape origin-root dest-root))) + (grc/rect->point (:selrect dest-shape)))) + +(defn- follow-parent-modifiers + "Modifiers that make `child` follow the change of its parent from + `parent-before` to `parent-after`, through the child constraints." + [child parent-before parent-after] + (let [sr-before (:selrect parent-before) + sr-after (:selrect parent-after) + modifiers (-> (ctm/empty) + (ctm/resize (gpt/point (/ (:width sr-after) (max 0.01 (:width sr-before))) + (/ (:height sr-after) (max 0.01 (:height sr-before)))) + (first (:points parent-before)) + (:transform parent-before) + (:transform-inverse parent-before)) + (ctm/move (gpt/subtract (first (:points parent-after)) + (first (:points parent-before)))))] + ;; A move doesn't need the constraints + (if (or (ctm/empty? modifiers) (ctm/only-move? modifiers)) + modifiers + (gsh/calc-child-modifiers parent-before child modifiers false + (:points child) + (:points parent-before) + (delay (:points parent-after)))))) + +(defn- follow-parent-modif-tree + "Modifiers that make `child` and its children follow the change of the + parent of `child` from `parent-before` to `parent-after`, each one through + its own constraints." + [objects child parent-before parent-after] + (let [modifiers (follow-parent-modifiers child parent-before parent-after)] + (if (ctm/empty? modifiers) + {} + (gm/set-objects-modifiers {(:id child) {:modifiers modifiers}} + (select-keys objects (cfh/get-children-ids-with-self objects (:id child))))))) + +(defn- resize-with-parent + "Resizes `shape` the way its constraints do when its parent changes from + `parent-before` to `parent-after`, keeping its top-left." + [shape parent-before parent-after] + (let [modifiers (follow-parent-modifiers shape parent-before parent-after)] + (if (ctm/empty? modifiers) + shape + (let [shape' (gsh/transform-shape shape modifiers)] + (gsh/move shape' (gpt/subtract (grc/rect->point (:selrect shape)) + (grc/rect->point (:selrect shape')))))))) + +(defn- update-geometry-untouched + "Applies `update-fn` to the shapes without marking them touched." + [changes ids update-fn container] + (pcb/concat-changes + changes + (-> (pcb/empty-changes) + (pcb/with-container container) + (pcb/with-objects (:objects container)) + (pcb/update-shapes ids update-fn {:ignore-touched true})))) + +(defn generate-follow-parent + "Makes `child` and its children follow the change of the parent of `child` + from `parent-before` to `parent-after`, each one through its own + constraints, without marking them touched. `delta-fn`, if given, receives + `child` after that and returns the offset to move the subtree by." + [changes container child parent-before parent-after delta-fn] + (let [objects (:objects container) + modif-tree (follow-parent-modif-tree objects child parent-before parent-after) + follow #(gsh/transform-shape % (dm/get-in modif-tree [(:id %) :modifiers])) + delta (if delta-fn (delta-fn (follow child)) (gpt/point 0 0))] + (cond-> changes + (or (seq modif-tree) (not (gpt/almost-zero? delta))) + (update-geometry-untouched (cfh/get-children-ids-with-self objects (:id child)) + #(gsh/move (follow %) delta) + container)))) + (defn- generate-sync-shape-direct-recursive - [changes container shape-inst component library file libraries shape-main root-inst root-main reset? initial-root?] + "Syncs `shape-inst` and its children from `shape-main`. `parent-change` is + the geometry of the parent of `shape-inst` before and after the sync, or + nil." + [changes container shape-inst component library file libraries shape-main root-inst root-main reset? initial-root? parent-change] (shape-log :debug (:id shape-inst) container :msg "Sync shape direct recursive" :shape-inst (str (:name shape-inst) " " (pretty-uuid (:id shape-inst))) @@ -879,6 +966,19 @@ (let [omit-touched? (not reset?) clear-remote-synced? (and initial-root? reset?) set-remote-synced? (and (not initial-root?) reset?) + geometry-kept? (and omit-touched? + (ctk/touched-group? shape-inst :geometry-group)) + main-geometry (reposition-shape shape-main root-main root-inst) + follow-modifiers (when (and geometry-kept? (some? parent-change)) + (let [[parent-before parent-after] parent-change] + (follow-parent-modifiers shape-inst parent-before parent-after))) + shape-after (cond + (not geometry-kept?) main-geometry + (some? follow-modifiers) (gsh/transform-shape shape-inst follow-modifiers) + :else shape-inst) + ;; Offset between where the sync leaves shape-inst and where the main puts it + main-gap (gpt/subtract (grc/rect->point (:selrect shape-after)) + (grc/rect->point (:selrect main-geometry))) changes (cond-> changes :always @@ -889,6 +989,11 @@ container omit-touched?) + (and (some? follow-modifiers) (not (ctm/empty? follow-modifiers))) + (update-geometry-untouched [(:id shape-inst)] + #(gsh/transform-shape % follow-modifiers) + container) + (ctsl/flex-layout? shape-main) (update-flex-child-copy-attrs shape-main shape-inst @@ -962,15 +1067,20 @@ root-inst root-main reset? - initial-root?)) + initial-root? + [shape-inst shape-after])) swapped (fn [changes child-inst child-main] (shape-log :trace (:id child-inst) container :msg "Match slot" :child-inst (str (:name child-inst) " " (pretty-uuid (:id child-inst))) :child-main (str (:name child-main) " " (pretty-uuid (:id child-main)))) - ;; For now we don't make any sync here. - changes) + ;; A swapped child follows its parent and, if the user didn't move it, + ;; goes to the place of the slot's occupant in the main + (generate-follow-parent changes container child-inst shape-inst shape-after + (when-not (ctk/touched-group? child-inst :geometry-group) + #(gpt/add (geometry-sync-delta % child-main root-inst root-main) + main-gap)))) moved (fn [changes child-inst child-main] (shape-log :trace (:id child-inst) container @@ -2192,7 +2302,10 @@ [changes current-shape previous-shape current-root prev-root origin-ref-shape container] (let [;; We need to sync only the position relative to the origin of the component. ;; (see update-attrs for a full explanation) - previous-shape (reposition-shape previous-shape prev-root current-root) + previous-shape (-> previous-shape + (resize-with-parent (get (:objects container) (:parent-id previous-shape)) + (get (pcb/get-objects changes) (:parent-id current-shape))) + (reposition-shape prev-root current-root)) touched (get previous-shape :touched #{}) text-auto? (and (cfh/text-shape? current-shape) (contains? #{:auto-height :auto-width} (:grow-type current-shape)))] diff --git a/common/src/app/common/logic/variants.cljc b/common/src/app/common/logic/variants.cljc index 883a3a40da..79de230b5a 100644 --- a/common/src/app/common/logic/variants.cljc +++ b/common/src/app/common/logic/variants.cljc @@ -4,6 +4,8 @@ [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.variant :as cfv] + [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] [app.common.logic.libraries :as cll] [app.common.logic.shapes :as cls] [app.common.logic.variant-properties :as clvp] @@ -72,7 +74,10 @@ copy that was swapped (orig-swapped-child), and its related shape on the new copy (related-shape-in-new), move the orig-swapped-child into the parent of related-shape-in-new, fix its swap-slot if needed, and then delete - related-shape-in-new" + related-shape-in-new. + + orig-swapped-child follows its parent through its constraints and, if the + user didn't move it, takes the place of related-shape-in-new." [changes related-shape-in-new orig-swapped-child ldata page swap-ref-id] (let [;; Before to the swap, temporary move the previous ;; shape to the root panel to avoid problems when @@ -83,15 +88,28 @@ (pcb/change-parent uuid/zero [orig-swapped-child] 0 {:allow-altering-copies true})) objects (pcb/get-objects changes) + page-objects (:objects page) prev-swap-slot (ctk/get-swap-slot orig-swapped-child) current-parent (get objects (:parent-id related-shape-in-new)) - pos (d/index-of (:shapes current-parent) (:id related-shape-in-new))] + pos (d/index-of (:shapes current-parent) (:id related-shape-in-new)) + + orig-child (get page-objects (:id orig-swapped-child)) + ;; If the user didn't move it, it goes to the place of related-shape-in-new + delta-fn (when-not (ctk/touched-group? orig-child :geometry-group) + #(gpt/subtract (grc/rect->point (:selrect related-shape-in-new)) + (grc/rect->point (:selrect %))))] (-> (pcb/concat-changes before-changes changes) ;; Move the previous shape to the new parent (pcb/change-parent (:parent-id related-shape-in-new) [orig-swapped-child] pos {:allow-altering-copies true}) + (cll/generate-follow-parent (ctn/make-container page :page) + orig-child + (get page-objects (:parent-id orig-child)) + current-parent + delta-fn) + ;; We need to update the swap slot only when it pointed ;; to the swap-ref-id. Oterwise this is a swapped item ;; inside a nested copy, so we need to keep it. diff --git a/common/src/app/common/test_helpers/compositions.cljc b/common/src/app/common/test_helpers/compositions.cljc index a4fa6b748b..e2efb1a170 100644 --- a/common/src/app/common/test_helpers/compositions.cljc +++ b/common/src/app/common/test_helpers/compositions.cljc @@ -10,6 +10,7 @@ [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] [app.common.logic.libraries :as cll] [app.common.logic.shapes :as cls] [app.common.logic.variants :as clv] @@ -443,6 +444,33 @@ :propagate-fn propagate-fn :library library))) +(defn change-shape + "Applies `update-fn` to the shape identified by shape-tag. With + `ignore-touched` the change is not an override, as when a layout changes it." + [file shape-tag update-fn & {:keys [ignore-touched]}] + (let [page (thf/current-page file)] + (thf/apply-changes + file + (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id shape-tag)} + update-fn + (:objects page) + {:ignore-touched ignore-touched})))) + +(defn move-shape-tree + "Moves the shape identified by shape-tag and its children by `delta`. With + `ignore-touched` the move is not an override, as when a layout moves it." + [file shape-tag delta & {:keys [ignore-touched]}] + (let [page (thf/current-page file) + objects (:objects page)] + (thf/apply-changes + file + (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + (cfh/get-children-ids-with-self objects (thi/id shape-tag)) + #(gsh/move % delta) + objects + {:ignore-touched ignore-touched})))) + (defn update-color "Update the first fill color for the shape identified by shape-tag" [file shape-tag color & {:keys [page-label propagate-fn]}] diff --git a/common/test/common_tests/geom_shapes_constraints_test.cljc b/common/test/common_tests/geom_shapes_constraints_test.cljc index 4f6cd3ce4c..081d32325e 100644 --- a/common/test/common_tests/geom_shapes_constraints_test.cljc +++ b/common/test/common_tests/geom_shapes_constraints_test.cljc @@ -7,21 +7,11 @@ (ns common-tests.geom-shapes-constraints-test (:require [app.common.geom.shapes.constraints :as gsc] + [app.common.types.modifiers :as ctm] [clojure.test :as t])) ;; ---- constraint-modifier :default ---- -(t/deftest constraint-modifier-default-returns-empty-vector - (t/testing ":default method accepts 6 args and returns an empty vector" - ;; Before the fix the :default method only accepted 5 positional args - ;; (plus the dispatch value), so calling it with 6 args would throw an - ;; arity error. After the fix it takes [_ _ _ _ _ _] and returns []. - (let [result (gsc/constraint-modifier :unknown-constraint-type - :x nil nil nil nil)] - (t/is (vector? result)) - (t/is (empty? result)))) - - (t/testing ":default method returns [] for :scale-like unknown type on :y axis" - (let [result (gsc/constraint-modifier :some-other-unknown - :y nil nil nil nil)] - (t/is (= [] result))))) +(t/deftest constraint-modifier-default-returns-empty-modifiers + (t/is (= (ctm/empty) + (gsc/constraint-modifier :unknown-constraint-type :x nil nil nil nil)))) diff --git a/common/test/common_tests/logic/comp_flex_interactions_test.cljc b/common/test/common_tests/logic/comp_flex_interactions_test.cljc index 0fa3644c6f..b3a339bc51 100644 --- a/common/test/common_tests/logic/comp_flex_interactions_test.cljc +++ b/common/test/common_tests/logic/comp_flex_interactions_test.cljc @@ -10,7 +10,6 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.logic.libraries :as cll] - [app.common.logic.shapes :as cls] [app.common.math :as mth] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] @@ -19,6 +18,7 @@ [app.common.test-helpers.shapes :as ths] [app.common.types.component :as ctk] [app.common.types.container :as ctn] + [app.common.types.modifiers :as ctm] [clojure.test :as t])) (t/use-fixtures :each thi/test-fixture) @@ -39,17 +39,6 @@ (ths/add-interaction :main-child :main-popup) (ths/add-interaction :copy-child :copy-popup))) -(defn- update-shape - [file label update-fn] - (let [page (thf/current-page file)] - (thf/apply-changes - file - (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) - #{(thi/id label)} - update-fn - (:objects page) - {})))) - (defn- destinations [shape] (mapv :destination (:interactions shape))) @@ -60,8 +49,8 @@ margin {:m1 5 :m2 5 :m3 5 :m4 5} ;; ==== Action - updated-file (update-shape file :main-child - #(assoc % :hidden true :layout-item-margin margin)) + updated-file (tho/change-shape file :main-child + #(assoc % :hidden true :layout-item-margin margin)) changes (cll/generate-sync-file-changes (pcb/empty-changes) nil :components @@ -85,7 +74,7 @@ file (setup-file) ;; ==== Action - updated-file (update-shape file :copy-child #(assoc % :hidden true)) + updated-file (tho/change-shape file :copy-child #(assoc % :hidden true)) page (thf/current-page updated-file) container (ctn/make-container page :page) changes (-> (pcb/empty-changes) @@ -144,7 +133,7 @@ ;; ==== Action library' (-> library - (update-shape :main1-child #(gsh/move % (gpt/point 1 0))) + (tho/change-shape :main1-child #(gsh/move % (gpt/point 1 0))) (tho/propagate-component-changes :component1)) file' (thf/apply-changes file (sync-file-from-library file library')) @@ -190,7 +179,7 @@ :layout :flex :layout-flex-dir :row}) (thc/instantiate-component :icon-a :nested-head {:parent-label :board-root}) - (update-shape :nested-head #(assoc % :layout-item-absolute true)) + (tho/change-shape :nested-head #(assoc % :layout-item-absolute true)) (thc/make-component :board :board-root))) (t/deftest test-sync-library-keeps-swapped-nested-absolute @@ -205,8 +194,8 @@ swapped (ths/get-shape file :copy-nested-head) ;; ==== Action - library' (update-shape library :board-root - #(assoc % :fills (ths/sample-fills-color :fill-color "#fabada"))) + library' (tho/change-shape library :board-root + #(assoc % :fills (ths/sample-fills-color :fill-color "#fabada"))) file' (thf/apply-changes file (sync-file-from-library file library')) swapped' (ths/get-shape file' :copy-nested-head)] @@ -229,8 +218,8 @@ file (swap-to-library-component file library :copy-nested-head :icon-b) ;; ==== Action - library' (update-shape library :nested-head - #(assoc % :layout-item-absolute false)) + library' (tho/change-shape library :nested-head + #(assoc % :layout-item-absolute false)) file' (thf/apply-changes file (sync-file-from-library file library')) swapped' (ths/get-shape file' :copy-nested-head)] @@ -250,7 +239,7 @@ :layout-flex-dir :row}) (thc/instantiate-component :icon-a :nested-head {:parent-label :board-root}) (ths/add-sample-shape :sibling :type :rect :name "Sibling" :parent-label :board-root) - (update-shape :nested-head #(assoc % :layout-item-absolute true)) + (tho/change-shape :nested-head #(assoc % :layout-item-absolute true)) (thc/make-component :board :board-root)) file (-> (thf/sample-file :file) (thc/instantiate-component :board @@ -260,8 +249,8 @@ file (swap-to-library-component file library :copy-nested-head :icon-b) ;; ==== Action - library' (update-shape library :sibling - #(assoc % :layout-item-margin margin)) + library' (tho/change-shape library :sibling + #(assoc % :layout-item-margin margin)) file' (thf/apply-changes file (sync-file-from-library file library')) sibling' (ths/get-shape file' :copy-sibling) swapped' (ths/get-shape file' :copy-nested-head)] @@ -270,3 +259,212 @@ (t/is (= margin (:layout-item-margin sibling'))) (t/is (true? (:layout-item-absolute swapped'))) (t/is (some? (ctk/get-swap-slot swapped'))))) + +(defn- setup-library-with-icon-slot + "Library: icon A, icon B, and a flex board whose `:slot` frame holds an + instance of icon A. The slot has a flex layout and the icon is absolute; + with `:layout? false` the slot has no layout and the icon is a plain child." + [& {:keys [layout?] :or {layout? true}}] + (-> (thf/sample-file :library) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child) + (tho/add-frame :board-root {:name "Board" + :layout :flex + :layout-flex-dir :row}) + (tho/add-frame :slot (cond-> {:name "Slot" :parent-label :board-root} + layout? (assoc :layout :flex :layout-flex-dir :row))) + (thc/instantiate-component :icon-a :nested-head {:parent-label :slot}) + (cond-> layout? (tho/change-shape :nested-head #(assoc % :layout-item-absolute true))) + (thc/make-component :board :board-root))) + +(defn- instantiate-board + "File with a copy of the library board, labelling its slot and nested icon." + [library] + (let [file (-> (thf/sample-file :file) + (thc/instantiate-component :board + :copy-board + :library library + :children-labels [:copy-slot])) + slot (ths/get-shape file :copy-slot)] + (thi/set-id! :copy-nested-head (first (:shapes slot))) + file)) + +(defn- offset-in-slot + [file slot-label icon-label] + (let [slot (ths/get-shape file slot-label) + icon (ths/get-shape file icon-label)] + (gpt/point (- (:x icon) (:x slot)) + (- (:y icon) (:y slot))))) + +(defn- sync-after-unrelated-change + [file library root-label] + (let [library' (tho/change-shape library root-label + #(assoc % :fills (ths/sample-fills-color :fill-color "#fabada")))] + (thf/apply-changes file (sync-file-from-library file library')))) + +(t/deftest test-sync-places-swapped-child-in-moved-slot + (let [;; ==== Setup + library (setup-library-with-icon-slot) + file (-> (instantiate-board library) + (swap-to-library-component library :copy-nested-head :icon-b) + ;; The layout moves the slot, and a previous sync moved the icon away + (tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true) + (tho/move-shape-tree :copy-nested-head (gpt/point 800 0) :ignore-touched true)) + + ;; ==== Action + file' (sync-after-unrelated-change file library :board-root)] + + ;; ==== Check + (t/is (gpt/close? (offset-in-slot library :slot :nested-head) + (offset-in-slot file' :copy-slot :copy-nested-head))) + (t/is (true? (:layout-item-absolute (ths/get-shape file' :copy-nested-head)))))) + +(t/deftest test-sync-places-swapped-child-in-moved-slot-without-layout + (let [;; ==== Setup + library (setup-library-with-icon-slot :layout? false) + file (-> (instantiate-board library) + (swap-to-library-component library :copy-nested-head :icon-b) + ;; The layout moves the slot, and a previous sync moved the icon away + (tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true) + (tho/move-shape-tree :copy-nested-head (gpt/point 800 0) :ignore-touched true)) + + ;; ==== Action + file' (sync-after-unrelated-change file library :board-root)] + + ;; ==== Check + (t/is (gpt/close? (offset-in-slot library :slot :nested-head) + (offset-in-slot file' :copy-slot :copy-nested-head))))) + +(t/deftest test-sync-keeps-moved-child-in-moved-slot + (let [;; ==== Setup + library (setup-library-with-icon-slot) + file (-> (instantiate-board library) + (tho/move-shape-tree :copy-nested-head (gpt/point 5 0)) + (tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true)) + before (offset-in-slot file :copy-slot :copy-nested-head) + + ;; ==== Action + file' (sync-after-unrelated-change file library :board-root)] + + ;; ==== Check + (t/is (gpt/close? before (offset-in-slot file' :copy-slot :copy-nested-head))))) + +(t/deftest test-sync-keeps-user-moved-swapped-child-in-moved-slot + (let [;; ==== Setup + library (setup-library-with-icon-slot) + file (-> (instantiate-board library) + (swap-to-library-component library :copy-nested-head :icon-b) + (tho/move-shape-tree :copy-nested-head (gpt/point 5 0)) + (tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true)) + before (offset-in-slot file :copy-slot :copy-nested-head) + + ;; ==== Action + file' (sync-after-unrelated-change file library :board-root)] + + ;; ==== Check + (t/is (gpt/close? before (offset-in-slot file' :copy-slot :copy-nested-head))))) + +(defn- with-shapes-first + "Puts the shapes with the given labels first in the page objects, in that + order. The sync visits the shapes of a page in this order." + [file labels] + (let [page-id (:id (thf/current-page file)) + ids (mapv thi/id labels)] + (update-in file [:data :pages-index page-id :objects] + (fn [objects] + (apply array-map (concat (mapcat #(vector % (get objects %)) ids) + (mapcat identity (apply dissoc objects ids)))))))) + +(t/deftest test-sync-places-swapped-child-where-main-slot-occupant-is + (let [;; ==== Setup + library (setup-library-with-icon-slot) + file (-> (instantiate-board library) + (swap-to-library-component library :copy-nested-head :icon-b) + ;; The copy comes before its swapped icon, which the sync also syncs on its own + (with-shapes-first [:copy-board :copy-nested-head])) + + ;; ==== Action + library' (-> library + (tho/move-shape-tree :nested-head (gpt/point 4 4)) + (tho/change-shape :icon-b-root #(gsh/transform-shape % (ctm/change-size % 32 nil)))) + file' (thf/apply-changes file (sync-file-from-library file library')) + + ;; ==== Get + icon' (ths/get-shape file' :copy-nested-head)] + + ;; ==== Check + (t/is (gpt/close? (offset-in-slot library' :slot :nested-head) + (offset-in-slot file' :copy-slot :copy-nested-head))) + (t/is (mth/close? (:width (ths/get-shape library' :icon-b-root)) (:width icon'))) + (t/is (mth/close? (:x icon') (-> icon' :selrect :x))))) + +(defn- setup-library-with-root-icon + "Library: icon A, and a 300px wide flex card whose `:close` child, placed + directly in the root, is an absolute instance of icon A with the given + horizontal constraint." + [constraints-h] + (-> (thf/sample-file :library) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child + :root-params {:width 24 :height 24} + :child-params {:width 24 :height 24}) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child + :root-params {:width 24 :height 24} + :child-params {:width 24 :height 24}) + (tho/add-frame :card-root {:name "Card" + :layout :flex + :layout-flex-dir :row + :width 300 + :height 48}) + (thc/instantiate-component :icon-a :close {:parent-label :card-root}) + (tho/change-shape :close #(assoc % :layout-item-absolute true :constraints-h constraints-h)) + (thc/make-component :card :card-root))) + +(defn- instantiate-filled-card + "File with a copy of the card, widened by 800px and with its close icon + moved by `icon-shift`, the way the layout does when the copy fills a wider + parent." + [library icon-shift] + (-> (thf/sample-file :file) + (thc/instantiate-component :card + :copy-card + :library library + :children-labels [:copy-close]) + (tho/change-shape :copy-card + #(gsh/transform-shape % (ctm/change-size % (+ (:width %) 800) nil)) + :ignore-touched true) + (tho/move-shape-tree :copy-close (gpt/point icon-shift 0) :ignore-touched true))) + +(t/deftest test-sync-moved-root-child-follows-root-through-right-constraint + (let [;; ==== Setup + library (setup-library-with-root-icon :right) + file (-> (instantiate-filled-card library 800) + (tho/move-shape-tree :copy-close (gpt/point 5 0))) + + ;; ==== Action + file' (sync-after-unrelated-change file library :card-root)] + + ;; ==== Check + (t/is (gpt/close? (gpt/add (offset-in-slot library :card-root :close) (gpt/point 5 0)) + (offset-in-slot file' :copy-card :copy-close))))) + +(t/deftest test-sync-scales-swapped-root-child-back-with-root + (let [;; ==== Setup + library (setup-library-with-root-icon :scale) + file (-> (instantiate-filled-card library 0) + (swap-to-library-component library :copy-close :icon-b) + ;; The swap drops the constraints; set them again so this path runs + (tho/change-shape :copy-close #(assoc % :constraints-h :scale))) + + ;; ==== Action + file' (sync-after-unrelated-change file library :card-root) + + ;; ==== Get + scale (/ (:width (ths/get-shape file' :copy-card)) + (:width (ths/get-shape file :copy-card))) + inner #(ths/get-shape-by-id % (first (:shapes (ths/get-shape % :copy-close))))] + + ;; ==== Check + (t/is (mth/close? (* scale (:width (ths/get-shape file :copy-close))) + (:width (ths/get-shape file' :copy-close)))) + ;; The icon content follows the icon through its own (left) constraint + (t/is (mth/close? (:width (inner file)) (:width (inner file')))))) diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index 06e8b5ac34..acaa4faf06 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -12,6 +12,7 @@ [app.common.geom.shapes :as gsh] [app.common.logic.libraries :as cll] [app.common.logic.shapes :as cls] + [app.common.math :as mth] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] @@ -19,6 +20,7 @@ [app.common.test-helpers.shapes :as ths] [app.common.test-helpers.variants :as thv] [app.common.types.component :as ctk] + [app.common.types.modifiers :as ctm] [clojure.test :as t])) (t/use-fixtures :each thi/test-fixture) @@ -3181,3 +3183,145 @@ (t/is (= (get-in synced-t font-size-path-0) "30")) (t/is (contains? (:touched synced-t) :content-group)) (t/is (contains? (:touched synced-t) :text-content-text)))))) + +;; ============================================================ +;; SWAPPED CHILD POSITION +;; ============================================================ + +(defn- setup-variant-with-swapped-icon + "Variants whose roots hold an icon A copy, placed 8px lower in the second + variant, and a copy of the first variant with its icon swapped to icon B." + [] + (-> (thf/sample-file :file1) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child) + (thv/add-variant-with-copy :v01 :c01 :m01 :c02 :m02 :m01-icon :m02-icon :icon-a) + (tho/move-shape-tree :m02-icon (gpt/point 0 8)) + (thc/instantiate-component :c01 :copy01 :children-labels [:copy-icon]) + (thc/component-swap :copy-icon :icon-b :copy-icon-b))) + +(defn- offset-in-root + [file root-label child-label] + (let [root (ths/get-shape file root-label) + child (ths/get-shape file child-label)] + (gpt/point (- (:x child) (:x root)) + (- (:y child) (:y root))))) + +(t/deftest test-switch-places-swapped-child-where-new-variant-occupant-is + (let [;; ==== Setup + file (setup-variant-with-swapped-icon) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 + :keep-touched? true})] + + ;; ==== Check + (t/is (gpt/close? (offset-in-root file :m02 :m02-icon) + (offset-in-root file' :copy02 :copy-icon-b))))) + +(defn- resize-width + [shape width] + (gsh/transform-shape shape (ctm/change-size shape width nil))) + +(defn- setup-card-copy + "Variants of a 300x48 flex card whose `:m01-icon` / `:m02-icon` is an + absolute icon A copy placed directly in the root at x 270, with the given + horizontal constraint. A copy of the first variant fills 800px more, the + way the layout grows it." + [constraints-h] + (let [place-icon (fn [file root-label icon-label] + (let [root (ths/get-shape file root-label) + icon (ths/get-shape file icon-label)] + (-> file + (tho/move-shape-tree icon-label (gpt/point (- (+ (:x root) 270) (:x icon)) + (- (+ (:y root) 12) (:y icon)))) + (tho/change-shape icon-label #(assoc % :layout-item-absolute true + :constraints-h constraints-h))))) + card (fn [file root-label] + (tho/change-shape file root-label #(-> % + (resize-width 300) + (gsh/transform-shape (ctm/change-size % nil 48)) + (assoc :layout :flex :layout-flex-dir :row))))] + (-> (thf/sample-file :file1) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child + :root-params {:width 24 :height 24} + :child-params {:width 24 :height 24}) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child + :root-params {:width 24 :height 24} + :child-params {:width 24 :height 24}) + (thv/add-variant-with-copy :v01 :c01 :m01 :c02 :m02 :m01-icon :m02-icon :icon-a) + (card :m01) + (card :m02) + (place-icon :m01 :m01-icon) + (place-icon :m02 :m02-icon) + (thc/instantiate-component :c01 :copy01 :children-labels [:copy-icon]) + (tho/change-shape :copy01 #(resize-width % 1100) :ignore-touched true) + (tho/move-shape-tree :copy-icon (gpt/point (case constraints-h :right 800 0) 0) :ignore-touched true)))) + +(defn- setup-card-variant + "Same as `setup-card-copy`, with the icon of the copy swapped to icon B." + [constraints-h] + (-> (setup-card-copy constraints-h) + (thc/component-swap :copy-icon :icon-b :copy-icon-b) + ;; The swap drops the constraints; set them again so this path runs + (tho/change-shape :copy-icon-b #(assoc % :constraints-h constraints-h)))) + +(defn- distance-to-right-edge + [file root-label child-label] + (let [root (ths/get-shape file root-label) + child (ths/get-shape file child-label)] + (- (+ (:x root) (:width root)) (:x child)))) + +(t/deftest test-switch-moved-swapped-child-follows-right-constraint + (let [;; ==== Setup + file (-> (setup-card-variant :right) + (tho/move-shape-tree :copy-icon-b (gpt/point 5 0))) + before (distance-to-right-edge file :copy01 :copy-icon-b) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 + :keep-touched? true})] + + ;; ==== Check + (t/is (mth/close? before (distance-to-right-edge file' :copy02 :copy-icon-b))))) + +(t/deftest test-switch-scales-swapped-child-back-with-root + (let [;; ==== Setup + file (setup-card-variant :scale) + width (:width (ths/get-shape file :copy-icon-b)) + root-w (:width (ths/get-shape file :copy01)) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 + :keep-touched? true}) + scale (/ (:width (ths/get-shape file' :copy02)) root-w) + inner #(ths/get-shape-by-id % (first (:shapes (ths/get-shape % :copy-icon-b))))] + + ;; ==== Check + (t/is (mth/close? (* width scale) + (:width (ths/get-shape file' :copy-icon-b)))) + ;; The icon content follows the icon through its own (left) constraint + (t/is (mth/close? (:width (inner file)) (:width (inner file')))))) + +(t/deftest test-switch-scales-moved-child-back-with-root + (let [;; ==== Setup + card (setup-card-copy :scale) + grow (/ (:width (ths/get-shape card :copy01)) + (:width (ths/get-shape card :m01))) + file (-> card + ;; The layout scales the icon with the copy + (tho/change-shape :copy-icon #(resize-width % (* grow (:width %))) :ignore-touched true) + (tho/move-shape-tree :copy-icon (gpt/point 5 0))) + width (:width (ths/get-shape file :copy-icon)) + root-w (:width (ths/get-shape file :copy01)) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 + :keep-touched? true}) + + ;; ==== Get + copy02' (ths/get-shape file' :copy02) + icon' (get-in (thf/current-page file') [:objects (-> copy02' :shapes first)])] + + ;; ==== Check + (t/is (mth/close? (* width (/ (:width copy02') root-w)) (:width icon'))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index 659aea2fb3..6a2aed7599 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -506,30 +506,37 @@ :title "Align self end" :id "align-self-end"}]]) +;; A multiple selection gives nil for missing attrs and :multiple for mixed ones +(def ^:private schema:number-value + [:maybe [:or :float :int [:= :multiple]]]) + (def ^:private schema:layout-item-props-schema [:map [:layout-item-margin {:optional true} - [:map - [:m1 {:optional true} [:or :float :int]] - [:m2 {:optional true} [:or :float :int]] - [:m3 {:optional true} [:or :float :int]] - [:m4 {:optional true} [:or :float :int]]]] + [:maybe + [:or + [:= :multiple] + [:map + [:m1 {:optional true} schema:number-value] + [:m2 {:optional true} schema:number-value] + [:m3 {:optional true} schema:number-value] + [:m4 {:optional true} schema:number-value]]]]] - [:layout-item-margin-type {:optional true} :keyword] + [:layout-item-margin-type {:optional true} [:maybe :keyword]] - [:layout-item-h-sizing {:optional true} :keyword] - [:layout-item-v-sizing {:optional true} :keyword] + [:layout-item-h-sizing {:optional true} [:maybe :keyword]] + [:layout-item-v-sizing {:optional true} [:maybe :keyword]] - [:layout-item-min-w {:optional true} [:or :float :int]] - [:layout-item-max-w {:optional true} [:or :float :int]] - [:layout-item-min-h {:optional true} [:or :float :int]] - [:layout-item-max-h {:optional true} [:or :float :int]]]) + [:layout-item-min-w {:optional true} schema:number-value] + [:layout-item-max-w {:optional true} schema:number-value] + [:layout-item-min-h {:optional true} schema:number-value] + [:layout-item-max-h {:optional true} schema:number-value]]) (def ^:private schema:layout-size-constraints [:map [:values schema:layout-item-props-schema] - [:applied-tokens [:maybe [:map-of :keyword :string]]] + [:applied-tokens [:maybe [:map-of :keyword [:maybe [:or :string [:= :multiple]]]]]] [:ids [::sm/vec ::sm/uuid]]]) (mf/defc layout-size-constraints* From 66f1f689f8f6064c0a0a2c916d6361e64b951776 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 18:09:35 +0100 Subject: [PATCH 50/65] :recycle: Use the DS select for the gradient type in the colorpicker (#12653) * :recycle: Use the DS select for the gradient type in the colorpicker The Linear / Radial select of the gradient options used the legacy select. It now uses select* with string ids, converted back to the gradient type keyword in the handler. Visible changes: the DS select look (label 1px higher, DS list with taller rows and the DS check mark, opening 2px lower). The list still opens inside the colorpicker, so it needs no portal. The "Create a RADIAL gradient" e2e test picks the type by role instead of a DOM position that only matched the legacy markup. New ds visual test "Gradient type select": closed, hover, open and changed. AI-assisted-by: claude-opus-5-5 * :recycle: Move colorpicker gradient styles off the deprecated styles gradients.scss now uses DS spacing, sizes, borders, typography and logical properties, and resolves the --menu-shadow-color alias. The deprecated import stays only for %input-element: the stop offset input moves to the DS with the numeric inputs group. The offset input uses body-small (line-height 1.3 instead of 1.4); the input keeps its height, so nothing moves. 0px difference against the previous commit, including a new shot of the whole gradient panel. gradients.cljs uses an icon-text class that has no rule in this file. AI-assisted-by: claude-opus-5-5 --- .../playwright/ui/specs/colorpicker.spec.js | 15 ++- .../ui/visual-specs/workspace.spec.js | 55 ++++++++++ .../ui/workspace/colorpicker/gradients.cljs | 20 ++-- .../ui/workspace/colorpicker/gradients.scss | 100 ++++++++++-------- 4 files changed, 126 insertions(+), 64 deletions(-) diff --git a/frontend/playwright/ui/specs/colorpicker.spec.js b/frontend/playwright/ui/specs/colorpicker.spec.js index ee9dbcd770..6625cb4b8a 100644 --- a/frontend/playwright/ui/specs/colorpicker.spec.js +++ b/frontend/playwright/ui/specs/colorpicker.spec.js @@ -144,15 +144,14 @@ test("Create a RADIAL gradient", async ({ page }) => { const gradOption = workspacePage.page.getByText("Gradient"); await gradOption.click(); - const gradTypeOptions = workspacePage.colorpicker - .locator("div") - .filter({ hasText: "Linear" }) - .nth(3); - await gradTypeOptions.click(); + const gradTypeSelect = workspacePage.colorpicker + .getByRole("combobox") + .filter({ hasText: "Linear" }); + await gradTypeSelect.click(); - const gradRadialOption = workspacePage.page - .locator("li") - .filter({ hasText: "Radial" }); + const gradRadialOption = workspacePage.page.getByRole("option", { + name: "Radial", + }); await gradRadialOption.click(); const addStopBtn = workspacePage.page.getByRole("button", { diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 693d10ecff..d831b30a07 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -1249,3 +1249,58 @@ test.describe("Comments panel filter", () => { await expect(menu).toHaveScreenshot(shot); }); }); + +test.describe("Gradient type select", () => { + test("Shows the gradient type select closed, open and changed", async ({ + page, + }) => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-not-empty.json"); + await workspace.goToWorkspace({ + fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374", + pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375", + }); + await workspace.clickLeafLayer("Rectangle"); + await page.getByRole("button", { name: "#B1B2B5" }).click(); + await page.getByText("Solid").click(); + await page.getByText("Gradient").click(); + + const typeSelect = workspace.colorpicker + .getByRole("combobox") + .filter({ hasText: "Linear" }); + await expect(typeSelect).toBeVisible(); + const pickerBox = await workspace.colorpicker.boundingBox(); + const selectBox = await typeSelect.boundingBox(); + // The gradient bar above the select and room below it for the open list. + const clip = { + x: Math.floor(pickerBox.x), + y: Math.floor(selectBox.y) - 60, + width: Math.ceil(pickerBox.width), + height: 200, + }; + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + await typeSelect.hover(); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + await typeSelect.click(); + const radial = page.getByRole("option", { name: "Radial" }); + await expect(radial).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + await radial.click(); + await expect( + workspace.colorpicker.getByRole("combobox").filter({ hasText: "Radial" }), + ).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + // The whole gradient panel: preview bar, stops and the stop list. + const panel = workspace.colorpicker.locator("[class*='gradient-panel']"); + await expect(panel).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs index 8338544f73..e1817621c6 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs @@ -15,8 +15,8 @@ [app.main.features :as features] [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.reorder-handler :refer [reorder-handler*]] - [app.main.ui.components.select :refer [select]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.select :refer [select*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.formats :as fmt] [app.main.ui.hooks :as h] @@ -167,6 +167,10 @@ :on-focus handle-focus-stop-color :on-blur handle-blur-stop-color}]])) +(def ^:private gradient-type-options + [{:id "linear-gradient" :label "Linear"} + {:id "radial-gradient" :label "Radial"}]) + (mf/defc gradients* [{:keys [type stops @@ -200,9 +204,9 @@ handle-change-type (mf/use-callback (mf/deps on-change-type) - (fn [event] + (fn [type-id] (when on-change-type - (on-change-type event)))) + (on-change-type (keyword type-id))))) handle-add-stop (mf/use-callback @@ -336,12 +340,10 @@ :style {"--position" (dm/str (* offset 100) "%")}}]])]] [:div {:class (stl/css :gradient-options)} - [:& select - {:default-value type - :options [{:value :linear-gradient :label "Linear"} - {:value :radial-gradient :label "Radial"}] - :on-change handle-change-type - :class (stl/css :gradient-options-select)}] + [:> select* {:default-selected (d/name (d/nilv type :linear-gradient)) + :options gradient-type-options + :on-change handle-change-type + :wrapper-class (stl/css :gradient-options-select)}] [:div {:class (stl/css :gradient-options-buttons)} [:> icon-button* {:variant "ghost" diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss index 0ce4019fd4..038e326ea4 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss @@ -4,18 +4,24 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for %input-element: the stop offset input moves to the DS with the +// numeric inputs group. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .gradient-panel { - margin-top: deprecated.$s-12; + margin-block-start: var(--sp-m); display: grid; - gap: deprecated.$s-4; - grid-template-rows: deprecated.$s-56 deprecated.$s-32 1fr; + gap: var(--sp-xs); + grid-template-rows: px2rem(56) $sz-32 1fr; } .gradient-preview { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; display: flex; align-items: center; position: relative; @@ -23,19 +29,19 @@ } .gradient-background { - width: 100%; - height: deprecated.$s-20; - border-radius: deprecated.$br-4; + inline-size: 100%; + block-size: px2rem(20); + border-radius: $br-4; position: relative; cursor: pointer; } .gradient-preview-stop-wrapper { position: absolute; - width: calc(100% - deprecated.$s-24 - deprecated.$s-4); - height: 100%; - left: deprecated.$s-2; - top: calc(-1 * deprecated.$s-4); + inline-size: calc(100% - #{px2rem(28)}); + block-size: 100%; + inset-inline-start: px2rem(2); + inset-block-start: calc(-1 * var(--sp-xs)); pointer-events: none; } @@ -43,47 +49,47 @@ background-color: var(--color-foreground-primary); background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAADFJREFUOE9jZGBgEAFifOANPknGUQMYhkkYEEgG+NMJKAwIAbwJbdQABnBCIgRoG4gAIF8IsXB/Rs4AAAAASUVORK5CYII="); background-position: left center; - background-size: deprecated.$s-8; - border-radius: deprecated.$br-6; - border: deprecated.$s-2 solid var(--color-foreground-primary); - box-shadow: 0 0 deprecated.$s-4 0 var(--menu-shadow-color); - height: calc(deprecated.$s-24 - deprecated.$s-2); - left: var(--position); + background-size: px2rem(8); + border-radius: $br-6; + border: $b-2 solid var(--color-foreground-primary); + box-shadow: 0 0 px2rem(4) 0 var(--color-shadow-dark); + block-size: px2rem(22); + inset-inline-start: var(--position); overflow: hidden; pointer-events: initial; position: absolute; - width: calc(deprecated.$s-24 - deprecated.$s-2); + inline-size: px2rem(22); &.is-selected, &:hover { - outline: deprecated.$s-2 solid var(--color-accent-primary); + outline: $b-2 solid var(--color-accent-primary); } } .gradient-preview-stop-decoration { background: var(--color-foreground-primary); border-radius: 100%; - bottom: deprecated.$s-32; - box-shadow: 0 0 deprecated.$s-4 0 var(--menu-shadow-color); - height: deprecated.$s-4; - left: calc(var(--position) + deprecated.$s-8); + inset-block-end: var(--sp-xxxl); + box-shadow: 0 0 px2rem(4) 0 var(--color-shadow-dark); + block-size: px2rem(4); + inset-inline-start: calc(var(--position) + var(--sp-s)); position: absolute; - width: deprecated.$s-4; + inline-size: px2rem(4); } .gradient-preview-stop-color { position: absolute; - left: 0; - width: 50%; - height: 100%; + inset-inline-start: 0; + inline-size: 50%; + block-size: 100%; background: var(--color-solid); } .gradient-preview-stop-alpha { position: absolute; - left: 50%; - width: 50%; - height: 100%; + inset-inline-start: 50%; + inline-size: 50%; + block-size: 100%; background: var(--color-alpha); } @@ -98,7 +104,7 @@ } .gradient-options-select { - width: deprecated.$s-140; + inline-size: px2rem(140); } .rotate-icon { @@ -108,18 +114,18 @@ .gradient-stops-list { display: flex; flex-direction: column; - gap: deprecated.$s-4; - max-height: deprecated.$s-180; + gap: var(--sp-xs); + max-block-size: px2rem(180); overflow: hidden auto; padding: 0 0 var(--sp-s) var(--sp-m); } .gradient-stops-entry { display: flex; - gap: deprecated.$s-4; - padding: deprecated.$s-2; - border-radius: deprecated.$br-12; - border: deprecated.$s-1 solid transparent; + gap: var(--sp-xs); + padding: var(--sp-xxs); + border-radius: $br-12; + border: $b-1 solid transparent; position: relative; &.is-selected { @@ -141,26 +147,26 @@ .offset-input-wrapper { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - width: deprecated.$s-92; + inline-size: px2rem(92); } .gradient-separator { border-color: var(--color-background-quaternary); - border-width: deprecated.$s-3; - margin-left: calc(var(--sp-m) / 2); + border-width: px2rem(3); + margin-inline-start: calc(var(--sp-m) / 2); position: relative; - width: calc(100% - var(--sp-m)); + inline-size: calc(100% - var(--sp-m)); } .gradient-preview-stop-preview { background: var(--color-foreground-primary); border-radius: 50%; - height: deprecated.$s-4; - left: calc(var(--preview-position, 0%) - deprecated.$s-2); + block-size: px2rem(4); + inset-inline-start: calc(var(--preview-position, 0%) - #{px2rem(2)}); pointer-events: none; position: absolute; - top: calc(50% - deprecated.$s-2); - width: deprecated.$s-4; + inset-block-start: calc(50% - #{px2rem(2)}); + inline-size: px2rem(4); } From 755369276d2faf2e2f4c6294eaccd8e7837e3d54 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 18:09:46 +0100 Subject: [PATCH 51/65] :bug: Fix stack overflow when applying a library update (#12661) * :bug: Fix stack overflow when applying a library update Each shape updated by a component sync added its reg-objects undo change with (update :undo-changes concat [...]). That wrapped the whole undo list in one more lazy concat per shape, with the old list at the head. After a few thousand shapes, walking the list overflowed the stack, which matches the nested LazySeq frames reported when a library update is applied. add-update-attr-changes now conjs the reg-objects before the mod-obj, so undo restores each shape and then recalculates its parents, as redo does. The trailing reg-objects also recalculated groups after undoing shapes that the sync added or removed. To keep that, add-shape-to-instance, add-shape-to-main and remove-shape now add their own undo reg-objects, conjed before their other undo changes so it runs after them. Tests cover many calls to add-update-attr-changes, a sync and undo of many shapes, and undoing a sync that moves, adds or removes a child of a group in the copy. Closes #12576 AI-assisted-by: claude-opus-5-5 * :bug: Test undo of an inverse sync that adds a shape The previous commit added an undo :reg-objects to add-shape-to-main, so undoing an "update main component" recalculates the main's groups after removing the added shapes. No test covered it. The new test adds a shape at the start of a group in the copy and changes a sibling's fill, runs the inverse sync and undoes it. Without the undo reg, the main group keeps the bounds of the removed shape. AI-assisted-by: claude-opus-5-5 --- common/src/app/common/logic/libraries.cljc | 40 ++- .../common_tests/logic/comp_sync_test.cljc | 256 ++++++++++++++++++ 2 files changed, 287 insertions(+), 9 deletions(-) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index ac5a516156..f11596677e 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -1338,6 +1338,12 @@ container {:type :reg-objects :shapes all-parents})) + ;; Conj reg before the deletions, so undo recalculates the parents + ;; after removing the shapes. + changes' (update changes' :undo-changes conj (make-change + container + {:type :reg-objects + :shapes all-parents})) changes' (reduce del-obj-change changes' new-shapes)] (if (and (ctk/touched-group? parent-shape :shapes-group) omit-touched?) @@ -1444,6 +1450,12 @@ changes' (update changes' :redo-changes conj {:type :reg-objects :component-id (:id component) :shapes all-parents}) + ;; Conj reg before the other undo changes, so undo recalculates the + ;; parents after removing the shapes. + changes' (update changes' :undo-changes conj (make-change + component-container + {:type :reg-objects + :shapes all-parents})) changes' (reduce mod-obj-change changes' updated-shapes) changes' (reduce del-obj-change changes' new-shapes)] @@ -1491,6 +1503,12 @@ changes' (-> (reduce add-redo-change changes ids) (update :redo-changes conj (make-change + container + {:type :reg-objects + :shapes (vec parents)})) + ;; Conj reg before the additions, so undo recalculates + ;; the parents after restoring the shapes. + (update :undo-changes conj (make-change container {:type :reg-objects :shapes (vec parents)}))) @@ -1693,15 +1711,19 @@ container {:type :reg-objects :shapes all-parents})) - (update :undo-changes conj (make-change - container - {:type :mod-obj - :id (:id dest-shape) - :operations (vec uoperations)})) - (update :undo-changes concat [(make-change - container - {:type :reg-objects - :shapes all-parents})]) + ;; Conj reg before mod, so undo restores the shape and then its + ;; parents. Do not append with concat: it nests one lazy seq per + ;; shape and overflows the stack on big syncs. + (update :undo-changes conj + (make-change + container + {:type :reg-objects + :shapes all-parents}) + (make-change + container + {:type :mod-obj + :id (:id dest-shape) + :operations (vec uoperations)})) (pcb/apply-changes-local)))) diff --git a/common/test/common_tests/logic/comp_sync_test.cljc b/common/test/common_tests/logic/comp_sync_test.cljc index a929c7a616..02cd219779 100644 --- a/common/test/common_tests/logic/comp_sync_test.cljc +++ b/common/test/common_tests/logic/comp_sync_test.cljc @@ -18,6 +18,7 @@ [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] [app.common.types.component :as ctk] + [app.common.types.container :as ctn] [app.common.types.shape-tree :as ctst] [clojure.test :as t])) @@ -539,3 +540,258 @@ ;; A position-only change in the main component must not propagate to copies ;; and therefore must produce no redo-changes. (t/is (empty? (:redo-changes sync-changes))))) + +(defn- sync-file-changes + [file] + (cll/generate-sync-file-changes (pcb/empty-changes) + nil + :components + (:id file) + (thi/id :component1) + (:id file) + {(:id file) file} + (:id file))) + +(t/deftest test-sync-many-updated-shapes + ;; The undo changes of a sync used to grow one lazy level per updated + ;; shape (#12576). Overflowing the stack needs thousands of shapes, which + ;; is too slow here; test-update-attr-changes-many-calls covers that. + (let [;; ==== Setup + n 500 + child-labels (mapv #(keyword (str "main-child-" %)) (range n)) + file (-> (thf/sample-file :file1) + (tho/add-component-with-many-children-and-copy + :component1 + :main-root + child-labels + :copy-root + :main-child-params-list (repeat n {:fills (ths/sample-fills-color + :fill-color "#abcdef")}))) + page (thf/current-page file) + + ;; ==== Action + changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + (into #{} (map thi/id) child-labels) + (fn [shape] + (assoc shape :fills (ths/sample-fills-color :fill-color "#fabada"))) + (:objects page) + {}) + updated-file (thf/apply-changes file changes1) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-fill (fn [file] + (let [copy-root (ths/get-shape file :copy-root)] + (into #{} + (map #(-> (ths/get-shape-by-id file %) :fills first :fill-color)) + (:shapes copy-root))))] + + ;; ==== Check + (t/is (= #{"#fabada"} (copy-fill file'))) + (t/is (= #{"#abcdef"} (copy-fill file''))))) + +(t/deftest test-update-attr-changes-many-calls + ;; Regression: each call nested the undo list in one more lazy concat, so + ;; walking it after many calls overflowed the stack (#12576). + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (tho/add-rect :rect1)) + page (thf/current-page file) + rect (ths/get-shape file :rect1) + roperations [{:type :set :attr :opacity :val 0.5 :ignore-touched true}] + uoperations (list {:type :set :attr :opacity :val 1 :ignore-touched true}) + + ;; ==== Action + changes (reduce (fn [changes _] + (#'cll/add-update-attr-changes changes rect page roperations uoperations)) + (pcb/empty-changes) + (range 50000)) + undo (:undo-changes changes)] + + ;; ==== Check + (t/is (= 100000 (count undo))) + ;; Undo restores the shape first and then recalculates its parents. + (t/is (= [:mod-obj :reg-objects] (map :type (take 2 undo)))))) + +(defn- geometry + [shape] + ;; Compare as doubles: a recalculated selrect has doubles where the + ;; original one may have longs. + {:selrect (update-vals (select-keys (:selrect shape) [:x :y :width :height]) double) + :points (mapv #(update-vals (select-keys % [:x :y]) double) (:points shape))}) + +(defn- add-group-component-with-copy + [file] + ;; Generated shape tree: + ;; :main-root [:name Frame1] # [Component :component1] + ;; :main-group [:name Group1] + ;; :main-child1 [:name Rect1] + ;; :main-child2 [:name Rect2] + ;; + ;; :copy-root [:name Frame1] #--> [Component :component1] :main-root + ;; :copy-group [:name Group1] ---> :main-group + ;; :copy-child1 [:name Rect1] ---> :main-child1 + ;; :copy-child2 [:name Rect2] ---> :main-child2 + (-> file + (tho/add-frame :main-root :x 0 :y 0 :width 100 :height 100) + (tho/add-group :main-group :parent-label :main-root :x 10 :y 10 :width 30 :height 10) + (ths/add-sample-shape :main-child1 :parent-label :main-group :name "Rect1" + :x 10 :y 10 :width 10 :height 10 + :fills (ths/sample-fills-color :fill-color "#abcdef")) + (ths/add-sample-shape :main-child2 :parent-label :main-group :name "Rect2" + :x 30 :y 10 :width 10 :height 10 + :fills (ths/sample-fills-color :fill-color "#abcdef")) + (thc/make-component :component1 :main-root) + (thc/instantiate-component :component1 :copy-root + :children-labels [:copy-group :copy-child1 :copy-child2]))) + +(defn- touch-copy-group-geometry + ;; With its geometry touched, the sync does not copy the group's own + ;; geometry from the main, so only the parents recalculation decides it. + [file] + (reduce #(ths/update-shape %1 %2 :touched #{:geometry-group}) + file + [:copy-group :copy-child1 :copy-child2])) + +(defn- update-fill + [file label color] + (let [page (thf/current-page file) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id label)} + #(assoc % :fills (ths/sample-fills-color :fill-color color)) + (:objects page) + {})] + (thf/apply-changes file changes))) + +(t/deftest test-sync-undo-restores-group-geometry + (let [;; ==== Setup + file (add-group-component-with-copy (thf/sample-file :file1)) + page (thf/current-page file) + copy-group (ths/get-shape file :copy-group) + + ;; ==== Action + changes1 (-> (pcb/empty-changes nil (:id page)) + (pcb/with-objects (:objects page)) + (cls/generate-update-shapes #{(thi/id :main-child2)} + #(gsh/absolute-move % (gpt/point 60 60)) + (:objects page) + {}) + (pcb/resize-parents [(thi/id :main-child2)])) + updated-file (thf/apply-changes file changes1) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-group' (ths/get-shape file' :copy-group) + copy-group'' (ths/get-shape file'' :copy-group)] + + ;; ==== Check + (t/is (not= (geometry copy-group) (geometry copy-group'))) + (t/is (= (geometry copy-group) (geometry copy-group''))))) + +(t/deftest test-sync-undo-group-after-add-and-update + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (add-group-component-with-copy) + (touch-copy-group-geometry) + (ths/add-sample-shape :free-shape :x 60 :y 60 :width 10 :height 10)) + page (thf/current-page file) + copy-group (ths/get-shape file :copy-group) + + ;; ==== Action + ;; The new shape goes first in the group, so the sync handles it before + ;; the updated sibling. + changes1 (cls/generate-relocate (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-objects (:objects page))) + (thi/id :main-group) + 0 + #{(thi/id :free-shape)}) + updated-file (-> (thf/apply-changes file changes1) + (update-fill :main-child1 "#fabada")) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-group' (ths/get-shape file' :copy-group) + copy-group'' (ths/get-shape file'' :copy-group)] + + ;; ==== Check + (t/is (= 3 (count (:shapes copy-group')))) + (t/is (not= (geometry copy-group) (geometry copy-group'))) + (t/is (= (:shapes copy-group) (:shapes copy-group''))) + (t/is (= (geometry copy-group) (geometry copy-group''))))) + +(t/deftest test-sync-undo-group-after-remove-and-update + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (add-group-component-with-copy) + (touch-copy-group-geometry)) + page (thf/current-page file) + copy-group (ths/get-shape file :copy-group) + + ;; ==== Action + ;; The removed shape is the first one in the group, so the sync + ;; handles it before the updated sibling. Remove it only from the + ;; main, as a library update does, so that the sync removes it from + ;; the copy. + changes1 (-> (pcb/empty-changes nil (:id page)) + (pcb/with-objects (:objects page)) + (pcb/remove-objects #{(thi/id :main-child1)})) + updated-file (-> (thf/apply-changes file changes1) + (update-fill :main-child2 "#fabada")) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-group' (ths/get-shape file' :copy-group) + copy-group'' (ths/get-shape file'' :copy-group)] + + ;; ==== Check + (t/is (= 1 (count (:shapes copy-group')))) + (t/is (not= (geometry copy-group) (geometry copy-group'))) + (t/is (= (:shapes copy-group) (:shapes copy-group''))) + (t/is (= (geometry copy-group) (geometry copy-group''))))) + +(t/deftest test-sync-inverse-undo-group-after-add-and-update + (let [;; ==== Setup + ;; Penpot does not let users move shapes into a copy, but files can + ;; still hold copies with shapes the main lacks, so build the data + ;; directly. The new shape goes first in the copy group, so the + ;; inverse sync handles it before the updated sibling. + file (-> (thf/sample-file :file1) + (add-group-component-with-copy) + (ths/add-sample-shape :free-shape :parent-label :copy-group + :x 160 :y 160 :width 10 :height 10) + (ths/update-shape :copy-group :shapes [(thi/id :free-shape) + (thi/id :copy-child1) + (thi/id :copy-child2)])) + main-group (ths/get-shape file :main-group) + + ;; ==== Action + updated-file (update-fill file :copy-child1 "#fabada") + page (thf/current-page updated-file) + container (ctn/make-container page :page) + changes (-> (pcb/empty-changes) + (pcb/with-container container) + (cll/generate-sync-shape-inverse (:data updated-file) + {(:id updated-file) updated-file} + container + (thi/id :copy-root))) + file' (thf/apply-changes updated-file changes) + file'' (thf/apply-undo-changes file' changes) + + ;; ==== Get + main-group' (ths/get-shape file' :main-group) + main-group'' (ths/get-shape file'' :main-group)] + + ;; ==== Check + (t/is (= 3 (count (:shapes main-group')))) + (t/is (not= (geometry main-group) (geometry main-group'))) + (t/is (= (:shapes main-group) (:shapes main-group''))) + (t/is (= (geometry main-group) (geometry main-group''))))) From 12dd4d19d001d9c3af8f7ee35c483c68cc387d62 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 18:10:02 +0100 Subject: [PATCH 52/65] :recycle: Use a DS icon button for Share in the workspace header (#12681) * :recycle: Use a DS icon button for Share in the workspace header The Share link of the right header becomes an icon-button* with the DS share icon, like its neighbours (comments, history, viewer). It gets keyboard focus, a name and the DS tooltip and hover. It also makes Share visible again on develop: since the share icon moved to currentColor, the legacy icon drew with the transparent color of its svg. The button is 32px wide instead of 28px, so the zoom, comments and history controls move 4px to the left. AI-assisted-by: claude-opus-5-5 * :fire: Remove the share link styles from the workspace header Delete .share-btn and its svg rule, which no markup uses since Share is a DS icon button. refactor/ stays only for the menu placeholders. No visual change (0 px). AI-assisted-by: claude-opus-5-5 --- .../data/workspace/get-teams-shared.json | 25 ++++++++++++++++ .../ui/visual-specs/workspace.spec.js | 24 +++++++++++++++ .../app/main/ui/workspace/right_header.cljs | 10 +++---- .../app/main/ui/workspace/right_header.scss | 29 ++----------------- 4 files changed, 56 insertions(+), 32 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-teams-shared.json diff --git a/frontend/playwright/data/workspace/get-teams-shared.json b/frontend/playwright/data/workspace/get-teams-shared.json new file mode 100644 index 0000000000..2c6119c75f --- /dev/null +++ b/frontend/playwright/data/workspace/get-teams-shared.json @@ -0,0 +1,25 @@ +[ + { + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true + }, + "~:name": "Default", + "~:modified-at": "~m1713533116375", + "~:id": "~uc7ce0794-0992-8105-8004-38e630f7920a", + "~:created-at": "~m1713533116375", + "~:is-default": false + } +] diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index d831b30a07..5441abf974 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -780,6 +780,30 @@ test.describe("Right header", () => { }); }); +test.describe("Share button", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Share button in the right header", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + // A shared team where the user is admin, so the header shows Share. + await workspace.mockRPC("get-teams", "workspace/get-teams-shared.json"); + await workspace.goToWorkspace(); + + const header = page.locator('[class*="workspace-header-right"]'); + const share = header + .locator("[class*='share-btn']") + .or(header.getByRole("button", { name: "Share" })); + await expect(share).toBeVisible(); + await page.mouse.move(640, 300); + await expect(header).toHaveScreenshot(shot); + + await share.hover(); + await page.waitForTimeout(1000); + await expect(header).toHaveScreenshot(shot); + }); +}); + test.describe("Undo history", () => { test("Shows the actions list with an entry expanded", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index 0b2f9a9b0e..b76ac50fa9 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -25,7 +25,6 @@ [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.exports.assets :refer [progress-widget]] [app.main.ui.formats :as fmt] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.presence :refer [active-sessions*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -239,10 +238,11 @@ :on-click toggle-history}]]) (when display-share-button? - [:a {:class (stl/css :share-btn) - :title (tr "workspace.header.share") - :on-click open-share-dialog} - deprecated-icon/share]) + [:> icon-button* {:variant "action" + :tooltip-placement "bottom-left" + :aria-label (tr "workspace.header.share") + :icon i/share + :on-click open-share-dialog}]) [:> icon-button* {:variant "action" :tooltip-placement "bottom-left" diff --git a/frontend/src/app/main/ui/workspace/right_header.scss b/frontend/src/app/main/ui/workspace/right_header.scss index 040b270490..a67b400537 100644 --- a/frontend/src/app/main/ui/workspace/right_header.scss +++ b/frontend/src/app/main/ui/workspace/right_header.scss @@ -9,8 +9,8 @@ @use "ds/_utils.scss" as *; @use "ds/typography.scss" as *; -// Only for the shared menu placeholders, and the button placeholders of -// the share link until the DS has a share icon. +// Only for the shared menu placeholders (%menu-dropdown, %menu-item-base, +// %shortcut-*), which move with the menus group. @use "refactor/common-refactor.scss" as deprecated; .workspace-header-right { @@ -120,31 +120,6 @@ position: relative; } -// Raw link until the DS has a share icon. -.share-btn { - @extend %button-tertiary; - - border-radius: $br-8; - margin: 0; - inline-size: $sz-28; - block-size: $sz-28; - border: none; - - // The legacy share icon has no class. - svg { - @extend %button-icon; - - block-size: $sz-16; - inline-size: $sz-16; - stroke: var(--color-foreground-secondary); - } - - &:hover { - background-color: transparent; - border: none; - } -} - .unread { position: absolute; inline-size: px2rem(8); From 0fa1077b9ac4329639f75a881694ddebb23e003b Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 18:10:16 +0100 Subject: [PATCH 53/65] :bug: Render no type attribute on DS buttons used as links (#12709) * :bug: Render no type attribute on DS buttons used as links With `to`, button* renders an but still passed type="button". On an anchor, type is a MIME type hint, so "button" is invalid HTML. The type now applies only when the element is a + + Penpot MCP + + + +

Penpot MCP

+

The browser does not discard these tabs while they are connected to MCP.

+

Connected tabs

+
    + + + diff --git a/mcp/packages/browser-extension/scripts/build.mjs b/mcp/packages/browser-extension/scripts/build.mjs new file mode 100644 index 0000000000..6d7b6b3069 --- /dev/null +++ b/mcp/packages/browser-extension/scripts/build.mjs @@ -0,0 +1,18 @@ +import { build } from "esbuild"; +import { cp, rm, writeFile } from "node:fs/promises"; +import { BROWSERS, createManifest } from "./manifest.mjs"; + +const TARGETS = { chrome: "chrome116", firefox: "firefox140" }; + +await rm("dist", { recursive: true, force: true }); +for (const browser of BROWSERS) { + const outdir = `dist/${browser}`; + const options = { bundle: true, target: TARGETS[browser], outdir, logLevel: "info" }; + // Chrome loads the service worker as a module, Firefox the event page as a classic script + await build({ ...options, entryPoints: ["src/background.ts"], format: browser === "chrome" ? "esm" : "iife" }); + await build({ ...options, entryPoints: ["src/popup.ts"], format: "esm" }); + // content scripts load as classic scripts + await build({ ...options, entryPoints: ["src/content.ts"], format: "iife" }); + await cp("public", outdir, { recursive: true }); + await writeFile(`${outdir}/manifest.json`, JSON.stringify(createManifest(browser), null, 4) + "\n"); +} diff --git a/mcp/packages/browser-extension/scripts/manifest.mjs b/mcp/packages/browser-extension/scripts/manifest.mjs new file mode 100644 index 0000000000..5b11972c55 --- /dev/null +++ b/mcp/packages/browser-extension/scripts/manifest.mjs @@ -0,0 +1,61 @@ +/** Browsers the extension is built for, one directory of `dist/` each. */ +export const BROWSERS = ["chrome", "firefox"]; + +const ICONS = { + 16: "icons/icon-16.png", + 32: "icons/icon-32.png", + 48: "icons/icon-48.png", + 128: "icons/icon-128.png", +}; + +const CONTENT_SCRIPTS = [ + { + matches: ["https://design.penpot.app/*", "https://*.penpot.dev/*", "https://localhost/*", "http://localhost/*"], + js: ["content.js"], + run_at: "document_idle", + }, +]; + +/** + * Creates the extension manifest for a browser. + * + * Chrome runs the background as a module service worker. Firefox runs it as an event page and + * needs an add-on ID. + * + * @param {"chrome" | "firefox"} browser - The browser to build for + */ +export function createManifest(browser) { + const common = { + manifest_version: 3, + name: "Penpot MCP", + version: "0.1.0", + description: "Keeps the browser from discarding Penpot tabs connected to MCP.", + icons: ICONS, + permissions: ["storage"], + action: { + default_title: "Penpot MCP", + default_popup: "popup.html", + default_icon: { 16: ICONS[16], 32: ICONS[32] }, + }, + content_scripts: CONTENT_SCRIPTS, + }; + if (browser === "chrome") { + return { + ...common, + minimum_chrome_version: "116", + background: { service_worker: "background.js", type: "module" }, + }; + } + return { + ...common, + background: { scripts: ["background.js"] }, + browser_specific_settings: { + gecko: { + id: "mcp-extension@penpot.app", + // the first version that knows data_collection_permissions + strict_min_version: "140.0", + data_collection_permissions: { required: ["none"] }, + }, + }, + }; +} diff --git a/mcp/packages/browser-extension/src/DiscardGuard.ts b/mcp/packages/browser-extension/src/DiscardGuard.ts new file mode 100644 index 0000000000..a50b06d682 --- /dev/null +++ b/mcp/packages/browser-extension/src/DiscardGuard.ts @@ -0,0 +1,28 @@ +import type { SessionChange, SessionMapObserver } from "./SessionMap.ts"; + +/** Browser control over the automatic discarding of tabs. */ +export interface DiscardControl { + setAutoDiscardable(tabId: number, autoDiscardable: boolean): Promise; +} + +/** + * Keeps the browser from discarding tabs that hold an MCP session, since a discarded tab reloads + * and loses its MCP connection. Tabs regain automatic discarding when they stop holding a session. + */ +export class DiscardGuard implements SessionMapObserver { + constructor(private readonly control: DiscardControl) {} + + sessionsChanged(change: SessionChange): void { + for (const tabId of change.tabsAdded) { + this.set(tabId, false); + } + for (const tabId of change.tabsRemoved) { + this.set(tabId, true); + } + } + + private set(tabId: number, autoDiscardable: boolean): void { + // the tab may have been closed meanwhile + this.control.setAutoDiscardable(tabId, autoDiscardable).catch(() => {}); + } +} diff --git a/mcp/packages/browser-extension/src/PageBridge.test.ts b/mcp/packages/browser-extension/src/PageBridge.test.ts new file mode 100644 index 0000000000..afaeabd048 --- /dev/null +++ b/mcp/packages/browser-extension/src/PageBridge.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; +import type { ContentMessage } from "./messages.ts"; +import { PageBridge, type PageWindow, type WorkerChannel } from "./PageBridge.ts"; + +const ORIGIN = "https://design.penpot.app"; + +/** Page recording what the bridge posts and dispatching events to it. */ +class FakePage implements PageWindow { + readonly location = { origin: ORIGIN }; + readonly posted: { message: unknown; targetOrigin: string }[] = []; + private readonly listeners = new Map void)[]>(); + + postMessage(message: unknown, targetOrigin: string): void { + this.posted.push({ message, targetOrigin }); + } + + addEventListener(type: string, listener: (event: any) => void): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + dispatch(type: string, event: object = {}): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + /** Posts a message on the page as the Penpot app does. */ + post(data: unknown, origin = ORIGIN, source: unknown = this): void { + this.dispatch("message", { data, origin, source }); + } +} + +/** Worker channel recording what the bridge sends. */ +class FakeWorker implements WorkerChannel { + readonly received: ContentMessage[] = []; + + async sendMessage(message: ContentMessage): Promise { + this.received.push(message); + return undefined; + } +} + +const announcement = { type: "penpot-mcp:session", sessionId: "abcdefghij", status: "connected" }; + +let page: FakePage; +let worker: FakeWorker; + +beforeEach(() => { + page = new FakePage(); + worker = new FakeWorker(); + new PageBridge(page, worker).start(); +}); + +test("asks the page for its session on start, restricted to the page's origin", () => { + assert.deepEqual(page.posted, [{ message: { type: "penpot-mcp-extension:hello" }, targetOrigin: ORIGIN }]); +}); + +test("says hello again when the page reports it has started listening", () => { + page.post({ type: "penpot-mcp:ready" }); + page.post({ type: "penpot-mcp:ready" }, "https://evil.example"); + assert.equal(page.posted.length, 2); + assert.deepEqual(page.posted[1], { message: { type: "penpot-mcp-extension:hello" }, targetOrigin: ORIGIN }); +}); + +test("forwards the page's session announcements to the worker", () => { + page.post(announcement); + assert.deepEqual(worker.received, [{ type: "session", announcement }]); +}); + +test("ignores announcements from other origins or other windows", () => { + page.post(announcement, "https://evil.example"); + page.post(announcement, ORIGIN, {}); + assert.deepEqual(worker.received, []); +}); + +test("ignores invalid announcements and its own hello", () => { + page.post({ ...announcement, status: "unknown" }); + page.post({ type: "penpot-mcp-extension:hello" }); + assert.deepEqual(worker.received, []); +}); + +test("reports a hidden page and asks again when it returns from the back/forward cache", () => { + page.dispatch("pagehide"); + page.dispatch("pageshow", { persisted: false }); + assert.equal(page.posted.length, 1); + page.dispatch("pageshow", { persisted: true }); + assert.deepEqual(worker.received, [{ type: "page-hidden" }]); + assert.equal(page.posted.length, 2); +}); + +test("keeps working when the worker cannot be reached", () => { + const failing = new FakePage(); + new PageBridge(failing, { + sendMessage: () => { + throw new Error("Extension context invalidated."); + }, + }).start(); + failing.post(announcement); + new PageBridge(failing, { + sendMessage: () => Promise.reject(new Error("No receiver")), + }).start(); + failing.post(announcement); +}); diff --git a/mcp/packages/browser-extension/src/PageBridge.ts b/mcp/packages/browser-extension/src/PageBridge.ts new file mode 100644 index 0000000000..42a0a54887 --- /dev/null +++ b/mcp/packages/browser-extension/src/PageBridge.ts @@ -0,0 +1,69 @@ +import type { ExtensionHello } from "@penpot/mcp-common"; +import { parseAnnouncement, type ContentMessage } from "./messages.ts"; + +/** Page operations the bridge uses; the content script passes its window. */ +export interface PageWindow { + readonly location: { readonly origin: string }; + postMessage(message: unknown, targetOrigin: string): void; + addEventListener(type: string, listener: (event: any) => void): void; +} + +/** Channel from the content script to the service worker. */ +export interface WorkerChannel { + sendMessage(message: ContentMessage): Promise; +} + +/** + * Relays the MCP session announced by a Penpot page to the extension's service worker. + * + * The bridge asks the page for its session on load, when the page reports it has started + * listening, and when the page returns from the back/forward cache. It accepts only messages the + * page posted on its own window from its own origin. + */ +export class PageBridge { + constructor( + private readonly page: PageWindow, + private readonly worker: WorkerChannel + ) {} + + /** Starts listening to the page and asks it for its session. */ + start(): void { + this.page.addEventListener("message", (event: MessageEvent) => this.handleMessage(event)); + this.page.addEventListener("pagehide", () => this.send({ type: "page-hidden" })); + this.page.addEventListener("pageshow", (event: PageTransitionEvent) => { + if (event.persisted) { + this.sayHello(); + } + }); + this.sayHello(); + } + + private sayHello(): void { + const hello: ExtensionHello = { type: "penpot-mcp-extension:hello" }; + this.page.postMessage(hello, this.page.location.origin); + } + + private handleMessage(event: MessageEvent): void { + if (event.source !== this.page || event.origin !== this.page.location.origin) { + return; + } + // the page started listening after the hello sent on load + if (event.data?.type === "penpot-mcp:ready") { + this.sayHello(); + return; + } + const announcement = parseAnnouncement(event.data); + if (announcement) { + this.send({ type: "session", announcement }); + } + } + + private send(message: ContentMessage): void { + try { + // the worker cannot be reached after the extension is reloaded or removed + this.worker.sendMessage(message).catch(() => {}); + } catch { + // ignored for the same reason + } + } +} diff --git a/mcp/packages/browser-extension/src/PopupState.test.ts b/mcp/packages/browser-extension/src/PopupState.test.ts new file mode 100644 index 0000000000..94a1b9d892 --- /dev/null +++ b/mcp/packages/browser-extension/src/PopupState.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getPopupState } from "./PopupState.ts"; +import { SessionMap } from "./SessionMap.ts"; + +test("lists the sessions and their tabs", async () => { + const sessions = new SessionMap({ load: async () => undefined, save: async () => {} }); + await sessions.announce( + { tabId: 7, windowId: 1 }, + { type: "penpot-mcp:session", sessionId: "tab", status: "connected" } + ); + assert.deepEqual(getPopupState(sessions), { sessions: [{ sessionId: "tab", tabId: 7 }] }); +}); diff --git a/mcp/packages/browser-extension/src/PopupState.ts b/mcp/packages/browser-extension/src/PopupState.ts new file mode 100644 index 0000000000..1bf6678a69 --- /dev/null +++ b/mcp/packages/browser-extension/src/PopupState.ts @@ -0,0 +1,22 @@ +import type { SessionMap } from "./SessionMap.ts"; + +/** Request of the popup for the extension's state. */ +export interface PopupStateRequest { + type: "popup:get-state"; +} + +/** State of the extension shown in the popup. */ +export interface PopupState { + sessions: { sessionId: string; tabId: number }[]; +} + +/** + * Collects the state shown in the popup. + * + * @param sessions - The map of sessions to tabs + */ +export function getPopupState(sessions: SessionMap): PopupState { + return { + sessions: sessions.all().map(([sessionId, entry]) => ({ sessionId, tabId: entry.tabId })), + }; +} diff --git a/mcp/packages/browser-extension/src/SessionMap.test.ts b/mcp/packages/browser-extension/src/SessionMap.test.ts new file mode 100644 index 0000000000..38e31fa874 --- /dev/null +++ b/mcp/packages/browser-extension/src/SessionMap.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; +import type { SessionAnnouncement } from "@penpot/mcp-common"; +import { DiscardGuard } from "./DiscardGuard.ts"; +import { SessionMap, type SessionChange, type SessionEntry, type SessionStore } from "./SessionMap.ts"; + +/** In-memory store standing in for session storage. */ +class MemoryStore implements SessionStore { + constructor(public saved?: Record) {} + + async load() { + return this.saved; + } + + async save(entries: Record) { + this.saved = structuredClone(entries); + } +} + +const TAB = { tabId: 1, windowId: 10 }; +const OTHER_TAB = { tabId: 2, windowId: 10 }; + +function announcement( + status: SessionAnnouncement["status"], + sessionId: string | null = "session" +): SessionAnnouncement { + return { type: "penpot-mcp:session", sessionId, status }; +} + +let store: MemoryStore; +let changes: SessionChange[]; +let sessions: SessionMap; + +beforeEach(() => { + store = new MemoryStore(); + changes = []; + sessions = new SessionMap(store); + sessions.observe({ sessionsChanged: (change) => changes.push(change) }); +}); + +test("maps a connected session to the announcing tab and saves it", async () => { + await sessions.announce(TAB, announcement("connected")); + assert.deepEqual(sessions.get("session"), TAB); + assert.deepEqual(store.saved, { session: TAB }); + assert.deepEqual(changes, [{ tabsAdded: [1], tabsRemoved: [] }]); +}); + +test("ignores announcements without a connected session", async () => { + await sessions.announce(TAB, announcement("connecting", null)); + await sessions.announce(TAB, announcement("connected", null)); + await sessions.announce(TAB, announcement("error")); + assert.deepEqual(sessions.all(), []); + assert.deepEqual(changes, []); +}); + +test("keeps the session while the plugin reconnects", async () => { + await sessions.announce(TAB, announcement("connected")); + await sessions.announce(TAB, announcement("connecting")); + await sessions.announce(TAB, announcement("connected")); + assert.equal(sessions.get("session")?.tabId, 1); + assert.equal(changes.length, 1); +}); + +test("forgets the tab's session when its page announces a disconnect", async () => { + await sessions.announce(TAB, announcement("connected")); + await sessions.announce(TAB, announcement("disconnected", null)); + assert.equal(sessions.get("session"), undefined); + assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [1] }); +}); + +test("a tab holds only the session it announced last", async () => { + await sessions.announce(TAB, announcement("connected", "old")); + await sessions.announce(TAB, announcement("connected", "new")); + assert.equal(sessions.get("old"), undefined); + assert.equal(sessions.get("new")?.tabId, 1); + assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [] }); +}); + +test("forgets only the sessions of a removed tab", async () => { + await sessions.announce(TAB, announcement("connected", "first")); + await sessions.announce(OTHER_TAB, announcement("connected", "second")); + await sessions.removeTab(1); + assert.deepEqual( + sessions.all().map(([sessionId]) => sessionId), + ["second"] + ); + assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [1] }); +}); + +test("restores the saved sessions when the worker restarts", async () => { + await sessions.announce(TAB, announcement("connected")); + const restarted = new SessionMap(store); + await restarted.ready(); + assert.equal(restarted.get("session")?.tabId, 1); +}); + +test("changes made while restoring apply to the restored sessions", async () => { + const saved = new MemoryStore({ first: TAB }); + const restored = new SessionMap(saved); + await restored.announce(OTHER_TAB, announcement("connected", "second")); + assert.deepEqual(Object.keys(saved.saved!).sort(), ["first", "second"]); +}); + +test("the discard guard protects tabs holding a session and releases them after", async () => { + const calls: [number, boolean][] = []; + const guard = new DiscardGuard({ + setAutoDiscardable: async (tabId, autoDiscardable) => { + calls.push([tabId, autoDiscardable]); + }, + }); + const guarded = new SessionMap(new MemoryStore()); + guarded.observe(guard); + await guarded.announce(TAB, announcement("connected")); + await guarded.removeTab(1); + assert.deepEqual(calls, [ + [1, false], + [1, true], + ]); +}); + +test("the discard guard ignores tabs closed meanwhile", async () => { + const guard = new DiscardGuard({ setAutoDiscardable: () => Promise.reject(new Error("No tab with id")) }); + guard.sessionsChanged({ tabsAdded: [1], tabsRemoved: [2] }); + await new Promise((resolve) => setImmediate(resolve)); +}); diff --git a/mcp/packages/browser-extension/src/SessionMap.ts b/mcp/packages/browser-extension/src/SessionMap.ts new file mode 100644 index 0000000000..53eca8e1e2 --- /dev/null +++ b/mcp/packages/browser-extension/src/SessionMap.ts @@ -0,0 +1,129 @@ +import type { SessionAnnouncement } from "@penpot/mcp-common"; + +/** The tab holding an MCP session. */ +export interface SessionEntry { + tabId: number; + windowId: number; +} + +/** Persistence of the session map across service worker restarts. */ +export interface SessionStore { + load(): Promise | undefined>; + save(entries: Record): Promise; +} + +/** Change of the tabs holding sessions, reported after each change of the map. */ +export interface SessionChange { + /** tabs that hold a session and held none before. */ + tabsAdded: number[]; + /** tabs that held a session and hold none now. */ + tabsRemoved: number[]; +} + +/** Receiver of the changes of a session map. */ +export interface SessionMapObserver { + sessionsChanged(change: SessionChange): void; +} + +/** + * Map from MCP session IDs to the tabs holding them. + * + * A tab holds the session it last announced as connected, until it announces a disconnect, is + * hidden or is closed. The map is saved after each change and restored when the service worker + * restarts. + */ +export class SessionMap { + private entries = new Map(); + private readonly observers: SessionMapObserver[] = []; + private readonly loaded: Promise; + + constructor(private readonly store: SessionStore) { + this.loaded = store.load().then( + (saved) => { + this.entries = new Map(Object.entries(saved ?? {})); + }, + () => {} + ); + } + + /** Adds a receiver of the changes of this map. */ + observe(observer: SessionMapObserver): void { + this.observers.push(observer); + } + + /** Resolves once the saved entries have been restored. */ + ready(): Promise { + return this.loaded; + } + + /** + * Records a session announcement made by the page in a tab. + * + * @param tab - The tab and window of the announcing page + * @param announcement - The page's announcement + */ + async announce(tab: { tabId: number; windowId: number }, announcement: SessionAnnouncement): Promise { + await this.loaded; + if (announcement.status === "disconnected") { + await this.change(() => this.deleteTab(tab.tabId)); + } else if (announcement.status === "connected" && announcement.sessionId) { + const { sessionId } = announcement; + await this.change(() => { + // a tab holds a single session + this.deleteTab(tab.tabId); + this.entries.set(sessionId, { ...tab }); + }); + } + } + + /** + * Forgets the sessions held by a tab. + * + * @param tabId - The tab whose page was hidden, or which was closed or replaced + */ + async removeTab(tabId: number): Promise { + await this.loaded; + await this.change(() => this.deleteTab(tabId)); + } + + /** Returns the entry of a session, if a tab holds it. */ + get(sessionId: string): SessionEntry | undefined { + return this.entries.get(sessionId); + } + + /** Returns all sessions with their entries. */ + all(): [string, SessionEntry][] { + return [...this.entries]; + } + + /** Applies a change, then saves the map and reports the change if any tab gained or lost a session. */ + private async change(apply: () => void): Promise { + const before = this.tabs(); + const snapshot = JSON.stringify([...this.entries]); + apply(); + if (JSON.stringify([...this.entries]) === snapshot) { + return; + } + const after = this.tabs(); + await this.store.save(Object.fromEntries(this.entries)); + const change = { + tabsAdded: [...after].filter((tabId) => !before.has(tabId)), + tabsRemoved: [...before].filter((tabId) => !after.has(tabId)), + }; + for (const observer of this.observers) { + observer.sessionsChanged(change); + } + } + + private deleteTab(tabId: number): void { + for (const [sessionId, entry] of [...this.entries]) { + if (entry.tabId === tabId) { + this.entries.delete(sessionId); + } + } + } + + private tabs(): Set { + return new Set([...this.entries.values()].map((entry) => entry.tabId)); + } +} diff --git a/mcp/packages/browser-extension/src/background.ts b/mcp/packages/browser-extension/src/background.ts new file mode 100644 index 0000000000..fc4f6ddf43 --- /dev/null +++ b/mcp/packages/browser-extension/src/background.ts @@ -0,0 +1,44 @@ +/** + * Service worker of the Penpot MCP extension. + * + * It keeps the map from MCP sessions to the tabs holding them, and keeps the browser from + * discarding those tabs. + * + * Chrome may stop the worker, and Firefox its event page, at any time, so listeners are + * registered synchronously at start and state lives in session storage. + */ +import { ChromeDiscardControl, ChromeSessionStore } from "./chrome.ts"; +import { DiscardGuard } from "./DiscardGuard.ts"; +import { parseAnnouncement, type ContentMessage } from "./messages.ts"; +import { getPopupState, type PopupStateRequest } from "./PopupState.ts"; +import { SessionMap } from "./SessionMap.ts"; + +const sessions = new SessionMap(new ChromeSessionStore()); +sessions.observe(new DiscardGuard(new ChromeDiscardControl())); + +chrome.runtime.onMessage.addListener((message: ContentMessage | PopupStateRequest, sender, sendResponse) => { + if (sender.id !== chrome.runtime.id) { + return; + } + const tab = sender.tab; + if (message?.type === "popup:get-state" && !tab) { + void sessions.ready().then(() => sendResponse(getPopupState(sessions))); + // the response is sent asynchronously + return true; + } + if (tab?.id === undefined) { + return; + } + if (message?.type === "page-hidden") { + void sessions.removeTab(tab.id); + } else if (message?.type === "session") { + const announcement = parseAnnouncement(message.announcement); + if (announcement) { + void sessions.announce({ tabId: tab.id, windowId: tab.windowId }, announcement); + } + } +}); + +chrome.tabs.onRemoved.addListener((tabId) => void sessions.removeTab(tabId)); + +chrome.tabs.onReplaced.addListener((_addedTabId, removedTabId) => void sessions.removeTab(removedTabId)); diff --git a/mcp/packages/browser-extension/src/chrome.ts b/mcp/packages/browser-extension/src/chrome.ts new file mode 100644 index 0000000000..38954c7ced --- /dev/null +++ b/mcp/packages/browser-extension/src/chrome.ts @@ -0,0 +1,26 @@ +import type { DiscardControl } from "./DiscardGuard.ts"; +import type { SessionEntry, SessionStore } from "./SessionMap.ts"; + +const SESSIONS_KEY = "sessions"; + +/** + * Session store in `chrome.storage.session`, which lives in memory, survives service worker + * restarts and clears when the browser closes. + */ +export class ChromeSessionStore implements SessionStore { + async load(): Promise | undefined> { + const stored = await chrome.storage.session.get(SESSIONS_KEY); + return stored[SESSIONS_KEY] as Record | undefined; + } + + async save(entries: Record): Promise { + await chrome.storage.session.set({ [SESSIONS_KEY]: entries }); + } +} + +/** Discard control through the Chrome tabs API. */ +export class ChromeDiscardControl implements DiscardControl { + async setAutoDiscardable(tabId: number, autoDiscardable: boolean): Promise { + await chrome.tabs.update(tabId, { autoDiscardable }); + } +} diff --git a/mcp/packages/browser-extension/src/content.ts b/mcp/packages/browser-extension/src/content.ts new file mode 100644 index 0000000000..1e115228a4 --- /dev/null +++ b/mcp/packages/browser-extension/src/content.ts @@ -0,0 +1,3 @@ +import { PageBridge } from "./PageBridge.ts"; + +new PageBridge(window, chrome.runtime).start(); diff --git a/mcp/packages/browser-extension/src/manifest.test.ts b/mcp/packages/browser-extension/src/manifest.test.ts new file mode 100644 index 0000000000..f78006c58a --- /dev/null +++ b/mcp/packages/browser-extension/src/manifest.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { access } from "node:fs/promises"; +import test from "node:test"; +import { BROWSERS, createManifest } from "../scripts/manifest.mjs"; + +const manifests = BROWSERS.map((browser) => createManifest(browser)); +const [chrome, firefox] = manifests; + +test("runs the background as a module service worker in Chrome and as an event page in Firefox", () => { + assert.deepEqual(chrome.background, { service_worker: "background.js", type: "module" }); + assert.deepEqual(firefox.background, { scripts: ["background.js"] }); +}); + +test("requests only storage", () => { + for (const manifest of manifests) { + assert.equal(manifest.manifest_version, 3); + assert.deepEqual(manifest.permissions, ["storage"]); + assert.equal(manifest.optional_permissions, undefined); + assert.equal(manifest.host_permissions, undefined); + } +}); + +test("gives the Firefox build an add-on ID and a minimum version", () => { + assert.deepEqual(firefox.browser_specific_settings, { + gecko: { + id: "mcp-extension@penpot.app", + strict_min_version: "140.0", + data_collection_permissions: { required: ["none"] }, + }, + }); + assert.equal(chrome.browser_specific_settings, undefined); +}); + +test("references icons and a popup that exist in public/", async () => { + for (const manifest of manifests) { + const paths = [ + ...Object.values(manifest.icons as Record), + ...Object.values(manifest.action.default_icon as Record), + manifest.action.default_popup, + ]; + for (const path of paths) { + await access(new URL(`../public/${path}`, import.meta.url)); + } + } +}); diff --git a/mcp/packages/browser-extension/src/messages.test.ts b/mcp/packages/browser-extension/src/messages.test.ts new file mode 100644 index 0000000000..14040124e5 --- /dev/null +++ b/mcp/packages/browser-extension/src/messages.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseAnnouncement } from "./messages.ts"; + +const announcement = { type: "penpot-mcp:session", sessionId: "abcdefghij", status: "connected" }; + +test("accepts session announcements, with or without a session ID", () => { + for (const data of [announcement, { ...announcement, sessionId: null, status: "connecting" }]) { + assert.deepEqual(parseAnnouncement(data), data); + } +}); + +test("keeps only the announcement fields", () => { + assert.deepEqual(parseAnnouncement({ ...announcement, extra: 1 }), announcement); +}); + +test("rejects other messages and malformed announcements", () => { + for (const data of [ + null, + "penpot-mcp:session", + { ...announcement, type: "penpot-mcp-extension:hello" }, + { ...announcement, sessionId: "" }, + { ...announcement, sessionId: 42 }, + { ...announcement, status: "frozen" }, + ]) { + assert.equal(parseAnnouncement(data), null, JSON.stringify(data)); + } +}); diff --git a/mcp/packages/browser-extension/src/messages.ts b/mcp/packages/browser-extension/src/messages.ts new file mode 100644 index 0000000000..e55644973e --- /dev/null +++ b/mcp/packages/browser-extension/src/messages.ts @@ -0,0 +1,32 @@ +import type { McpConnectionStatus, SessionAnnouncement } from "@penpot/mcp-common"; + +/** + * Message sent by the content script to the service worker. + * + * - `session`: the page announced its MCP session + * - `page-hidden`: the page is being unloaded or stored in the back/forward cache + */ +export type ContentMessage = { type: "session"; announcement: SessionAnnouncement } | { type: "page-hidden" }; + +const STATUSES: readonly McpConnectionStatus[] = ["idle", "connecting", "connected", "disconnected", "error"]; + +function isSessionId(value: unknown): value is string | null { + return value === null || (typeof value === "string" && value.length > 0 && value.length <= 128); +} + +/** + * Validates a session announcement posted on the page. + * + * @param data - The data of a window message + * @returns The announcement, or null if the data is not a valid announcement + */ +export function parseAnnouncement(data: unknown): SessionAnnouncement | null { + if (typeof data !== "object" || data === null) { + return null; + } + const { type, sessionId, status } = data as Record; + if (type !== "penpot-mcp:session" || !isSessionId(sessionId) || !STATUSES.includes(status as McpConnectionStatus)) { + return null; + } + return { type, sessionId, status: status as McpConnectionStatus }; +} diff --git a/mcp/packages/browser-extension/src/popup.ts b/mcp/packages/browser-extension/src/popup.ts new file mode 100644 index 0000000000..fa29fac3fb --- /dev/null +++ b/mcp/packages/browser-extension/src/popup.ts @@ -0,0 +1,28 @@ +import type { PopupState, PopupStateRequest } from "./PopupState.ts"; + +const REFRESH_INTERVAL_MS = 1_000; + +const sessionsList = document.getElementById("sessions") as HTMLUListElement; + +function render(state: PopupState): void { + const items = state.sessions.map((session) => `${session.sessionId} (tab ${session.tabId})`); + sessionsList.replaceChildren( + ...(items.length ? items : ["No Penpot tab is connected to MCP."]).map((text) => { + const item = document.createElement("li"); + item.textContent = text; + item.classList.toggle("empty", items.length === 0); + return item; + }) + ); +} + +async function refresh(): Promise { + const request: PopupStateRequest = { type: "popup:get-state" }; + const state: PopupState | undefined = await chrome.runtime.sendMessage(request); + if (state) { + render(state); + } +} + +void refresh(); +setInterval(() => void refresh(), REFRESH_INTERVAL_MS); diff --git a/mcp/packages/browser-extension/tsconfig.json b/mcp/packages/browser-extension/tsconfig.json new file mode 100644 index 0000000000..b05f0b7842 --- /dev/null +++ b/mcp/packages/browser-extension/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"], + "types": ["chrome"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["src/*.test.ts"] +} diff --git a/mcp/packages/common/src/types.ts b/mcp/packages/common/src/types.ts index f9d7cde400..5168594f54 100644 --- a/mcp/packages/common/src/types.ts +++ b/mcp/packages/common/src/types.ts @@ -97,3 +97,36 @@ export interface ExecuteCodeTaskResultData { */ log: string; } + +/** + * Status of the Penpot MCP plugin connection, as reported to the Penpot app. + */ +export type McpConnectionStatus = "idle" | "connecting" | "connected" | "disconnected" | "error"; + +/** + * Message posted by a browser extension's content script to ask the Penpot page for its MCP session. + */ +export interface ExtensionHello { + type: "penpot-mcp-extension:hello"; +} + +/** + * Message posted by the Penpot page when it starts listening for an {@link ExtensionHello}. + * + * A content script that said hello before the page was listening says it again. + */ +export interface PageReady { + type: "penpot-mcp:ready"; +} + +/** + * Message posted by the Penpot page to announce its MCP session to a browser extension. + * + * The page sends it in reply to {@link ExtensionHello} and on every connection status change. + */ +export interface SessionAnnouncement { + type: "penpot-mcp:session"; + /** the MCP session ID of the page, or null while it is not known. */ + sessionId: string | null; + status: McpConnectionStatus; +} diff --git a/mcp/packages/plugin/src/ConnectionMetadata.test.ts b/mcp/packages/plugin/src/ConnectionMetadata.test.ts index 3131086153..1ae436afd6 100644 --- a/mcp/packages/plugin/src/ConnectionMetadata.test.ts +++ b/mcp/packages/plugin/src/ConnectionMetadata.test.ts @@ -147,6 +147,17 @@ test("resume and freeze events cannot send liveness messages before initializati assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]); }); +test("answers heartbeat requests once initialized, without forwarding them to the plugin", () => { + const ui = pluginUi(); + ui.connect(); + ui.sockets[0].receive({ type: "heartbeat-request" }); + assert.deepEqual(ui.sockets[0].sent, []); + ui.sockets[0].receive({ type: "initialized" }); + ui.sockets[0].receive({ type: "heartbeat-request" }); + assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]); + assert.equal(ui.messages.filter((message) => message.type === "heartbeat-request").length, 0); +}); + test("plugin reads the current file for each connection metadata request", () => { let onMessage!: (message: any) => void; const messages: any[] = []; diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 71973ade0b..8ae1163ad6 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -145,7 +145,9 @@ function sendTaskResponse(response: any): void { * Emits a liveness signal from the plugin event loop. * * WebSocket ping/pong is not enough here: browsers can answer protocol pings while - * page JavaScript is frozen and unable to run MCP tasks. + * page JavaScript is frozen and unable to run MCP tasks. The plugin sends one on a timer and + * in answer to each heartbeat request of the server, since a browser runs the timers of a + * hidden page as rarely as once a minute but handles socket messages at once. */ function sendHeartbeat(): boolean { if (connectionReady && ws?.readyState === WebSocket.OPEN) { @@ -252,6 +254,10 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { parent.postMessage({ type: "context-request" }, "*"); return; } + if (request.type === "heartbeat-request") { + sendHeartbeat(); + return; + } // Track the current task received from the MCP server if (request.task) { updateCurrentTask(request.task); diff --git a/mcp/packages/server/src/PenpotConnection.ts b/mcp/packages/server/src/PenpotConnection.ts index 267c1b159b..902f248a48 100644 --- a/mcp/packages/server/src/PenpotConnection.ts +++ b/mcp/packages/server/src/PenpotConnection.ts @@ -17,6 +17,8 @@ export interface PenpotConnection extends PluginLivenessState { readonly userToken: string | null; readonly session: PenpotSession; readonly pingInterval: NodeJS.Timeout; + /** the interval sending heartbeat requests to the plugin. */ + readonly heartbeatRequestInterval: NodeJS.Timeout; /** whether initialization and dispatch subscriptions have completed. */ ready: boolean; } diff --git a/mcp/packages/server/src/PluginBridge.sessions.test.ts b/mcp/packages/server/src/PluginBridge.sessions.test.ts index 1a42241169..3e676725f2 100644 --- a/mcp/packages/server/src/PluginBridge.sessions.test.ts +++ b/mcp/packages/server/src/PluginBridge.sessions.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { once } from "node:events"; -import { afterEach, beforeEach, test } from "node:test"; +import { afterEach, beforeEach, mock, test } from "node:test"; import { WebSocket } from "ws"; -import { PluginBridge } from "./PluginBridge"; +import { HEARTBEAT_REQUEST_INTERVAL_MS, PluginBridge } from "./PluginBridge"; import { PluginTask } from "./PluginTask"; import type { PenpotMcpServer } from "./PenpotMcpServer"; @@ -37,17 +37,33 @@ async function connect(sessionId: string, token = "alice", fileName = "Design", const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=${token}`); sockets.push(socket); await once(socket, "open"); + // answer tasks from the start, since one may arrive right behind the initialization + socket.on("message", (raw) => { + const request = JSON.parse(raw.toString()); + if (request.type !== "initialized" && request.type !== "heartbeat-request") { + socket.send(JSON.stringify({ id: request.id, success: true, data: reply })); + } + }); const initialized = once(socket, "message", { signal: AbortSignal.timeout(1500) }); socket.send(JSON.stringify({ type: "initialize", session: { sessionId, fileId: "file-1", fileName } })); const [message] = await initialized; assert.equal(JSON.parse(message.toString()).type, "initialized"); - socket.on("message", (raw) => { - const request = JSON.parse(raw.toString()); - socket.send(JSON.stringify({ id: request.id, success: true, data: reply })); - }); return socket; } +test("asks each plugin for a heartbeat at a fixed interval", async () => { + mock.timers.enable({ apis: ["setInterval"] }); + try { + const socket = await connect("tab-1"); + const requested = once(socket, "message", { signal: AbortSignal.timeout(1500) }); + mock.timers.tick(HEARTBEAT_REQUEST_INTERVAL_MS); + const [message] = await requested; + assert.deepEqual(JSON.parse(message.toString()), { type: "heartbeat-request" }); + } finally { + mock.timers.reset(); + } +}); + test("keeps two tabs for the same file and dispatches to the selected session", async () => { await connect("tab-1"); await connect("tab-2"); @@ -169,3 +185,21 @@ test("a dashboard session remains routable while its workspace metadata changes" assert.deepEqual(bridge.getUserConnections("alice")!.getSessions(), [{ sessionId: "tab", ...context }]); } }); + +test("a task fails at once when its plugin connection closes before answering", async () => { + const plugin = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`); + sockets.push(plugin); + await once(plugin, "open"); + const initialized = once(plugin, "message"); + plugin.send(JSON.stringify({ type: "initialize", session: { sessionId: "tab", fileId: null, fileName: null } })); + await initialized; + // the socket closes once the task reaches it + plugin.on("message", () => plugin.terminate()); + const started = Date.now(); + await assert.rejects( + bridge.executePluginTask(new PluginTask("test", {}), "tab"), + /connection closed before the task finished/ + ); + // well within the 1s task timeout of these tests + assert.ok(Date.now() - started < 500); +}); diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 913b28b26a..0c1136deea 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -15,6 +15,14 @@ import { MultiInstanceTaskDispatcher } from "./dispatch/MultiInstanceTaskDispatc const KEEP_ALIVE_TIME = 30000; // 30 seconds const INITIALIZATION_TIMEOUT_MS = 10_000; +/** + * The interval, in ms, between the heartbeat requests sent to each plugin. + * + * A browser runs the timers of a hidden page as rarely as once a minute, which would leave the + * plugin's own heartbeat older than the stale threshold, but it handles socket messages at once. + */ +export const HEARTBEAT_REQUEST_INTERVAL_MS = 10_000; + /** * Maximum plugin heartbeat age before a connection is stale. * @@ -187,8 +195,8 @@ export class PluginBridge implements TaskDispatchHost { } }); - ws.on("close", () => { - this.logger.info("WebSocket connection closed"); + ws.on("close", (code: number, reason: Buffer) => { + this.logger.info("WebSocket connection closed (code %d%s)", code, reason.length ? `: ${reason}` : ""); clearTimeout(initializationTimeout); this.removeConnection(ws); }); @@ -218,12 +226,17 @@ export class PluginBridge implements TaskDispatchHost { const pingInterval = setInterval(() => { ws.ping(); }, KEEP_ALIVE_TIME); + // the plugin answers with a heartbeat, which a frozen page cannot send + const heartbeatRequestInterval = setInterval(() => { + ws.send(JSON.stringify({ type: "heartbeat-request" })); + }, HEARTBEAT_REQUEST_INTERVAL_MS); // register the client connection with both indexes const connection: PenpotConnection = { socket: ws, userToken, pingInterval, + heartbeatRequestInterval, session, ready: false, lastHeartbeat: Date.now(), @@ -260,10 +273,11 @@ export class PluginBridge implements TaskDispatchHost { /** * Removes a client connection and releases all resources associated with it. * - * Clears the per-connection keep-alive interval and removes the connection from the - * socket-keyed index and the user's session collection. The token-keyed index entry - * is removed only when the user's last local connection closes. Safe to call with a - * socket that is not (or no longer) registered. + * Clears the per-connection keep-alive and heartbeat-request intervals and removes the + * connection from the socket-keyed index and the user's session collection. The + * token-keyed index entry is removed only when the user's last local connection closes. + * Tasks sent over the connection and still awaiting a response fail at once. Safe to call + * with a socket that is not (or no longer) registered. * * @param ws - The WebSocket whose connection state should be removed */ @@ -274,9 +288,17 @@ export class PluginBridge implements TaskDispatchHost { } connection.ready = false; clearInterval(connection.pingInterval); + clearInterval(connection.heartbeatRequestInterval); this.connectionsBySocket.delete(ws); const connections = this.getUserConnections(connection.userToken)!; connections.remove(connection); + // a task sent over the closed socket can no longer be answered, since responses only count + // from the connection the task went to + for (const [taskId, taskConnection] of [...this.taskConnections]) { + if (taskConnection === connection) { + this.rejectPendingTask(taskId, new Error("The plugin connection closed before the task finished.")); + } + } if (connection.userToken !== null && connections.size === 0) { this.userPenpotConnectionsByToken.delete(connection.userToken); } diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 3b7d86bd01..64b7eae00a 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -177,6 +177,24 @@ importers: specifier: ^3.9.10 version: 3.9.10 + packages/browser-extension: + devDependencies: + '@penpot/mcp-common': + specifier: workspace:../common + version: link:../common + '@types/chrome': + specifier: ^0.3.6 + version: 0.3.6 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/common: devDependencies: typescript: @@ -783,6 +801,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chrome@0.3.6': + resolution: {integrity: sha512-NXCrCBefCHCKMVbkaxfBQqyP766Wg85S2FQhixCCxq7AtjJwoEaja2y5NSqCzpihIi9mPqhaF5RuybTnrdYD2Q==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -792,6 +813,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1918,6 +1942,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 26.6.5 + '@types/chrome@0.3.6': + dependencies: + '@types/har-format': 1.2.16 + '@types/connect@3.4.38': dependencies: '@types/node': 26.6.5 @@ -1935,6 +1963,8 @@ snapshots: '@types/express-serve-static-core': 5.1.3 '@types/serve-static': 2.2.0 + '@types/har-format@1.2.16': {} + '@types/http-errors@2.0.5': {} '@types/js-yaml@4.0.9': {} diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml index 3da100dca6..5aa8300f88 100644 --- a/mcp/pnpm-workspace.yaml +++ b/mcp/pnpm-workspace.yaml @@ -19,6 +19,7 @@ packages: - "packages/common" - "packages/server" - "packages/plugin" + - "packages/browser-extension" overrides: '@hono/node-server@<2.0.5': ^2.0.5 diff --git a/mcp/scripts/build b/mcp/scripts/build index 3e5337fda2..671091ac9f 100755 --- a/mcp/scripts/build +++ b/mcp/scripts/build @@ -21,7 +21,7 @@ set +e set -e popd -pnpm -r --filter "!mcp-plugin" install; +pnpm -r --filter "!mcp-plugin" --filter "!mcp-browser-extension" install; pnpm -r --filter "mcp-server" run build; rsync -avr packages/server/dist/ ./dist/;

    Z7E0G$tnP6Ljl0%flNdoOX@aJ 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