From f76b1447ee0a70969db8e17515857c8d47659133 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Wed, 14 Oct 2026 10:20:27 +0200 Subject: [PATCH 001/167] :sparkles: Improve error report and warn on update loops (#12044) --- frontend/deps.edn | 8 +- frontend/src/app/main.cljs | 4 +- frontend/src/app/main/errors.cljs | 70 ++++++++- .../app/main/ui/workspace/viewport/hooks.cljs | 2 +- .../test/frontend_tests/main_errors_test.cljs | 142 ++++++++++++++++++ 5 files changed, 215 insertions(+), 11 deletions(-) diff --git a/frontend/deps.edn b/frontend/deps.edn index b1293a652c..c5184e43ae 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -15,8 +15,8 @@ :git/url "https://github.com/funcool/tubax.git"} funcool/potok2 - {:git/tag "v2.2" - :git/sha "0f7e15a" + {:git/tag "v2.3" + :git/sha "bdebbdb" :git/url "https://github.com/funcool/potok.git" :exclusions [funcool/beicon2]} @@ -26,8 +26,8 @@ :git/url "https://github.com/funcool/beicon.git"} funcool/rumext - {:git/tag "v2.25" - :git/sha "27e5a1a" + {:git/tag "v2.26" + :git/sha "d726717" :git/url "https://github.com/funcool/rumext.git"} instaparse/instaparse {:mvn/version "1.5.0"} diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index c7d87ba8ba..dbd3deb96c 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -16,7 +16,7 @@ [app.main.data.event :as ev] [app.main.data.profile :as dp] [app.main.data.websocket :as ws] - [app.main.errors] + [app.main.errors :as errors] [app.main.features :as feat] [app.main.rasterizer :as thr] [app.main.store :as st] @@ -134,6 +134,8 @@ (i18n/init) (cur/init-styles) + (mf/set-update-scheduler {:on-trip errors/on-update-loop}) + (init-ui) (st/emit! (plugins/initialize) (initialize))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 928722f889..d2c4976024 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -100,6 +100,40 @@ (assoc ::instance cause) (assoc ::trace (.-stack cause))))) +;; Events in flight when each exception was caught, as report lines +(defonce ^:private in-flight-by-cause (js/WeakMap.)) + +;; Events kept from each end of a long in-flight list +(def ^:private in-flight-edge-size 10) + +(defn- in-flight-lines + "Report lines for the in-flight `events`, outermost first; a long list + keeps only both ends." + [events] + (let [total (count events) + size in-flight-edge-size] + (if (<= total (* 2 size)) + (mapv ptk/repr-event events) + (-> [] + (into (map ptk/repr-event) (take size events)) + (conj (str "... " (- total (* 2 size)) " more ...")) + (into (map ptk/repr-event) (take-last size events)))))) + +(defn- record-in-flight! + "Keeps the events the store is processing for the report of `cause`. + Runs synchronously on the error path: when an event's `update`, `effect` + or synchronous `watch` throws, the last line is that event. When a + `watch` pipeline fails on a value from the store stream, the store has + already finished that value, so the lines show the events that emitted + it, not the event that owns the pipeline." + [cause] + (try + (when (ex/exception? cause) + (when-let [events (not-empty (ptk/in-flight-events st/state))] + (.set in-flight-by-cause cause (in-flight-lines events)))) + (catch :default _ + nil))) + (defn on-error "A general purpose error handler. @@ -114,6 +148,7 @@ (ex/print-throwable error)) (do (vreset! handling-error? true) + (record-in-flight! (if (map? error) (::instance error) error)) (try (if (map? error) (ptk/handle-error error) @@ -166,8 +201,9 @@ (println "HREF: " (rt/get-current-href))))) (defn- generate-full-report - "Complete report: context, formatted throwable (including `ex-data`) and the - last events." + "Complete report: context, formatted throwable (including `ex-data`), the + events in flight when the error was caught (when recorded) and the last + events." [cause] (with-out-str (print (report-context cause)) @@ -177,6 +213,12 @@ (ex/format-throwable cause)) (println) + (when-let [lines (.get in-flight-by-cause cause)] + (println "Events in flight when the error was caught (outermost first):") + (println "--------------------") + (run! println lines) + (println)) + (println "Last events:") (println "--------------------") (println (st/format-last-events)) @@ -202,9 +244,9 @@ "Build the report string for `cause`. `:format` selects the payload: `:full` (default) includes the formatted - throwable and the last events; `:compact` keeps only the context and the - error type/code/uri, for environment failures. The option is accepted both - as keyword arguments and as a trailing map." + throwable, the in-flight and the last events; `:compact` keeps only the + context and the error type/code/uri, for environment failures. The option + is accepted both as keyword arguments and as a trailing map." [cause & {:keys [format] :or {format :full}}] (try (case format @@ -392,6 +434,24 @@ hint occurrences)))) +(defonce ^:private update-loop-warned-at + (volatile! 0)) + +(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." + [_] + (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)))))) + (catch :default _ + nil))) + (defn- download-report! [report event] (dom/prevent-default event) diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 6b91ba1984..ae8397383e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -402,7 +402,7 @@ (defn inside-vbox [vbox objects frame-id] (let [frame (get objects frame-id)] - (and (some? frame) (gsh/overlaps? frame vbox)))) + (and (some? frame) (some? vbox) (gsh/overlaps? frame vbox)))) (defn setup-active-frames [objects hover-ids selected active-frames zoom transform vbox] diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index a964620c15..c622647f84 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -11,6 +11,10 @@ - stale-asset-error? – pure predicate - exception->error-data – pure transformer - on-error re-entrancy guard – prevents recursive invocations + - 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 - 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 @@ -368,6 +372,144 @@ ;; The guard must have allowed only the first invocation through. (t/is (= 1 @reentrant-call-count)))) +;; --------------------------------------------------------------------------- +;; Events in flight +;; +;; on-error records the events the store is processing when it catches an +;; exception; the full report lists them, outermost first. +;; --------------------------------------------------------------------------- + +(def ^:private in-flight-cause (atom nil)) + +(defmethod ptk/handle-error ::test-in-flight + [err] + (reset! in-flight-cause (::errors/instance err)) + nil) + +(def ^:private in-flight-heading + "Events in flight when the error was caught (outermost first):") + +(defn- in-flight-failure + [] + (ex-info "in-flight failure" {:type ::test-in-flight :hint "in-flight failure"})) + +(defn- recursive-event + [n] + (ptk/reify ::test-in-flight-recursive + ptk/WatchEvent + (watch [_ _ _] + (if (pos? n) + (rx/of (recursive-event (dec n))) + (throw (in-flight-failure)))))) + +(t/deftest ^:async report-lists-the-events-in-flight-of-a-failing-event + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + (st/emit! + (ptk/reify ::test-in-flight-outer + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/reify ::test-in-flight-inner + ptk/UpdateEvent + (update [_ _] (throw (in-flight-failure)))))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause) + outer (.indexOf report "typ:(:frontend-tests.main-errors-test/test-in-flight-outer)") + inner (.indexOf report "typ:(:frontend-tests.main-errors-test/test-in-flight-inner)")] + (t/is (str/includes? report in-flight-heading)) + (t/is (< -1 outer inner) "outer event listed before the failing one")))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async report-keeps-both-ends-of-a-long-in-flight-list + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + ;; 25 nested events: 10 from each end are kept + (st/emit! (recursive-event 24)) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause)] + (t/is (str/includes? report "... 5 more ...")) + (t/is (= 20 (count (re-seq #"test-in-flight-recursive" report))))))) + (finally + (rx/dispose! store))))) + +(defn- in-flight-section + "The in-flight section of `report`, or nil when absent." + [report] + (let [start (.indexOf report in-flight-heading)] + (when (<= 0 start) + (subs report start (.indexOf report "Last events:" start))))) + +(t/deftest ^:async stream-pipeline-failure-lists-the-emitters-of-the-value + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + ;; The listener's pipeline fails on the trigger that the emitter emits + (st/emit! + (ptk/reify ::test-in-flight-listener + ptk/WatchEvent + (watch [_ _ stream] + (->> stream + (rx/filter (ptk/type? ::test-in-flight-trigger)) + (rx/take 1) + (rx/map (fn [_] (throw (in-flight-failure))))))) + (ptk/reify ::test-in-flight-emitter + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::test-in-flight-trigger {}))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [section (in-flight-section (errors/generate-report @in-flight-cause))] + (t/is (str/includes? section "test-in-flight-emitter")) + (t/is (not (str/includes? section "test-in-flight-trigger"))) + (t/is (not (str/includes? section "test-in-flight-listener")))))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async delayed-watch-failure-has-no-in-flight-section + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + (st/emit! + (ptk/reify ::test-in-flight-delayed + ptk/WatchEvent + (watch [_ _ _] + (->> (rx/timer 0) + (rx/map (fn [_] (throw (in-flight-failure)))))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause)] + (t/is (not (str/includes? report in-flight-heading))) + (t/is (str/includes? report "Last events:"))))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async update-loop-handler-never-throws + (await + (mock/with-mocks* + {ptk/in-flight-events (fn [_] (throw (js/Error. "in-flight read failure")))} + (t/is (nil? (errors/on-update-loop {:rounds 26})))))) + ;; --------------------------------------------------------------------------- ;; Expired organization SSO session ;; From 1f96abd577af5fd907d9cb098bf49d72d8ba9934 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 14 Oct 2026 08:33:10 +0000 Subject: [PATCH 002/167] :books: Note that needs triage and internal issues are closed State in the contributing guide that issues under `needs triage` and `internal` are not open to contributions. Every new issue starts under `needs triage` until the product team reviews it, so contributors could mistake a fresh issue for an invitation and send a PR that we then turn down. A PR opened on one of those issues may be closed without review, without an explanation and without a reply. The wording follows the existing note about AI-assisted content. The help center page keeps its current wording for now. AI-assisted-by: deepseek-v4.1-flash --- CONTRIBUTING.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a11d90851..9da08257f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,11 @@ If AI-assisted content is used, it must be carefully reviewed and verified by a Report bugs via [GitHub Issues](https://github.com/penpot/penpot/issues). Before filing, search existing issues to avoid duplicates. +> **Note:** Every new issue starts under the `needs triage` label until the +> product team reviews it. Issues under `needs triage` or `internal` are not +> open to contributions — do not start work on them. Wait for a maintainer to +> confirm it in the issue itself. + Include the following when possible: 1. Steps to reproduce the error. @@ -164,10 +169,15 @@ To save time on both sides, please avoid submitting PRs that: - Submit AI-generated code without human review. - Skip local syntax and formatting checks before submitting. - Skip the [discussion step](#workflow) for non-bug-fix changes. +- Open a PR for an issue under `needs triage` or `internal`. We may close + these PRs without review, without an explanation and without a reply. ### Good first issues We use the `good first issue` label to mark issues appropriate for newcomers. +An issue is open to contributions only after a maintainer says so in the issue +itself. Issues under `needs triage` or `internal` stay closed, however simple +they look. ## Commit Guidelines From f40686d2056ced54fb7b604e87b53010425b7bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Wed, 14 Oct 2026 12:51:51 +0200 Subject: [PATCH 003/167] :books: Update user guide for 2.19 features (#12201) Document stroke per side and its tokens, path editor changes, image bounds resize, configurable shortcuts, tokens from a shared library as tokens source, canvas background color tokens, Penpot board presets, the 3x export scale, the text editing context menu, the organization and team selector, the hide resolved comments option, account security and the Check for updates option. Fix outdated notes about WebGL exports, background blur exports, library token import and path editor shortcuts. Add the images for these sections and remove the ones no longer used. AI-assisted-by: claude-opus-5-5 --- .../libraries-import-tokens-button.webp | Bin 27906 -> 0 bytes .../libraries-import-tokens-confirm.webp | Bin 30480 -> 0 bytes .../libraries/libraries-tokens-source.webp | Bin 0 -> 46024 bytes docs/img/shortcuts/shortcuts-settings.webp | Bin 0 -> 64912 bytes docs/img/styling/stroke-per-side.webp | Bin 0 -> 26546 bytes .../teams/team-selector-organizations.webp | Bin 0 -> 45812 bytes docs/img/teams/team-selector.webp | Bin 1256 -> 0 bytes docs/user-guide/account-teams/teams.njk | 14 ++-- .../user-guide/account-teams/your-account.njk | 9 ++- .../design-systems/design-tokens.njk | 7 +- docs/user-guide/design-systems/libraries.njk | 38 +++++++---- docs/user-guide/designing/color-stroke.njk | 15 ++++ docs/user-guide/designing/layers.njk | 35 ++++++++-- docs/user-guide/designing/text-typo.njk | 1 + .../user-guide/designing/workspace-basics.njk | 3 + .../export-import/exporting-layers.njk | 2 +- .../user-guide/first-steps/cloud-selfhost.njk | 4 ++ docs/user-guide/first-steps/shortcuts.njk | 64 ++++++++++++++++-- .../first-steps/troubleshooting-webgl.njk | 3 +- 19 files changed, 161 insertions(+), 34 deletions(-) delete mode 100644 docs/img/libraries/libraries-import-tokens-button.webp delete mode 100644 docs/img/libraries/libraries-import-tokens-confirm.webp create mode 100644 docs/img/libraries/libraries-tokens-source.webp create mode 100644 docs/img/shortcuts/shortcuts-settings.webp create mode 100644 docs/img/styling/stroke-per-side.webp create mode 100644 docs/img/teams/team-selector-organizations.webp delete mode 100644 docs/img/teams/team-selector.webp diff --git a/docs/img/libraries/libraries-import-tokens-button.webp b/docs/img/libraries/libraries-import-tokens-button.webp deleted file mode 100644 index 6cfe1b21562608917c090f9b1a7c677825529d6a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27906 zcmV(~K+nHYNk&HcYybdPMM6+kP&iEPYybc+p8{9_Rf~cENE<-xA6oYpAR;CJJ#QTR zm-B@e;>*1P`C@I=I)C6AkMsN{%5A$R!0!F(7 z>$VFr?HT;obhB-H94iObd++4ETW?}6QroA^Xz`pQX+|r(3}%|(B0N=W3mZ=b)IbAU z1Fizq0pXMbsan9z;0Z;#EkJ5h?`lwrQ;4lX>4vnT7I1^BVjG}_AMdMB1=|$frQV}1 zRL3@e+Z6ZXt*THBq*OqES7CaW=oc2FKx&%vhzf^xv{$(3+)iau2DdobpgRZK%s_Ff&tm+AuSfXSv;B zPI+vTKNF_g?nmzBGiDxB`n1>^=)&&;`jFtoV>hL)L=ia9OwtW&1q zxDB~HVNUAwG+p2Z;z8T?w9U3{(;>+uQ(`;No^;(8-Z=je&=B~(u#m*e!o#C&+in_d z+qTwD+R70yF4q;$|AfMQyD|oWgmT*2tN(7;NKz#CJuTeLhs+V>YTK4KP8$EsJ?Jw$W44FP%y5{QtuZr$WVSra%uJF(lFoqqEtHfoYj>CJbdqM=LTEd! zOhbPG(?MvPbN*lhvrJD6{Y)v@riB!cE&8~Hv_gHPgQ+1^WSg?mKq|<}`_z_g3du??*HHDq?Iy3I_x2#Vjvb^(4Z7;+fLhB=SYT_Qto5^{~uz=4LIaj z3`bJ`9k`98NUmk(-NOf9(f@y)Y&qNL;;JrYW@fJHs_LqSs;;W;V(O}{W`=|J-eI2E z?Wz8s?laW=JV6&7g%UNx2`^$|W8-E6!4qa?Qp}9Kgv2sO$D?FUjvY|D z-nl4a+va+=itX-Zt2iyoQ!oAjSMiCzfqW){^> zI@`8Y-Z>S6wv8m2Kb(bhEZzof+qN;4|Nre&^q&peNQ!*)%oRgv$^HLzkDGIZ(KyB- z$2eq+d3eU`XP7C>yy~U>JmYyrHJ+ekDK}s^Lm_yAL1j)57+Ph^aeajbTzCpw^2nJB z6g=hj0AV_T)sRn6Ddbg_B}>5*L_)8!N6S~RWV!GZmiS1{5z2gkFkB$AI#zOnzvN2c zZOHhGu)^{`e@yN;*>Vd*@gb7}OuTb(T%aWzw2^{t+%aSE4Z~{)Oe_=@hXAV#b z!*hVrGfFN{a@3pw%NMZb$r&tpB=52;S)Mt=XdLjrw2b5#X--fICtfiOm^4iPe|Q4| z5=_#DV7C6VVH-)2N8jB&GuN%edB*#X_uu8r)mG}#m9fm?5c7bF3}v^D*E<8lA?BWj zY}P&D-s?PnSK+CVK@UU4=^kQ z1)q~b6bOs}hl1BC31kw49VZS<1b{{n0R#ZDC zc!-dj6VJ}G_G^Rz7EStH=A=(FiIyFZWzcTSLluq#97`}5a1^D1!KNoB0=EDvn;Jei zA%J&$$728k7}-Xvm{{1Agm>R@jCY~NJX~-b0e~wrCZ4xrD1ke^<6r_oba#nnW-{Rb zfnc@K?hT4Z*=r4mQ?2G`^x%a6+;K(55^_psdNl&YDBUISlFn#k;|K-=4d$z{n5luS zIl@C^yZd?JugTQ-E9wE3@SKghf66&jDqnv9ea?Mf|2Uc6=Eg%f!3l&#YA4^w`24As zcL3cwz3)So#oYvecb|wXLAv=)dR7&`SOEZsqVy3GQAC!=F8qg$y(Mm0rqEK|^WlM-`4VFUQ>0d9uhajUW->p6_)z<4=;yi>S zmrR@AhaV_|-$MwKn$z(2PX*y(O-d4s2tIyvyRx0_=ac261EX?oy zz0ev3)W=0p+vW3xhcXtsPn-AizhCov6~Pe(!gDg>{k(*CmZuUW?C<%NXWt&OcRsdw z(DJ1QOCf9Zt``7q`ynizWboyV!~jPwnO48&Z#aekcoR5JFAK?Lcm4nX#W9`v$YKwb z4LI4iWNLn+LH#N$(5K{=a*_ClhfXZz(R4`VF$9w_AeU7fJQ4pRXTVU`UdQ#}0b zbtzl@s)sJ$nT+U+E&u>ESWalyX<0W6VYp*cq?;#YMEX?9dw?VNY%%#zxl?o}*vE>n z*)NtnG_S<60YGR;0`9N5-e7Qf5Uh4-(sE@6efW;|sY2<$KbM4cufT5Tg<{Macl)HD5U>#f4YH9y-1j*4SW0IT zop9s&nElIXZuSs4P8AmX^RH9TDTh(95hqpZRN)wTy0*@m4`p?tTJD-X{0V;02YGSR zI$FOD0C8M$cO4fm$jHftr)aUzVX7Ey4;?u+HCxSiD`xte*>-Oa(+%Y_mfOfwFJ#+aqN6$L@B zUp^5EE5{W#+EVkj{!9!k05H}h@bZcpOa_zf({^BmAlo*&Plp>5ww>*u{uGy0i+M1)=RTZZ+ z@NxJzOa`Z;)VbxAfyJ15iBas_0a=Ef`yxWJiXvu!9TChX@kkDK0QFT3+xISq3P%wD zhf;I(gLYw?4&vZCjYZ$k(Bl;VfUCT@LpN@fwP*nHed>FQT*LYn0C31v7e8p!J>!8c zHllGlY~Ikgm;X?~x9g7agwDs7K00sopDyex(p^HoRZlZK0&pUyA^vI{O}v4r`MlrM z<@t~IAMZcj0WGYF)w5cvA#NUo-Bq~Z`8Y`t?bipJu=Sp^CD6MqC{+l z{oF`q1yf}1LWyPg?HN>Agdg<~8zFl{4yO*m1`e;d0oX$tAT~vOil=BopaB66b^{Fn z>Ht*w_a=yq3DpHUogqDLn+e zMhUq4C)#g}Y9b*Bnb*39&Tm$Rh60S}vbKgdg{ZUCqn@;Bru!cCO8BTTGdDxr_*rK1 zc}3QR5H;pj<74CigM7?rvolJG5qfN`9Er^r1Y3s!r!I2*?kXrC_L6ByvFnzOwTt?Sri2ADF}k^f5oX~9vHs#J|a%mLLL-JtCod~hF*Vs)(&F$)r zDA4R*e`SuO-Qg~i@cEN%6OF~H&68urpfs_@dwW9HQP@FRV!P(hfo^peaOztn*QEnz zl25Mei>M7(r$IUCqKx56E)oxZv})$WmF%66gg0Pp7U60Ql(RK-wIEO!w|U; zvX$NQ$i0*GosQS*mri4=dE-hVICB%#)QH57|*me0PoFx!PqAGbW9J_w?C zQ)+kW?5J{85XD0iE$JH)Mx0XX_g5#}ckt;#0~X87?hOfR?xge-^&8xd z!O$eyIQTiVXwu%s+V8r3FgA$x+6Dx-k%3X0_3%gZTp#>vTkU~SoxAdpr)m#A_^Rp? z+CsU8W7dBrC-P0 zjP5mShmjonFO^P1N`Fh|^t`>AaEzK^BtJ+ZO^^`9U0ZU}{v2+OyVWO0Vs;4VLj6}O znUx&Q_G{4PR=BefEuVFfOU!}$$~A)3pi5kFCL9|MXA2p$X)`x}*!fa%I9qLjQC;+b zSu`I=4oQx709%jRW?E|x=>W~N!dZ|2fzbAZnj&xjv6^(WkxEl@a|;)@mFw<;s#9Mh zURSDO-a5N2fW~1XCoC@)(W*#1I&Xby;Du4VPPV5e5`tGVqRwaETZWrh^X}l}kYQ8X z<>|9UF_(M_@uy`ykv6djV~Uwev(pV(Cu~wpS@y*YWAuFm@3S=*Xeq-;y&^>M*;pIC zDr?bU3i`~-z#EzY$7m)4{rcF?P4u$-Bi;7k;tW1sCHk~aFIz>AO{`u#KwC>@Iw{sk zSvgo52Bscz>gJjELGMjCYi4P%Em{G-HQ+lS7PW1NEuEYkJmDpLiM4OUO83#RriW29 zsdUn89W4Uf8?JQ!YHyy)8gIu*KzxlXtNUy-1g*l=G+GdK z;ZulD?fl}gwhqfQ&$Y&(eC1zk%2ltm<%)S3kS=m2MVX&7p_al9;u6_5MMt`=P~ST8 zT{dVovf0N9BPZF{l9S@y4QY7Gj$*2BHOAIkU~)h7f{|QQynWK^*W*C5Yfx)6``%q( z!KTJytzyr|ZF=FNY?!hk0oWRFQ-@%E!nU_uR66QPwqM=8v+K-9G;1N*8q4HFk7r8#;B{6w4RQTvIxD9}H)6Djl%^VeQLAszlM|h1Mx#nH zW~sR0c0OUGcUeqnd?T|b@H0l6@5%K zXZ(i}b~M*ot4n)prYj~H%d}R{ZF0^1cAK=XZ02w(`3}63-!m%XZ1cd0O#48q`V?@u<%&>zYm=qqvb$?s>L7XaaSXa9kqRJU(0h!g09)fTm~Kz8Bcq)xkodE= zHnvG)ZTxr;!q#=#`790Q6%uztQG6tHfwFh5d?#!b&*Pqz*0ubn z6=d}&6B0sH84^pGQUr2of*^Fp8VbjQ@u+;Y5OhaH9@Yy-AFP#uEJ496MRhHjfUoa@=z ziX8yzGy4CnE#DDr18?VW>O1ys1c!%JWJjQeZ2y49j_+VO>KKZ&HMRA?)_mUXf4Nmq z9lDHyPeC5xIETwCA*C_1*qYUzjGacdqg)Z&Vas)O0#7rmWOAxeJ%g;C(_9L@@sib1 z0p!x??=D>Rp84*U(bL2#pvdL9sPkYQsgCig0N{9WcP%DaE>5$o>bSf556|;|CZB&B ztSbYkg?)RQBB!=!h+2BR2xc??%$V+w!f?Fz`yNqFg_M%H%;_=^vVJX9;@lm%vAECa z*75%1{m1){_utyKK`HauNdq7jorMxFfFd`8jDS^^f_Dhk?TQ9xIAI}zX>dqqvJI-& zZE96sNpUrJ3joZB!OjLOa~2r@0E~Ds*^0nCuaq;lH0OaQ3EGiPK zklqae<30NG|9hsvZ=E?}?(^8dHY->_8AQ&wA<7-*$Xfh? zK6#z$5c1@Z2{wWCehC0D;+yaNP%k%NN`Eu}X#Gh4{GS5=4HJ%n%m5W_$iiHbmN@k3 zBkL2EQT}>g`|BV3LmeU-B;HxHX3D=CS#)(v_b@bg9C$=*S7?&dV8P5%^e|j~V83A= zjjw#5-}mV`l>2og#N?*tMy>I_J^=tQBL%tsF2mq9)^N~arl;-tCKqwwc{m*$1t27#TW?ztX^K*mr8#k9x4jbj#kvgOh zuvK$!8~^@TXVda{>;hZQ@BmF_6oybH!HtS_uNAirB9!RIdtbOIUrzjZ3;p=N12r5I zvvcPl*}z;FZ+VcSH4Jna1sNym)@6(Xlb8ROzP3c1u*H0ywQf^II@TKz?fb0n8hAX} zM!(ek%ImaQzW%!lFh|b5%Ki(Eb+W%beiNKi`saTSu;B*~inQE$-R%;7z!!j+{^Kb6 z2PYVo;mtD)8vPR0H1M$x`lb&{q2h;_Q*dstz2~sU-`nnPrsGXs`ep!jB6A|2W8`{U z%iZ?=xOs{*jS}aETbH8j6Cb6=-%4*(>_aW)SPl6Fwed8O;}^@}&EGuL8+5Sq7&p-E z_2zG$WtQaZkrprfo_jy{K^xkrGIjo*vPAY=5uWD)H__kw_!-x_IiSkiYsK_mTOoXN zyp@vgy7vtO+w-fzH=QqPiYenOofwL-A(* z?}3~sZn=Era=yu5uZJG^`zVEzmsG^A{XRApm&`9zUf>)2W!ujkM#&}9gzW)Vt1^}>i9)nKV?-y9y;$(+k zCFch>D9!_73Q2|R*CWjZPce9wvW?~hUuu6meshzlzf;%p3m)MZmpSp{h|dOdcE@OE z=XwGF0C&mS?byTA*$&trX6&+>cAzzr`Xb8h|Vrhj`qbpJnFD*r~VtH!@R2LJf4 z(rmbO)L4Dbz1|AbHp4N8rhk4e5ikVL ztVkGE_*8`n{JDTas0TZu|r-KHvg)4Azo<_>u`>UDKc{5qhH zp{(2F&k}ZRORS%(ZL2Qkc#Bh=gUgLt*QS?Ve@YUFm7DK<`Xfc+3%Tg3>E6eib-jJ5 zM;flqyS?6Y>lx0l@$6MNNrf(5!O~bEk*1tdY zru8H99;*Ts9%?1t;j%cb&$(WySdLk14A}8 zx7SXjI_rJRRJutX<;eXpI+yskj`8Yi{`gp{Jx$!-!y3eRzL@J>+p+#i6ROM0{~{9{lUadYMmD9&RmRce(`Yy+okcaUEm6tDcv> z#i7q!NEh*FThDMmgZkXcf{eB>g~pTGS3vD|U~!rZQHrg_^fv)sMBhUcqzE!H+$h=B z)bQ&J2u~o4q_y?Kf968I z32(iUMwniMDwY5DJJCdKhQW9w%)al{6hg{M-j*N|p*W{l}gU z4|+6}PHl8XHhk`O-p~a>8Iw$9AAW7=cwyH$rv^{_Udo@_!G;-i?a)c%7tqy|t89tN z3BXcrDtWweTe=#e-GcO5sn%b^t_hF`hQ=8yhp#|S4{jFo(hN*WQYu;=ob3jU_AwYFot zxz>DL?&V%C*_rR&R7uW&8jz`^@40-X!Fda}7t{u9OH(p)0hn7d!)~$wnH!W5;r`xKzN5Wwi{QAsbK9 z+N3qkTJzh_4LqM{WhEiYWSu9HzT+@(Q6UR%Xh^TWHQV`{b6P@+SnIyuGmpl0=)h-{ z^;EpW5OQ{60o=%toNLl_gDr5t3InmPtSr0YAguNk_lwx$bY&&Q=t-0C-WDwlj=30< zcOD|XfgHFY@FhP~OM^Sv9BsGm>un4`eVhQGB)aa8aWcB&nyuva&Gi@q3>;77Vs@;* z;<&Drm6ecR;G}C{J=v5{9%Xn)?_7pFV97uo_=4fsS5{unoo3@3Ky1_%UYn0O z-u=+vw&f-~+H57;l_L*#iLFnOO5(MbVkN)d0MH;MU;FjZA8oc00>$>%#~En1n~WJ^ zzr%JaDT=8F6IC>&-Pg zDzE>guromngG;mZ`^n4$a@cCCu*cv4#r9YN0IZ5w0u$JKL#W6{%rshFUe1thiUYrc zjN?}=SHhPj@!7zl6)>{|l^vnsGDtyFLGJs)j3nTKG6Zig0MP2R5ZzuWfem%@+9Yfp zfEtHG07aPwtLN6s8PoZjfXKn#iz_V5zPRL~H7r*m?3Z-C$sC%>w!C~3t#0ws*cZCN zEFCUWXt^?3c+$YXFCoWfHO#dkRg@ThdU-jn))ogLPe2{fb45$6h@=25QQHo`sXUmb z?u~I?H2_HUs-D|)aJKtX{06MO0hl4L$MEUx*6`CAYWDcvxHk+XU3t#{Emy+rEOt?h zvIungNQM$Cp-*L4kE6-8?2Y<_6!AoUh12qC>(~L#2=8P(IMEu zK!pvv<&?KtrpsPTz|=LWD4d2H3Un}OH|k)(!iHQ+FX;N^!Z#D_d8um_0};h2Ri*Vk z!yBz2qNRypO5w_01r(z-rKj%^As|4VWJ+yBsAV_-O>6DlH3(KxWUAk%omaU)GZSI( zlBLkKB@?K)$tlR;GNh}w1f2-O#K1SgVv$**36DHAuk}7&z-00=7v^<(JkKf+TeH#q zYh=36ixv1ce?U`|(W%$**JkFBgx`u;WhMCBRlBaN(ALSX0;ryi@&O9Au0V%}2<^vl z&X1oLf=LHRBUq8ptk=FgWS3x6n?~e~;nN3n8oZ)~`JZpALSo6o+E4M>O0QO6Y`%M+ zrL)mPln8)qpU!wYB$Ridk8QO}B!TcT!2nuEhPhS}=oAeOJZ;}UmHp?fB&a+*>xNJf zvv8G2M}id+M#?fv@a<&^4uz0WiUjz>4XS1DXDAUw-+U<0sTdo2O8W#gLqMl_kL4N`ZcNfAVFqE3ROW(qPmM#ralPgHp{#bJuT{+I zmmo|}hyLDP@)E~o55ar^5LvR|`ZYTp)UI{4LHY&{_&iBrSh@Jtf#B=d1L$!=IaEn> z^@ASCQfGqGv0Jcrmu^Ix6iaM83S9m2QQ>JSdvGGVZ;GnNlT0o%*qZ?@9Ub z?Udo1C?@w{(eXEtq&J+Rls1aXdS|hAvF?QKu#eeCUN6LGU;p4I{HfSG8z!fNb+oE` zR>FA!kGJA@H2P4o*BjTT{P%QHaid12#>m2d{dFp~lmHbWd~-gvLn1>ER26QAE-HTu z@{-yvUb_-}AI~9P{D|W7n?NzHiha|k^Z5fu5%AWrXp7=m`4%OP;rFEf_iaH|8(;1l z0i<@IYSUO67^o^d9KYUhXM^xky$=Q$t%#VR0A>qV%=wdO-7+HSJB24^8 zsUSVhDvL=XNOwvW5;94r63lKXq7s)$qDnHea4GxGr@Y{1>F;KUw_}Q+#sT42m0IiF zJNe3Y59bU52OgMZ_#(}w#H~o#uY$;z)BNfo|D^-~?IB5NlCWd@+uxJ+zf)3n6F&6- z@aN)6x6_WD69bH#K1TcXIw8FYmi_t%KmM<3V)ca(S^!Y!vYEe~(nKX&w#+%3+!is% zMxdIYy26I-;R)Fs&9<}oI z+`0c_l~mbg+d<$!_MMBF0H@3 zr6?2~Ek|U~<6215wKluEkF^oS(}jQw!uihUOvuRFZgmwzyq@Njcjl)G0JO&>rAxw( z4Jzn+Qhq$f9j^3h1gMqS{D2`&Ll(`-YjKuZPk4l`q zJ4VKkZs2_IZ6$RR0P9lGMhFiu)1D&+uFIk2k2sp#4n({=SAV70Olj zdL>)ql5hQyOuqlEu^M*%!Tp%Zn|{G(m&x;i1=u6n+}+PT{f;?pGO2M<%U6pJ`=y*e zt+xCF;B+}7aTsCiK(rucSt?uMwa7}x9Y=1l?>E^QI*YZYHZ`K-u2O|$AP!=e8kJL; z(<((sf?_9ei`6(~IIYrHWXuLPO(cd1tf2QHMR6cI)hN8u9BeXhqke(kwyTjkB|Ct2 zD2K!Z;$|{W;JZaM`!bo=Tl?c>L2(#A%2sB4Q(14T()b?mAg5 zkHrkqMAm4F&}z>)4nt^PJvL*BEJ<+p zQXEfRi%FJ}kdqh@uP>+Z=Gbtw2v-e;o!4;Y6wV8}Hpd#UQ0v)G$K5mpm3I|xwUr8J z)01{OJAP`GErkIK5Ljcs+i~xm6^M1*@lJ^L>Wsv`%Y#1Qt@Pb~b-|fWZC_uGWb*wl zRd-kq;ATQ4FHX2vnvJ%H0e3{}nJ?DC?}*DLvNQDHc_$J#;k23pHfRS4sWhK{y$5xV zY$LCJ?*Euz0>)C#dd!A6NTcPo@mpl>sv}!&N1eQl49(x z|BHOYSoH^Ek(i{aGtQuCNB=v`Ck6_kG?qKNQEJJv5q(h-g40eN-Q8}nF?$}Hd)J{ zQ1so~bcL93M8V>#n53hISI8pg$h6pCrbS~h^_T?75M>DOaG1s99qcm5HtjAAgMqiIT zSZP^#=v!(k0AFPeO>hoDoOJfCmW!RvBG1$@rznnvi`*1WeJl2JHaQs_!UadUy7Zk?)?>-7gpg$fFunt5Lggr(-wlZK$O`BMs z8nr3W8R1BYv!tzZ(_o+rp0PY8KP^oUtq6i;&^WY}Ql`mX`e)lfBpkL}m1&iBE;f9CSd z&XWV7>nsIblx>N?`MVfmCmY(Yt=vTO?fH^{^O)(=yk1ob`=*-vXlZgIO*Y?qp*hp> zl8%(FF}z%~w7BTH&V8Ik z@0|355bgJjd@reZG197%By7Tr#$HQbBTKGCBXupg>V3;893^vZIe$`vl|k?k%2QuJ z&il&`sqZ~<4XRtf;+MY79QI8I`e_8bakS@n?CgbvbBDAA95Rtz+mGP{BSX09E=Gn} z*?@&ZE;R=On|K=A-IIY4g3%NXhf()*V+*go!|XdHlMFaLr)YxZzG6~KCxjwE%PH(z zM;@Xhrv?B}rxdrqa|xzc5fIH<>7XXPQKRQAhL(BXst-pE%<51g8~^}7vrm$4X>|E$ zaqg=%qsn~Sxsf>+k`E?}A+z0#}?L&h5Ey#S-%nT*vC zoWN}PZYMYpS)eoPx{r*|GkCO1iB*wrT?_qZO!?ttywZ4+@-VWk(u5W0>`XSHF7Rcx z{JrvfNn9!AexF@c-BPv(_x`(DB~DA4mL%dkLtj_k`(0&^7%nN{2f^7{ypcMtJtyE+ zbcs>hq6B!E+-vtfzqXx{_l(5>%x5Jj3&>lD?To>z^mK3g_C?|7ze))^WA9bg*dpiK zur!z1D-E=gAHHS(Z5%m2oc8gkR?W?q8_g$DOHsxrR#^~2Ev0Oc%zInup~YNBl`xUX ztOfZp7y4}X89#~HXz$$pO0NB^&f+?5zsnddw=-AT+(T~oaJtyyTkcXI<2G~&YE7Sh z5xcJMZ8sj&wM*x%y#J}haje$dac!KqmvSbywDM+@SYY5lws3gG-<=cpe)xZcR}KzS zZ~rQ~FUx*Syv{7xb_*wy@TB`W?);8Nw3QSMobJTgAut?3?K0+U1O~b!w@OO*p z*LzX-=63Sxx7mTyvRK17iTw4^109IZT7={5ovblQQNW^9OL1`GG$B*j`>Ky_zjV!y zeCCk}ai%y?g3vz~->aslR0|olokXSK`^50~_9n3s4?Yv;Ciyej_Y*33ZX3JFO!M%k8dWfcPMIu0-vq8} z_;QzSQA55LFLDtmO3NXm%gYDOXL*r{mAU${ z>oj6}&*b78P&|1E^r zb>*ZR3J+o-9b4^O40eI545DiDl%kK{WtgmDs{+V(8;jppRuzoUb*Jp?3I|eQH?nUo zMlf?ax}*nR55~Bpg5Da-9bL;=sSU!|{J`y6ay`kGa~o^i{QkudKa0%XRZ4lNIZe?- zHHw~y8J2X!)Ey$BiJeuDi`{vY43w#qQuFr5$)3hKTt&2QQ*&5T7b{vu%68YBDoMPz zrAL47Djid(lwq#QpxTkTODg%vT0nQD`?T*b2xc(0l6o3CmT|jEVm%`-EItG`1X~`G ziLM$;o&D)typXkPy9C(=G6*KJ)ggZpLtC9;Av#SL)Kyysz4F;jtl85eqoG)=uTnmx12`gV&Ng zmg6P%#(Fu3?bwLDEaIaioru9lYbYTm*~W=;K<$38mVTZ37SoLrYP*ONY&>qz4Gk^j zI(JRz04cOE0OXBTBtBF_C_JvLN*31lnCcQ&(}fZjbcIH7=)6Ow19R_HCWJswVLxXP z6(v1HOUR0e5?w-QsyGk%bwuyY{CsR92|KQk9ES1U#NfJapAqOFN(|pe6w9|Tpu40b zHtI5~M;D@hyMOtv7>0 zYY!9BKj#PH$nB}yCmzl2P!_aw%Nun4Qzpoe!-OSyXXp2m~DV~P>6onUOhpIX|M5fgQi zn^X?gL7BM#&at!}TGx~c6pFnk!5?c6;@hUHKP z(C4cBmz2`5G%ab1-;%JdX)2^0(u7suy~r(W&2BNms#ZygLKGLcfEc}-WCv9$lNwwr zqQmqXt=*a2VmVTiXk6P?tr8~*$hil5)F`~Z+PO=BO=PHXRVgAnG!CE#TA9R#;hTx9 zwzz&(Im2tg3zuFcDv3c1Y$V^)*KCHHw>Lb1ss;fUfj~dG);8!MCd)?5G&fOs0Ch8HB@R24A}{Lm0I;3P zqGVDSP{TSBz^G$PW~C`2QBK1MigHM2cNZeRa8bc_x}s1GgT0zb?6ff`0028#NKUPm z7Zy4$a7h$pz8aQ@T2LGm9~S_%M&-v-nPo*qL<7*`XzDm|iP)*8UdU;THau*eiqpXiiHTCU6>1*(v=ks2(}eE3?-pc`z@slq42_n9-_k~nWDDLX%Lo$ znk<6YHv9vp$+pfViUS@EqwlTlh;FTwmi)r`%zm^V#t?i z^NX5BjV$8mK0YK()TwGpt#O%1iRM~*y0)@hc5JH4cS2q;qhD(3EqlnKqe*k zH*fiw%sqtepE@R}Q^RRAUO&gH-wmuYVkfqVCVlne>I$d1X(i=3umj8?*i~{-`BDV@ zzf)RfcF^q&Gc?`4bRz`JSlM!^iZ6GeS$p>#gzQIULh8D6(sxM&IfYmWN$ZrYvFo1u z{G%sU{-R=tsdJx};aV@|L`RGHf%4--t8eaky$;uMv7FJ2cp08_BF&jv=>=m=L#XVj zxf`wV@j%VgbiS?W%emYPLtBZ3CfdgfE8bm789}!Uk#bA*NnWhuqSp8L_x~DjRhkEv zUGPpBUc|>BQEsid7pHW%UkQ;BR6n^h1=mGdLwYvsfU+NPj9xI^<^cgm)$~n55K@w( z?pbO0qXV?c#bTl=dVVm)`x6{P)ncx(;&^>-@+zV2Usy0m3GQ<%aVLMh0KeqemF@d$ zdvj0SPI2o1SPs6L6LXMk3*2v=@i#InAYf^)Ic-m@dH1zn<6-(Z>Mv`WCu(-`h6>a_W4W;q@->@-A(*gsFa8`+c-L{(%MaW9n;9HOKk? z?*+@i!1V>{`6YBhm4mR1_M36Pt?2>Cwx-)a_^wXKmC0$52qE;!nW<^|W^|?|1?B*9 z1XIl=0P$V{(kw00VIFDdwZx3A3wuu2=sr$VTsU!_E9XhM$Njm{9z9?FoJ(^r5RP2-dn4Kn3HK2@5c);CQLw-G{k6f6HJm1Fy$&*C*8Z*6mzr083B2_1eK<0QS`$J6w`3^lNtc^n%%m3xCa0?Vxjjn-^tb)24$H`2zAmCtU3{^0scSMwInPp*K$s9S z7iEC}sX(iG)grY>7jFSbM^e;luf1wU86rdx36)0VD?QQiNI@N9Mc0EIBn#jrd7@2b zEwaQ<1j5j$UVGInm566p0Khi{Nr^P57HJ|(DL@I7Vl<#mGL(VnI*=`?(D9Wb$tPV! zuN`c&{V2f@H8$4?EVxlb@?@w8B=e0q#0wbIj^#y03>BW>=A5n02>>RcO9c{?wgBMh zw#Wwu@DTv1@@O^zB;n2SV5o#)@sDkksRsZpgIx(Q~L z2`%X+3Gmx|%>(bzCyOv=W;zA%s26J%=G2U10bgWiZV@G5SXBy5*32rwklu)QSaAZ_ z0+<_sdm7|j9uY4@r~Q}^DS@%^Sd;}hn^!16q&yk(J!!_=CGZV3WNnNTKsC&s%nt!= z6ab?*SM?z*Frvj!AesWeaZT0~az&G)=`(;jKD!$MQ}AV(+E^w52%+Qb%{+iu)|w^l zPXZWpgn{du1VIlMzY|OUh+d2CmmoOGiBoJjx?>B$(HVejLYf!80i>^QXUJJ%0lL#PEf)jY7wy6ILJ0 z3(;*8VgyKO(QGWp#TK-KD@wzVLK)+*L|yKn>q{Mr0v^_jcP62nmfD*H-KVFz)=Q~S zBtRiYW0?eg`_6CgK#b0BhdxdL7*w{X1_1Cud%SNX7*NjW5a?cJjU4REG%PJ-Wdkc0 zqTkY@6zG4NvUDLP!5^pHKYQvrIPO6Q|47+?8;&J6%f}8oP;d&iQyTy@Il#RNvuf&n z+rJSo32n^4x*(jTuuBlZpu1eRKSqg!&)(E0UE;TeStfSajb==7b|*GaM)d~;BIwI1 zx$#8mPiImi0-j0d&3?f4)-&C7&s9w~NvxV7$WiX0PbDHIXJ|SXq;4jZl${##?Ago4 zDf{xbsr$AI?Iy(t89-CGt(f`oB@m25Q^253_}(kWqFOHPd!`{66Nd?1%9s?2oNRW} zOPb)fMcC6pGzaRQ%SrO3*>a18RvYXmu5o?A9 z%jXk^CYSwjVL!WOC%t&~^zKJDSlT;@Xl6A7CD5#xzwUWIuA52}fbvrmXdC9_%#1>f zg^2+DCud*9`p9&tomIQiHwcX>p7Kw0qEVoWr!Smf2TjddIC#NChotD*c3}xm3pP$x zX&P25S;06;ESca8Qa+J{1t<9x8dbc>+(tt_06gFBE|HxBV&@bh4F0uI(9mO+3m$6`e997CMQkE_-9a+V@=7dG;y6uWT+yCpn%KWbDG3ZSME_q)I z)M+=mV&MWnUGsE~AVS;rIYb^t_tu;CU-tduKm2PQzl>(p*6IJV=fBMCzrchZ8rwfh zukujJDUv;*e1PdGS+IQ_jHFoHyJ6ibgZ-}PzoI*>Cr0ODtM%Gh?hpJu4p_C=mQ}oM zC{73=(@DwOXHQD9>i_RNL?GRiv+g@xyh|*04asS!`6nFv$hFixR{(%0k_NH1+x{2l zm;hXlt5kCa(AJ6zu*WbbycE}JuL$6=g%j^FH=T9ncA@70XgfhTp3XJ<#~=2S03d+M z!&*)j#)i7PPkRn%KJFR-NuK!lfyNX=!xh^+i!m?_R-t5CeW=RSUgI8?eO#B&2*%60 zYAt(%0>bSK%_#kilueC)WTPAPZb|1VM*w;lpHaR6fMXx+9bYc}$`rwz+JB{Zb?{YF z^aX%NtvuKZ0AU*MMmJjSj&HR@0Kj&VZgitxaAPyv0RW)OdG+hgX7yVJ+x{fZHP@x= z_~N4gfNi~^%GJknZJGM$s6`)Q^DXq&5BO6zSW*n`}v$fonFgHL42JHeP7H&Mis&tS}pQYTh zJ^VR^*}!C$uG&>Vv{HW8mRh(VcLU}`!HgR@f7`q^;x$S`D(ijd;)KRGgt1 zy%~!8%ij^k7^pO-IlsImh^Jsa1CVtvU`j7T?P+NvRdoRGq47n{PKI z&f!WI8&H+sqf4UmTI2WMR^k`Cc4^$~=d%6448n_?DB|0&;wdebDt&s-)<2Qyhkg1X z;gJAEl*7s$(Rz%hPr)K*vnZtjW}+wrJ6Sx{;)%T$Ql7&JL+F=HJONt+w52fc4j*Oc z!$-;j4fytCNO#VOC(Y*yKzu63HODhSpBv34Pj>);xfphTOAk8Psv$TjY=s~Y#R&;3 zBq`9D#XBivl?Wh08P!SEB@7vo#m9U1AHF}$v+*(uKtLgE6_apDigZRB0aT|brcUR_ zz1d+>pd{;**&0NRQ8Aw!s*(-Q%`j35ITKKn5tUj1ij^|b4_dvw<7e-$e#!!qU_jv( zP{;YLly;0sK!{m9C001(s#6^O$d>VW$f0c&7-?Wy$_p5h5(C4^J0aWwo?F03Y2-`* zAx3x#06?=`+yNgWUMnRXmjd8tv>8SIMNjyubP8_^a6b$uE=iK3s7exmx#^q+bt{%U{%F z6Ws!rIrOyTI7Z{F7#1wLNhh#J4r9^Vx75h0?EdIJDc$wSqj65vgd_Myi)J=(8OrN2 z$0M~9PFM&7uVB`Sr^C4&r?6~Rqt6u*0RRq#;^uoOVJL>LBN?r;L_>ESju;LwmhF!5 z)IuygAmrE3ugwayJdHm+N;m;p`UWXkc9ySc&0-!QtFG z+H_?wWOu_H3kCA_7i}?r22ixyRr;Pjc3`f*)Z>wlr_@hOO3AbtxV*+OUV}iT!jZj5 zfVXuJ6V2a&t1ew1BRxDjc=az42H@|n#$qJ<7Jz6SjV<)qbtv-x4&ugC%zTjnyw%22 zFq9bkd{vf4PfmAShmnGoYP3TlQ=lK)j?|8Jo4=KaN_9)B{ARb{K&G*;#IZLw`6L0q2&vL z%C=5Q*WK-_Z#agEV;*&XIjN+q$(iPx!NIJVbduv?((R9gXk%oX((WErxVtA6!|c;D z7;#Xzs$~P8@@$)zjMYnyr%-@NMrIlwFKV5<;Q`)yigWuYsc3cbnd;d$!Vv@j?mxw} z@xM+D%)4YS&K`F2C~pKGWVX<=%d)jPRWKzKZ7{*U(X1Sg21^pR>w;*Sz-=gsWa(tU z-tEvM9I>Rvbkh4vUVhj7d;^w{wcDx7Q)gYk$1n-t4!Il308M&j%9<~rT9R75LqHTY!J`{&SeaI5(EI#cS#~Dk#Pm|x0xa!7u>RBm_@yuJ6A6IRQ8V+hT$H{G+ zmeop^C-aFshzkN0om6x;V=U1^L~edVU?*1bw>0?tJL=CY9$Y}WmlE(Xb}U*W97o>X4tP7N@&V95QnCm;HJ zeCGiMxKTdAr)NY#C9+saeXxmy%$`T+ZO= z3_tM3bsn-@SUWtIGE+9O%_k=f(RZxJLzX6s%eCcLggstjMMIBQ_`c}-IldeOo+nrd zf^l6Q#VI<>OiK?68#WAEgOV|wC00^mamW}-TTao8T_+)CAS<$bSI>B7t%AS6<37-R zYiiZeirzz>dpoB_y~uIEsx_#O1wX$n@RmM--LPSR)~UOFnixHO_8W8|VhaS!AV!JwDesPaFes97r-$}_1e{R)yCup~@>o-zt;Lk1V zaQAKx`uL6nu)-dvoAtN<+YDU!?j`EwkA!GoWTxYg7Y}aG;eJm5G#L)MpUTP8{`3E9 z4_Gd707hL@&^V#z3qjac_jBfd{$A$IgT%IBLvO2MwO^R^dLPRbPNJJ3*Z0kU zILEwxSzfD=hwmF%v{=Ky$DxER-euE`kpbUZdOMi{d$mi|;amYn_*0e4n;Y?x#I|JRYA9NdOhaB_Rc* zearI`OTN!$y&cn!rSyjy0N_hV2xLmNW=m9J*ue9R4S++(ayTql_<-WdJ*s?L{XTvB zKlpRY#@~Hh13!Ox82IyU_qhB4qdvZ)K0V{GDb2rBn&1Bd0MYrl%j2&Uv5FpzWq(*S zGOxog0Kgm1@$s_zybZkmzxIFxF++c^8~PB{G}BxLe-Ui#0s6Y(*H+8?aQ zsCZH=n-zAamE$-6vSEGa=CtVU1*bn*p-yVe@x(rS;xuaG*go1{P2))@Tvj{LtoYxH z5yJ3{QYs}mk}UW-;*A@bJI!A}mPyYLgujl@@AA44GqJZD(1Bu?MZD+HY3pg-G_vS_ z{R?EqY+{{8rLCvcS!8Pa-48Z=->mvPEnUzAs#0{i>Hg)tV$bza>?vp8PhUjK zb$7teEQ(TgciTIXh-Lub>J90xO+2G~k_uz6g!4u}J01tcvne-socQZsso}u7XV{$b zf6HUVO)@aY9xH-elAwsUqYSVZYc%9`UJRq%^@4B!06wMLOKl?t{&ej)M}?~jyp;Hm z07%;J(VP})<8r=mk6OMigFk)8d03%*Hp9MrMSOri?{OF3<8uE6L`2j8UR%V{qLt`u6O+SUVA{XfqvKW>ndbD+!eI%=gj}VCdGHT zcscf15oA{(#&=y*Z~fJZ58rMzQWMp+o?BfYfE%Mbf5 zRk_+tS^PI{*}wTK#5Aq2Dx+{NZ4rxO6r#A=HFL;E&)_Qr`I=08IBW7KB67jD=}WqK zdVKUgyn8&bJ}*+38ZNWahJKoWcaH`>j-`f@(j_+O_)in8#QplT+4A&Z_5D{m3L(dN z0Sw!G{v(RdONDfyQAT%Cck|sWYNb8Vx;9;rzwzNkr5LTe6sd$UtTOM@+|dguD?);{ zyT2D*hkW>;`b0(9>2WOk<pstHgVdyMn+3Gm8BkQezsvc{9bPfu4)CN;KYhlW%o1eN|4&D~XIGzWNgFyT|zAWtcuqNNE_HRc>o zeiTcCWn{XIwA7O?nxMj)tc2LGbo2r8HBP+r5~EJ1s8&1aD4}}l5cbWYH#OK_mf#tJ zo!foO=-fjbmdTzRC3R<)Ckb{y7934?JkI4z zyrY8z@lVDg^3m&_MkV#CvIR7fMPBp0@4oDZNKz=|Fum`(zrx~CGu&DXk>?u5tB~d!82O-MWhw6 z1Y-au^Tje6{ts{{*D@s z&*(pAdBxJ}$xKt~$)hO*C4*+sumNu1fJnnXAr@eYzuKfdDcYe{Q+WXnI~=+S^0V(d zD(9@e4?wNb-GcDli9r@eU?!u!=^}iXfM9g{F$#u3|9l0gRiEP#qqYreHjuUMFvSs@ z_=ITK`$Q96>--n+QNAS=|!D5#7s7I@vFe{?G5lZ`kzi#){B%4Je2okJ7y{K!6 z!?p#UHx&|nieo8VD}|gSLlE9kH8rOb^zh80faJq96E2}CyKuJ(HH=jZCDh|haDGnx8^eZc9&uiMz9b$4d}f-8&Ki zFz4(Jcz^+X2?65$!WTWMwCHU@tF&lNZRkvL<_>B!q5$QUWQc3L@n3h}ni2=!S?)HIyDW~=}005RX_Du}i%gM5xJQ9s35UsD` zVfLBH!`ND+qJ~J{%)J-muM=z&D}R3s0Dz#DCc5^%DSuKWxo=BY<5>f@tEL)@locGU zm`X@+G*6{tToauho8_`-B8v#Id}8gl|I_g2y`tayG_HF1GDVdj(-}oNdoxic=g&|T zQP`0IGIRwU_u$24g)`rF%$$r}j7t0BjceP^d4ANQ-#=-v+#(0~D`>3)Lh>b&X({F3 zqU!vsa*l(oZ3`#c%Kxf+UWC1aa^!W8YKu>3eY5Sf*y{a%xt$<5`Ai{P9IWH3#=R2y%iJiura|inb;toCBU~4vI*#`6z}gx!?2{k+El(8{x&pI6Jk7 ziAb|y($uH&i(|h{?tcnh5v>V&(W<(^oHngDM-Yo5a<4JOk<)z^A&yot=lp8@oM}#h z4nfj%`vxkz?>W~G))}z0Te!!ok}txe8*?1Il$)&-0Nj~RXK%w4aAdHlZth}nR2|^i zaJdy&GMuAK?PLJJH_fNp!kBjh5S$P4ue#5D9iURp4vczN-cxLs*F_|+?raaCBP;_A z=3EBg;DqBvAyOtN0Kf^u(6!U(uCJ&ZZXOnZ=cAEk!JjN}J=zw9j!HH;p1oSgH-PI} zy$+LZg_R`?yYs5Q+TuDFywLyu${>KE-L88uFq>PP`*oEt7{S5Yl~C^s9)qc;E~ogWWC@`(w-xe>25U_4i{u0oJB73tPQ4yR10yRNI+y9qb)7}erKkEtyVcL&u_ z4#2CLi$daT&fEh1RY_Mb$mhB1V-^L>T|0vx@7mS#fBzR>=^;%AP2~CGgX`W%=;l4Q zi%P!kT3G#qJ-iV}RzzMmY>&rbr2V^nvu>ZhBf|bwoTw&BsTv9Z;MKHgs14uHC$egP zIMvU1N|0>kG`4NTq__&Fthyg2MPUFPS2rDpvRM^Ff}a$XKK};%E3FF2XLOg2Znlh& zP9s5=k5Sk~IjP~z1W1D8tBCpERUejEMk!>F(C`>2hbDv1&!|3{&C=Vok|wKG5(Q;r zdgNNGDhd>{x;98tyf)Cn3FjPVaf{iU2yI6UuaYZ;TnTtoy07PlFJq9;eA#M@goH_e zKBM))l<=?*tGKKwj==sQt?JL2ou)D*h(4vSzlnc2Ot3DyFQtKz@9}gOfGGaTO&9K8 zR=cq47E=m}4WO6TkCkB^0gf4l3a&;I0(#~dE~XwaRVJVx1Q2zmQd)#>NKke42Lq(p zA^~iKCiX@%OYn(I2J%WL0~}mEiB75`U9pUIP!8^9W&jV8Xuk3|{AYwcLfYPYeY0fv z!z7BRz1>6rb=vG@LRw<635;w2by^VsX0-@$TLLvCK$u3bs*506kGpTmH1T9zdfnfk z&ch-t>R;4Y%uJuRN+-4qvI?H$NnWkqFZxoy0WTsWf08-_dYDUxL(mi75$<8(pu{K{}op(!_(n?7%=Xt-FB;~XcAZoK+&tMca;iM^xdI7tSngfJ< z{39J%C6zD#P7xZ2MP8>&>pQehOK`l2Z zG6GVCOC>e@1X@p6TDK^vA56Ufx5mKk})CFf4zdH&oIxW*tM_V&5&UwKAO5_1I2U@OZHx#m^iIm7A1@ zF?!~lC}r=Wo_o(do^NtCf>E-cA7is?x0`6}tf_kt_wCx#f0uPU?8{9uBONJGgD+iZ z&yJdghU%8im~8E(2x!}$aPzi$Om%BnZ_-5Fz2~bpKfiF|-S=KXRW6+&;;A{qmUG;k zzIT6ov|8%s-G9-by(LriaODaDhzSMWmdfB$#fsW;Bc@zz`h>)gM869Dk9wh;?F z@LLDSNjPIv1XcI>#`5C@8XT%h&Xpj0zAh`%!iNJiCC|ARq&B{_n(W-upIQCo_nyrq zt>q$tJs91zLZ`U609Zg$ zo{rYBev?(Kd2hoU=m=?v0?2%O>6%xMPG)~(7ix1b#}O>e&dileLKwBD%4tVymT`P#H!e(&k4DeLS7Vt>zrg#zzCB_TE~Kj{P4d<@DzuvjZpb3%89t1XK#mEhQ(FQH$;a%tJ+$s7aekrjl+m6=~ID)C|eFIfn`xxoi za$VZjuM+_PY}NNz?x zWkSbIW4vWUjFb@c7cR&X_HySg9b+WzB|ThoKH=tVHRW*N-uf(}1pmFI=V0aW^9*IF zSX^31?d-wzSU6dtI9%R-K@^LLPR>QRE;L01LuM3 z1pwWY*27JxTiu;RqEd%I$uuekfRRa2G!ou0R2m6j)@EJ+Lr`fhg!i+OQT>X%gzV|G z#|r?!LfTVrpKVAR;0GQx{cj%hds}uwYPUSsbIQHgRl4$(Sw19thryHj{ zkLFU=p@z)9P&?a@s5sSCAxNxI*wiZ$0qL;8-Y@v{n}G)w_Y`XqvGr9!MvuYIZLTJT zSg0v6)#X&-)c4wxXnkt^%#WhYV+kyHk+KZgnA0=4_Qqg#d}?=KBDO7k^O-+7uYxGP@$~$iDXLFlgE$7U zmj(a;kLRd4iQMKUH#Yzc0?U8@fX7c}oB*JdB_nnW-!WAdJ3JUbqhKZ$Y$rgdlY+O- zj#w2idbG5_-S0iUEoLUjEJnb2!=&c{0HCmQx6~!p9y&2i05cJxx64AW3t%a$A?cRa zuB{Aoz82DGoBY5p3mn@>BHf=$$KXM*GU4Akz_Bybl0b6DL0mgVMA_3F1#OA*ghv1i zp+J_QJyntUHUB18)b4yA0Ls2azGqjwafGLoKTi(Y)+moL;4OD;$bOrApg+wtC(hZW zoG#unwceFocoR+Ya!^Z)Wu-#S?%=jB>szUqU`;e#aX+kxTGbSUs}EH|WmEnm_QYE`Al#C0D!csF~F1M5$?WHUK8$Im1EUpNU^P7o#}Qo&ZubW zuK7ju66#{~asLZ!o2ZsZ5Or_>MT`}->;z_~N1~``GY{~IxC|xj`lh#N+5Io4i`3(C z#8V!}U&hjO!4i{T1_3W%9wFc-Y%z#+XL04%rMNhNs3<6&ZTw0b(Ft#T*^z0dQlQGRMI~E=q!$AfpArro=p% zHCCK83AqScA}5LC;A0>=Dk>8O&5iP;?}BHfD`Ic>j% zhWTG-P`GbuQL^9f$4>}5Vq1zhzB({__k3DgS@5t%GlH=?82x&@L1eibsJySAu- z1(Qshjv0ci7-MCX=-d$tcDdB^=&Rwy;UtGRSy7yaNds2n>-H( z?(tU-6kKzr-v1I7Nw5e4wr%J2V-h-m;Pk5E>&v#i=(}eqKLFYmP4}&49hh=6E2VHQ zWPP^)=qOsI06xRG*9m+T^!RKWYrX#J^EHw|?Sv6mw=~2GX(Q5tEYodfYz|W{%)*;I zeIp8X!qOTMyTFkS;JTQym7{MWORLtfT*ziYAPv*hd!LxUvV2~&Ayz08Z<0)bDK=H+ z&z}ADfP{q0teB!H=$0ECw%(@1cwA*kmZ76tUD){(+dycOX4>q>4o$Y%vxufyQY2N< zB|*FtZ8;`(J$nG?_D0FbK>Jj&SynU)P@1w?1`nNRWO*`qQp#w7bsReFKBGrw26LVOC_3kS>cZoVCrmQvq?1mh z;*l{swZKd|@}iqu69N>;Ic<`_QEwE0E>+{72OVqV$bDEfLJ;+1BsM`Hsgq&FAq)N* z7b2&S)vk$RI^TEEd@M&X*^Mjmj)H3I@R*@coo~ho$HgK-kP_sgKc+WvJl7cu*ZB?& zn(N(HGm$yuiX={Uz03r(^KCmant3&bT(9O$7N6$*Uv7mpe3x6{;ON!R6(6D43K}i3 zI1gU;W4qi6dt_o_CIMdg*k#Brx570TF1JGB8eo@OA@MPa%dM~pgP&dK!MV|?fE+6h z&g2*2xZDak#-79UCYfsj^zE*T6;nViw?f=5`!V!j>fPgY+N#Ih5sANQw!#rV9ooiHV1kv8%aF!N5MA7yyejE1UoT diff --git a/docs/img/libraries/libraries-import-tokens-confirm.webp b/docs/img/libraries/libraries-import-tokens-confirm.webp deleted file mode 100644 index cc923ac079cc306c9badf0eab2f0826c53e05500..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30480 zcmV)NK)1hANk&EpcK`raMM6+kP&iEcb^rh`p8{9_Rg2=bZ5%24Z=GB(?}(TH>ew`0 z*~L3;$Z+H-pn!EMr(6&OxvaRn#ReyG;~1}EOH_9$hxlc>wXH3doHQOYR6PbUGcz+Y zGc#ibvXu`eFiL>IZm1rps$TUAO3Y?2nVo{DBhXr93j-MC!3u%hKLL5lpuGt~Sttd3 zDTv8a28w~nm&cO#5|E`d+7zb4(0M3@_Qv~iQ53QjL%YuuMxeEx-3p;?#V{1ZMDUqn zmJ(q1nPMpiW+{wWtL&jriEAt$94qayE4yB8knTDt2a&^=$mznaMDOY9YqmBxvOoz-c%(=@nJmbRfsLaeAX8e{JkGLJ4GUI)(^-zW< zg<;-hX6}C0a+!yj@^mc*mpfEuhI_61Xcvx{>GtYk=9HPadCDrgkb=wi^iT(7W*ADl zhRV#$=yG-FlQJ`;%-nkQz@xV9X(MUdc1bcrxVTc-ICSIsuK_+!4^>qch0!rvmULa- zZfkqnHruv+-;ah)5&%1?Zs$rp%iYWW|I*HuoUt8SqPi${0z}7g^j{WY+jiT`34&`R zv(J_Clv>Nbl=A;y=Q#Ie<_$A5GczqSGcz+YGcz+YGc!|QW&)E8V`*km?&Nv?ivr3q zRHht@Hby`l#6)$lgL(E=aIgaK9t%BJL(je8@45;KW1-5bfE+LiU>Pw`6}2$ua(nHq zI;v$g6jel0gcLJL8 zM5FuH)2r6=7?VhK?v-rS9rn@&mXdI^s{`&xB-ukV(%iOnEYbatHkO8xc!cRXJ?juj zGD|2)gkWMWfrv!6)WVw|rN|EPBo=HX5R!6P%_hwxxZa;=K`@o11pA_!jSn)?V=_Ua zaRf*StUKm#DY`>@lb8T691}@Y|Ed04;}cVrniK#4kTODP!0p&~enkMlke%k-%ZyCo zaME*=ob#0c7@@Py#ysr`WD*PG6tk=SmOVN1Z0xg+q;d_guud~;(i5GjZ_L{>7`faV zi{gy4`TE8_#Hu@c}7QLeL z=|0N{r3f||^NhC$EQC|dIy)7~q<+&8cL78Iz#vX}=2b=}buIMi8ZTnx^Z!y*|E>N2 zCC(;BFrYC6Ix(JMLO>P+Izu@GI2XBq5tADLgg6hG5J&`g)z`&lh;SmwBZ&Qn50A$Y z=a?^tqxcW6x=P4M07!5|7y^xmVfd9I!mCKdXUGsh%ElpnB{{~!;|M4MK!|=H0nlwc zhByb69Mf?OepFm^ugZ#O&Mqk>ejR26Sf2jBA%CC#d0O2Ty5MvX6 zX>Uc5+)H=`UK*1_k%E`NL%KyFf`*BL&)|trYNla-u52FeLCW_-af!)V`MO>>#(}Jq zg^UmMYUPuXa*@fV>oy((oB^fAPzZoXne+Z#J|IIFC}tx655F!pN0cFTNr9TL{KMA6 z4#P*f05xXBf$#ls1l>nVrfmR#23$*adcTKbm~Q>$k20Kq*K|*Mi$@WRU@#nT7}q`B z%p=|tP79zWw5U?}oe%y%lp_F7wRBpPT8H@#0I0A%8zE^Vz;Fh`0ft!mh@foRNeD-8 zFy3DbF8FYQe`FNmSb&iX{*iy=G+x*E_$r4PJMHysDB?eI2H|MHF${jqe`FZIQ73nJ z)N0sTRxiEog*S-!qaGmYq)uWHm|>&Z~odR@LC99YUb zVm2KK_-p^CJTM4)g0s%HG3(;GAhu?`lqebXfh)J{*j^F z^t9{1PAUqjd^udJu*Z*oOw9QU=71bi2S+W3FZ2vB|HwabqFaT>iGg3x8A-C)ph6~I zyb!ilftsULr&+(BdoVgZU??gSUfbL|O^W~jXfJn(Q=X%XE-BDyxi^u#KkI{bSOfC61^&a3YqBZ zTa1BL*=a2))IccW0eR*+$16GmC~bD2s{iu(V(CO2V+Ady zS(+bow-5&P9X*Ct;uovtpbg>K{1^E z614m2pL;;7?(|-TlX-o6uS{^@kjl<%#B|=Ioj-kG3Pi0xaubgo?jk^6E~{2t8^tFC zn8W&dv1Et#l2C%n6SO)x4`xB#{Pr0WP=-;dPVJri-k%OIBr4LKCe^5cxtEQ2%F&Zf zCock?3`YF_#d(6yh?ZujdkUbm!OO=L6+53p+NV#rWfbt5X!1JhHeL`ylOZHpfBWAn zL}Ge;pmU^=sK2;cV(5Tg`jW#8W+?lNBFM6pSxlw~F`{+ME z%&`yEF19E~cvdx**IBvhI)3E1rrK^YrR9@&(%!2&XnRCuWwAE>_Lk-7OA4e-cldOA z^4Zr9p%~ZJ=?}y4F)Xi4@aM0ng#cWo+CN_{v#NQMpxxj9xfdiwocVpw_xe{Yw`Bvp z0%omq;@j1XXfGaQYTM;`_D&7!KU>?4kRu0}wiY$Y&bV1TAwGz^Ie_E*!*W$O`*FeJ z=9VwoMAe^Ani6F_VtHn2wP*NDj7MJ_S8jb~R2d0yj}*KKSq z0vGcvC3;*hBtc1I2wd44^)l-}y3@AxEw|kA6B9s)KzRH5JWeKcnt7?y^W=`}w5pAU z#c<|x(BY?ll;N~1^zmj9otj>+C6?g=icr`}b<(OozPpi}pA?8&f8-`Q;rdx%>sw^? zoo=I%sJXtGufgql$zga456Q4~hW!8al-`qMn9~@(gv6(h3pIv}*M^SfG2{Z**V1=+ zxk5wQhXwAKGK~05Fyh@I%yPr3gW%hOcZ{bcT39{lAWBD=kX zL!_fs#NuLwbF#W!Zw5e+*IVAqlPtsP%&E!u_L0UB0JyR@W->ncsJZhej1&6_B8L&k<0wv?{)i6!fgQvqe9=m zTVdyA#|H#Ox2Maw;kMev?jZosh#q{?&19u+gMRv*%CSt>EmHj5_4)a(cM89UZp;6cb9iujZ?%b^GC# z&Pm4pmsd9M!0Bw7w0c~=N~40t5M=rs-?D8Dq>>qY{N8pqooi>#ch9H*IT}McibH<8 zTHvoN$NO!d#*i6H$Ihux+b9~j36HyZV%Qo;m1CZPMmKMJ0T_m7{QJM2Ywyj^_C>~d zf7;6Fd-X*UmNZ5HCEV8O`^R~9{A7G`q%*k7@L%TxukO#-cY0qw5Mcu}B5~2r+W3c_ zW?8H5g)tvY?VHtJDCQ`C^8d~zU3}ujHT4S5or`W?|Hiai02D>d^~L=)ZNr~#Q3e2< z*6;avd3OKa*5gALaqsg7+|wk-pDs9#2B4F$-JGr1^R&PGJ^#7IydmE3^y7aQe|dLNGMZ;S>Rofv5m z@bqDoZVx?rGmCNt!!4I?K^TIfk=y9_>!U1?;q|^3jhG}Jf40&535EuFDM6o6O8zEHt_08%6dLq>}{^skmvUB zr)B2k(Zj4|kpAKGg;~_OSwEvK7;%eqv|_6c`}!Nx(eMg?Ro%b-{d?ZBg^9BD+sz$W zX?OpQFaWj9emFDd2|s+}bC&RYHP6|Vzh0A^aJt~z?UpUSyvOAH+(!HT^$P_$WpBhh zK5g_?i96#~-NC2#x}Ace5iyW6g;sANlyKjTrl#KU=L^19U&7G)>Y;Kmn7xrHUw{9W z_{utbIvvwnTH0#i#FCrx)E{{H$;xg`kgB(KLf)H~S_97RSZ*t*!>mPg%;kb<8B#ov z^hf{iV*22BJZdP%`G-}lIqG~J${_?W+Q^fOU=iq!*9%mb3qzVs=FEd5F29nJ(uj$q zH~aVQ$os!D{k9C__q!CD1BYy$wC^wIp~p|6K@M`GUM}v)n)wXo0)9-hx$bwgVs~n} z0q4&`3ma|b3naF`y<>Ss4g`&xzK*hMzJTlTZhe$nh1zB1Fhc|=F?Kz;qiE*?d`utH z$29Ql4{oQXax7{2;lpDH!>=V8d9SjwT2ajX!>@8>^Xb(BQB?oDqzME7&{SM}e{P9^ zH}?!~?lR)ndH?Ir)m1!Y=cU}-ZZ01D>+JNauh!I?u?Q}V6`K0Q`N05(ARez(rj&Q+ zt&E4KB6x-8&*bov$4IWfmI%WRD}u!(JN5jYY*|x1P@3+U;+@ zJQSHRlj&LUHd^SfC5)(B6J(X{nBj}0p8;IsKASJ7VT8vQirndZLuwvDnYM~1R&ttX z5APm~qoU}N25qvA{(dvQv($+y^K3M6?Pc683>uGQ+QaAq(ahuUG0g)rP1GlbyRw~Z z-+wq`5gj|eX9**>ODz4d|93WZ^lv=~Bfo-uStBX0js&#P;m|Vt!f%KnE=y>Rw=pKJ~@3^ z6y_K+ne-n%T3gx#dC=r)S^?2!C-r`-ON(6&rV90H=c+$EvXtd^a>I(%$)#2%Ko^!One?d6_s98p~e)+JP znmV7KH}AUN#jI@b^D}C}R@+1WoMm@-Z^;f?rK^wu(@3B9o4FFY4DzhEPKTfVQOGc| z^=uq;x?~DNX*5hD+-K)2P2=|0uWPnSPz{LSt2@f_Ww@b!G-plYE}w1(|M&WoPrnkrmgeINWj+|ko7HBG=9f?W z_FvDnGmEqR=A4JSfBhTFZV_Pmwcmd-o7UYKoF5AT*Y*)F=QF%2$!Q}IA_yW86O}W2 z+vzv{?Z>9LviGN-aI)Y!8bQ)(+~eLS^Q=Ry>({*iu*+?7F^}gpzP{51kZd$mLeFkc zhH%LP(*Z(R>5o5W`THqaw!C^m8Rbyij9f-VJYpG72oH@;)&dTXSPO;UYMfcy11|4B zRUmK1*2;f*wovJe>u*aQI=b! zo~$Ic;WFeA)7RGF)6qO{XMVk2)6LW@V+G94S3w(ccZbe@{IpG)jg^|Rjt?0xghI<3!vQxdSV+d{ zdd=dv$lBZ*&Fq8KG(@kX8QI7 zvN{$_jbYSQx%SN+dkZ!0(^{;vrQ9uw&HtjJ_%-ctNJC7jOUZ1icJ9L#g?gdz~*CHUNp5t?h`} z`3hF~e0anKi+aAAV<$DQ-%$=UAdLn^UVS}1w}du7fBp!`PsB3HcX76;H5oUzsD+eL z`n#LiiJRrG%rEwPFFD|9H$3SC#OsHmz9w`X1P*q876_D;l*yp}mg(wJUhA|=M5C)0 z3OKEwxbXu z9KGEYKy8}$+JUh+N%ECq8R4G<;n5Lu4XG2Rk>*OZs=O{hKV}D^J)&ISZZ-p`tqyTJ zRvTX=`2Z;Ka#g3B0`H?GVgpk-k=y7Xf^L9a0cF*RL5tDzUOLK_>rSiQ5w`$9%~8^O zA0EJ<@%ak{cQH`5SK)P1@^|aVO_*C(_X~mD(f}2(jU7?A%?F3CU*QCO*<#QZ(Q=sV z0YHdbq{A*6>kl>$0cgMl+N#$039a+Hbt6o6EBheh+bxX|08827i!f-o#2sBKYW(!Q z8+!EeIRaO9lQX;F(GnG)fT_HsJU9?g-dIJs+*BAm(!=86Ch901RXLvJl?V`@K1U%VJh$%3IJ$CJn5$I z+~DEUkNw;t)1H64#f1T&BMDn4^3aJ7PI%B#ptdQ)l;g$iojf0HfMy2e(RAnK1{Ewf@h{>Q3002eK z1;V%i0QHl2KYwii02CP)@LdI^G%Vz71{9D!HnE7ylHwdmNm1f&+Cu;UK#1{(er>9h zjr{SG>(>S~Vc3tZ0RRmX`Q!gx0Dw+}6XybZ{5(K{;oqpF(~?)|%b^q^JS#5IGa~(t z_h~aZPyiZ~*!%SOA3ulTGg1LSDci7LTLpRdMhHW}ZTE%?pcIte8@VqoLqq^DjQm?S z1N>0musp?^hH;<(@@`~uLjgb@7a4~^A%X1uEF^b#Z)|QxzrR;zGLiW{pw^%0`<( z6K{tyxM&Yz<80KXE;(MK00D*oX#kkoX}<1O?ry1z9D>R#>D361+sH?;NiqmgtPH^7h#dRx7d;g1 zHrvoCJnSH$6WOlkDGf=3a$}8nJ8{%n6*;Tv$%D$*2 zrLS2IHE!4=Mi^Wp&4wKd(u#5vs6hUOv3S!B8&F`EQtC|w1^WP?WQ!)h03q74j{+#U zZa0ZxHb^SCUYzF_Y*7IKDI5g=n(K;KF>#p7nCGuvJFGg=HS+7^4CWl;js|DO~3~tl78e3u*N>X*PPGbK1A>v#hq8EAt&$O}DTZ zBUQ2VT%~kIFm-B_A@wAzvY>{jbXwoJQtpN-^XhF%teW~2?4n%h zH>s^g1iD_00qHb;<660Eu;b;wq5=($+_rRG8{tf7ZkK-}K%PP6)o&~}&FooCIEH*A z86E7ncZn=l zt4df#Rw`Y$Ml>K}XjeDOG#u?L8?Q+vn`*GIs{INdCErYq%JY!i35>)mFD(PKp(DMbZUeP;9W+sl?P%DbRXcTS z%d=&5pcUH9rBM8_*_7^I-W1W&F8@XfG~Z0M%K5n*S))Z(+`&^Kz}=UXb_41B4i&8G zPf!^hMy|?moR-EAK1-Wz+z_%yONw15wXNL(W30j<^e^p>rECMnQ*&olGF(%lRdniB zwnxiqvqosum#%wOQL~I4^fvJtJ(S@DKu1w~dDgjt)EfO!do*@JZU;Cg!~*y%8*KY2 zl6=Ux)iNP<0<4s9TV)XB<#(f4Q}Lqm8fiA_mdeCRwgm00MC;5?FttHgsF+51EoKQ+ z$D5`pvPypX&3LY(#MXEsdeu%!DgH-9K%P)W9WoVDIf0~`XwdPs^L!pf>7xb|xmDUv zZGjusWbIQsxsk!BU0WWJc}ZCC{7S{p%6$_jw&026RolsxbPkfQzl7iPl6P(pwM|7s-QjH%&GAQC2APC*Y`@ z_hF=|b^Wp!H&*Ly7-zH_l6UE(mb`9fhYG+@G+6aBYL-%o=A_*#DAT+D<MQ2_j|!+GfXMT2aQ{!Vp1m(O0i#&2&zF!YS~1|P6S1* z`B|ruH;#9=<{4&j$C@N@`45L~;xx$;w0S#xD>tp;4#Ba22FxHGx35s(*ZG3l58eea zrgbymUD-(`uk9yAIg*eiDD=|Cw{$Z~a_nxeAQP8i#@)2S+mgj9% zl~NiHpt>T*E!3^L(TUfFps8U2g`X;oMfXT4p?+?0?_u@xx1C>Ajz58EEU#TJ6iSI# z(TIuaZoGtaVD2Ti#yUclS#M_@mjz%)%Y3wpwF$BV{ za#=q?3N61WG01y~2fGDto3tt{^AoW)3JW?%LOK4MZaf-KG@iAcnk#32k8l7%9ft3{ zP}HB(j}e2WT`d)PE~gnSBpK~R##LGgx#R1lm@v;REmt;CigE_K<=f6 zEc^z+fyL0Y@SQ8SW2wA_uBTZ|s2H}<%LZww@+a-uiipgOGo@Zs=C8Z7Z>U0?u>e^l ztg>Eu16rd@qmvKP4pbpJu^7D8Qt3ygcE#wpM;V*lIKh;>xXf|DK{knx1gF=U-&%h2 zL7=_+n!XwD#XJowv0LLnC_|$4ejUBA!Wj`L9XG>x%(!t)Cnjp5E}8AzJrEojKtF|H z5v7#OG|u+0jPjn31A;Z316jLFIY;UH$t0%Ty6x9i<>Or;OLH{KHhF`%71?MJ3w-7x)EUTdyVev?U< zv%zhh!zb8&i%m9BgsBbu>V_v_x4cL*xx{OBFp1k{ahwK`-(V;jE&Cl>W8F<&7o1py z-87?Wq@8?|>0$J>=?C?GHi}AFVC-k)31Ta^X(@HRX8-f9x0+|@Xlug|20`Js?5FA4G%Q{hjuEblL5GHF zMn@(ioVdkqmQiV)AV-K*-3?+@D|OrbEaWzaIH_&kVj78-?{TyEz!mUDh+E4^wH&J@ zzm|8%s_POQp6Z71X{SXSMALy2G$`_V^LZ-mn6}1@6}suiu|n$A``<>F>L?X=zI0+LKxw+nIx!!=d@%7dDM`Ve9P?T zYZbN#JUR!|f@>WX=iM7quA6@tUpxaXujQzvmE`T`7~XIjh4K`&j8chjVU3G7na1IP zkIG{mg;6PW>Kjm$9@ANvv*!ue&qC3769%#Damfd$v4UPFt%J9RQHC*#q#rM}G%Cw5 zA_;q%xDDTBOtXG+BeeY*Azo+Mk7gy;HaHR6NJq&;PP552879b9-)r_iy=Rctw1TL5 z5X1;*%$e+u>AuVbB!z&88&%C1#2f!nCoU6R{^?=(Oo zE~&e1x6Jr_M<-@Ko2OA>Yk@g2b7U2ijNG|R!ox$!jWio}&beWHqA`NInH{EyRVxmw zoJAXFjc^0-=`)dWQ0i-8-73#Rlr9TVH_sC_VaHsR?Vbt&2Tk_hL89hc^4hp}- z<*|@>Mj9wtK}zp5>MSe5X{e`@-qd>iJRe@smYdi$-24>!>9kYPii0X2P{XUQ&Qj|_ zVHMN}|MkGr-7&ZU^&^j%M=qTejU%Y*&!=JTk#G%PdZ|0r#4jw7c3YsRn?)U!=DIaP z!fzP|S}98a6jxtPQg!~bf?81Z+a4_A@PwoAnNP#5eR;s$7KT-+gAO}Z?s%(loK4!+ zrjgZ}G1TTr0FZxHRo9wNqIk;njegmQtH}&hRO*;_6P9+OX0g-pax-SE&`s#(A8=uy zxbkwEWKO)Wl4GC>TF1`e_BjDyhV3ZK(X79Gje$noIvJ)kAogoA6xrt>G}2g!%9S0v z?zHG^f9z}Rm*?%&=F`M{wUjIh(-7K|g^umHc!g)Dqr-Di1Oa&W_ zvtd#}I#JFv%4;!g5x(yQKv7gY#ECVJX<4wr7FCee7CEl&P)OYPqT2 z_S-m;a$IOoSmhlCHEyi9=eXhVvIs?9A&nCn@xBKe@~?vQwtST84PtFINjuS%NUBAg z*8h2tQfB*UN>OS>1wfv5G3i*4B+4*UzKdz+hNU(Tw|BySi;tMElsue+o` zU3WGMcaE>VsPWQMm7*}b<>3tvZ$xAxV_N(J5EM_w+c~}!P+Vm(iDxHv(14m(#~G<@ z9G@OV;B=hPVw7aB&?AXxWLyn~$9rHJ@tQTyyy!+H)iRPYfzyz?5|>fZUYK`shuye^ zzvXe}z3Mgf6T_mLUXi$8Xi&7@rUv02;{ey#uBS;Rdtc;mO z0cC(&q@(196fm{rx#=Y|JW7&3Lfp;^h&M9ndWTz@0zn<7uI-c-|9n}KVNf%@mdZ`* zb-z>1<6BJHxNV#r+tF%{?6jM^$p&O|8db{>XgI!>gXfmIbgB;yV%dq^S{1qngn?3K zY5dy03u+97PHVXzf~Z;~RU%%Ox#4dsX%J96*W4v#ub5Vzx2{Ucpq?ogD!iPs_c0e^)EY~HxN z>n`!?NH{HQ)T%orCM~mQHMv}+Ldv4!3X)%1gs%=MUvQVTJv{uByt?QM6o48JQtx}{LXJnE*d}$7y z^!*bcud*GbH|3+uV;d=U8Une{Fg@^PCj;batONs3ldHm??`Wp)mx6c;&@) zTa|MC3Ny-gF-bCwzM~BqKlWfZ_K%MZMdA8<73ZzQ7Vv;Qe<3mf5Q723a1uc^%(3Nz zm)I?v7$3Qw7H*q<6x$=hkh;++2JyL7)h)z;LZs@uS!Y?xB$`UpsYvLYL{-=^7lu(@ z1Wc`(zfCe^t_*{NT%G=sjNQ8NbFXplC}QyZdBneG5dZ=}C+WA0yq$wgmBm;AMy&B_ z5=}=PZl3@1=Mxtb{VQ~0l3MY&;Sd)YZrZqMTUL2+*h_+G*#KT zQB*h;;nTt_4LRzEdu31(KI^`(ePnS1rToUPNbN@`+YA+`jJoDx9D{i|w|#gbqgqcQ zMXc6~nDt!ajhF+E*sm-gwrMluDjEtsOB+|6q(at;4$p{ToXEGn*I@ZmJpbn}BqjjB z7~~9^1^Zi}42as3%Sf@SbLfZgjPJG-`L)YiKg@jk@3;U!P&lk6>5{(-bYOg`v_(S@ z7a-SDGG^n{j}s;%r}Z=r6Fsh<|MTY)(;|AR2&%}X-yljpZMeoHnYGI;MP7?Z9F}~c zDK$&^v9qXQ|8?iadb3fmoz^aT5vJr@no*cpu6TW^Er@oOH~Zk=3XrzUMcOx=eB(( zd2D2oe9&$uYIY9-BGa-Ho%rqBO23nDBLjw!4VI$>!(vq4|j{jRdra+;kMyNLoCCx(_m zvwj$qCUzx6QS;Et$XU9~70^+5u7;6R8(Xbu<%ulKHl89Bd8O?np@nK*{?hat%RBKK zhif{=mzoPYqeqpTb7wcdi&!hCEbxUIv(4RFtU7_bG;-Q^ykZ)r7tEq}BL5;&T6H^2 zt7U@LLZ_ht@ueTL@l$J6XYj7d4IRkh5GUnS&&wX3ZtMCVf&e?iT$3xu*)$moW3Lh- zuYKsY&8#-zhG%OGh=fj}N-{`jp{kn;0reZ#EAtwq(L{CP9t>v)zx`5UUKR<#7B&wUfD@b zxvQINaS&-$FAZ8i#c)hX<@GqB0ed%pcqXM6leq00sbrN74S9JdZ3f!P@<_S~x)Fty z2E=7A_Ta?V&d>0iKp9@^C#ANFf06*RF@Q7fG9QLgUCynZrd8HW%2bWv7U8h&Ij@Qv z<@+{nI(m|!k*KzsMW}>nYoHMm)RZ#EYs#loWrEvsM2^D)ONqEqb8`H%{_UrA^{A zUo5>Qx!0yvkx~RJtS<_})+z+0+iL9LZyuYPFt*z^3}PA6ZBP_8dEi&s;kArzL=b8V zKYwf+95+!5yFe@e;5PlZgEHj1hBqp@;Vns9dqq*PPVIT+UUV&@8WFCs zrc)@~iw20QFmB`eIeRA8z%bl-6DOKlt5X0Vr~-Cj?eKsy06HIX!Kkq85d8|2LmZ1RHO4(o|h$3@4LZfAO z4+!$+vthcf{*NLc7ol9Y)Q@RYjO4A1u4hzS%YPVj8`;q%kCWZY@_qo#40w7+N^);>1ckNe_{IOM=#Q%e?4D{P7Vj)w4^k zZB*+Xbl_Yq*jxjEnyvXOu^H4Daz3(u1%L+pgBKzb6Y&8V92Ne&xQk`@_sj=C88MOb z0N*nj5r#ql$N1x*2{apZjDBBD192gWu09{W)gLSl~%0H9R6 z>Bc0=qH|P;s>TiHAz>T=uHkFJvVf9>`x#{6ganVR^kWklriCPA#AHYZ0EHF#hF|}E z?*KXx6NS*Q>%Z}Lnu5|tLvSdB+Xxf_!fo&*xQ2<4UC&{76O^!!^9b?*P!s~BcbY2x zK?H`0iHQi=%oHlj02A5A^)vk17%+hr|1!c5N5KQ!4?qXbLC$6%i_AhHME3BWEt5DG z=qAOJmVCpm=QDUlPvX%K+yDS!1ed{1P+!L-Ax07bu^ALX>7#i+cSAALa6aK?0Mu{+ zG6nzz8#spD^Lict!U^Cuw&C~u@$Z2%P$@uAd|VuW-B3apE+j+*0MLl>kh2&RLh&#E zJ>R6pP;Nv4K#d{ekhrIihyc6{iuqc+`GuhV<#|N=$>_*F`2R_S_}k*d-whsv8$LE6 znB4$Sd>t40zx?5Fz+498LWuJW`z(I++XRZUgrNbI{(S_)-Ei9B6fD*3+ozkev#f5HSYE-{QDl>YMf)It&gG8xbT0G`Aj z{q|n}MLZDz_0~uHS$x8;3)e6fHvm9-t$j8_7Vux{vv|{X0sw;Cbrt*ADPb8r#p-(slpxtqanx9KruOvTBVRj z1;q-P9=sUf@2j^Nw6ssk?v$>W+>VlPxwlfR(I~X1B&AwI&O<5bUU@WFgHAzBDNahR z{S;G5=B8M&vD}(bYc!-->fE}@H63V_=2W_Wa@!$_(*RJM+ssl>OetwuX{_Q+Jgqn! z#BGp}Da-J*Q~7jiK&@nwl>|`8s-|+Ac(p*PhoRGSq7$opl)Pt3cClh0@=m(ga$TRa z=tfo|z7}y(ScchNxR_K3$)<~OtYq=E)jZh0-V|J9MI-4w8AMBwP)lz&{cO?({N@}LE?If{WO|93Y(M@Nmc_r=?`Rkc%_%rpCFf-f)P$+Z}0E+Bw zG&a)QFeAV>wh&76YN;)ZmXhT(FGt81h5__8nrLB$q|3}R0(gIo5ZRn0N||b2mf*W8 z0w`e{X{d#{;U;^s&OU>E{4P>d1RQtI9KDs`FO4?t+k5a^3G zQsQm>4%r+EuQ95vn(3%(R#)>IOGAbLC_|K(P$?75%?%kaU~)sP7g9$eVd6n0NdAh&OSEw?C z11m~a?5}JAHH)Ec(ThH~-^iVlYT@tgf9&-_P!!eI{Y2FmMHRt_uSNaEq?L2P-Im?D z;qzXLcrD_F047mZi%s0hO13?#fcHTciS}XoriokWJDaqZ+|54<81S^POU7**!w)J- zC+~OOiUf1g5t<+pE9td~f<=zn@LT#!hmmfg?P>x@q-4{@ELPe`3dE5SiwR)rZgNuokiE*W+zWvH{)5^!%ubvJS6I`&r7 zUr{!hcgRa=r(`fhffz>m&O<*x6>-f$Zq#_OO~Z9-YJCuvQGT1Ar+d5=)Ek&!DC0Cr zky@u)?1enbEKb#$6V^FG{5{h)fEZDEd6rf;x}BV&6BEf!`$Fo+nd*0Jzo)9B^)^6| zo8(byrNzUXrLDA`Q>k3cF79XKjzn>ppWX(0elIWd`CHQHmqt8V)V*q{yo z@R~4r?Nk_TaTHD8&kiVMw_3dS-70?V0X45ZT;Fi_WYvoMB?T;_ny&{w!Fn&5n%_E3 znV+0G9U2~v&_LvvFzbb|wDR4C9zn7hXhaOHB%m)d%Hq^UOL_$b&&o25Q?+4-O%AWl zK`udNirVj4)uYvBK#-dqqOV&#$lt`Yy=&Jw7C{+cT1_XRu&knVYn9y~!8E92NN#pm z%ws5dwQyi>#2bMF?S#80t47={bMbdFI5Ck)8_TiqIvvRD=vcTu$g`@l9VE-fG|FyZ zDcN8%iJ|l_W4BJ|EDH2@JXdehmTu5C46^rhfw}9vRY`c#aVi8z+}i$|ZYw)QEkKcb zO~x_e^e*hZR~O4&4uOPzML zXxH#`$pYQOD?lgVvmT_i+UPjVU+?pGR~j%TauIR~GHt>yVjBR8?9pf;mk<*J5ezSb zQh}0OZVI88Wuy>c8>ms@T1XfN_#HD6W8P2h`SEK3vKbZuY-AfDMy`YO^0^EUMo{wK ziU9Hf5RxLpZ~-dyU%@wAoA@uM1;FG$0gm!9U(?lVN^1Qk^*+`5&+ASfwEtB9sUu@n&DlkPmaAdK#kgk5 zC$SI>(Tx%^3~}M)qktMKs$OgfDi1QLsAK?Wz3FpH(PJ6zH8_1eclswmPjO_qa1X*@N?76 zdZC=%B~HgbTj;qdW`AR&Rl?RnhXKyb+IqR!D)wv8^ct7&#+Jj$uHiY>#zZ==!O zt#-d8UewWnxc`&`15Ai$(uN8D1owuZ@X=89}vqt$bIy`LtpJqMVMjV zsq&YfywYKIJzE;v)5FCVma@-upO7tZn+gEHQ=Q~&+BvF3sZe@ZzXl0^|4e7J_I9~m z6%Q($MwiPyfDki1^g?+piOa%Vzh>e6fn$;}rH z%%}#L=i(>dGu%mVw}9#8U~4V*YDIRdvokQOaJ{2*xR|7v!?}9>ZUF%LNUyE|!2iEX z(p+u5838IN*rK|vpx|Z|KyksoEd>Rn`{OM^i3$p~PyicIz)b}O2nh3Hm|01;>sOT1 z)l_i-C69JpLBVAJph18Ypuk>O|HK~~KI#ll008-wS05J^SI9*v%A>;=yeNDs}#4dAU(ctgWo#J8IX?wi!F3N9r#R(E=n z#gA2Qc9_7c6?cOI1nFuOc~X?@hJQ%OZdixAZu2>MX#jwJ)0yY2B>?0`x^0>p)TmBR zXM+Y0J@(-|=`3-|BfA@0uMS(x7D&08x1)o7&7&ULG$Cv(UgA1JMi2klz??kdz{FMFJ8k(}#I?wWsw%2RQDC-`+3l~UrKG$R8qsw5;u=H~?O$KwfWniD5wF@pFoE?;DC-3~I zrVwN{S=2k}F4KIKXa_}yO2@m)fBw44POLBXo9QUrr&n3L zvN=2I$avHu3jgq#&RA~L=2`D{+wP)Tp3lv=DD~}aSdNiY;h2}fvG>2xEdVgxjWgFG z@Z;_0tygurZlgJ^y_)N@{iNCX@?N0g{Cr}&Z)rIr)6Np!HoJKh>E6(nVf6i`*S2@Q zq^>`H;c$NF)1*)Tfor#Fu5!aRQD)^l-0{)Y+wqr4mSdxN9v!m}7isG}s~H|0>YZ$9 z0?BYKn(X5<(%?Mg^W5@~qm8gzs&c_<-QI8GzJo`;*TTlK=_GUMw)yf$p|pq&--h!m zb-pkY{j~L9#dfq>&<2BNqqZNu+!e|IkNNY5k*AN6Uiqw=J$*l_`z^leOe@@Z!P;ni zAm2T-%f5d`(K%1kT(>y1v)1pN09XE`o!k2U^mHHx7Pj(y z-j0fojgEGhWlBtw(Ojwn!JU}dtC6zZEcy6d;59%eULg(prNMF|&b6A;I(xg}{)%-s z2GJ0=lh2@lS$g@*&z(i930Cx?#Z*k z)ZWtIG@&tWot-VNtl3c>dAzr}gQm0>TJXLi?mqk^xk z6Q8FO!kzmdu>g^=9xcyrXkq7!mw@IW626 z9v?W;9sS4b%+A`SbFxB}m<+R9@c(kpu#K?o44aNReEcjE|HqGGPamYc0!88b@Z)(B z#tc>s`l%He%r>NS^_R0*WICU$ZGxs@8VucqeNOKfu4XEK>edape_>h%Fe5{L`%7l< zs5{6ob)wcbsC3I7X$uf0*npjotc9`&aViyMal( z(e!#YKRVp(`5Xv_I8mMD#Ug3CUcOmJrDGKg*>pyYmd5=Y0hYt|^vG@Pj&B#!qn*{| z9^=4kIzyJ>pkC6jn_4&C5v%b`wzoUomYD6wt4p!ePvb|2@*HEg<2ee0zAhLx@RT=f z9rrnUt>DzPGk@qR9Cq?eD=KLW*V*=ubT|5$AQr?$p0S8>HN#Jgx&_9NFtWwuYy}jJ ze_Ff|2r%>g`P_szynlEi|My%eVn01FEkJ+>BV1m8Si%zBql72D)88hCUw+|QdJB%c z%4VLuC)XctG2*Vj|Kz~w+umVST-|tMrqpHJlo#eU%fpeIT@c%y38#u~6@%^m#;Eru;E!iEHcG9mOMUG|?@sNw$wg><~t))7{_F=p0 z17)B8JcI_h$H#};6|bMPFk)%MQJz3`id&vYM-BJj8Wem=BsIm(&WCAp^Kx(>wF~FyZHalJEWGyQ|UETpRWawRPb4pJw!bC;TQCpqKY2&Vsf-{F~`$ zzEb_>@nM$>rZAOO=jR%2ce=+pAV6$wwf+7>ru(Q970UQN{Ipn`+UM(EY#${@w9W|+ z=Y7w=h~4^TjF@rbm_&JzG6Bz5=Znnz>d4@@Pg8w8ThQv@zkmIC_u`5`o6d_ez^Yqv}zf@T-fHBRPcV33D*jyirz69fPN zgjOA1&W0YpXorye$ImB!`dfv=)$A&(aCUH6%ro7r?0H7xYz?T>|L?~!=ifxVdJiy+ zZ1wov)Zj($R-`dY_vYLb4^%`Tm(fdy;8=7G^kLfX;4YaJWM%?^A zBG8rQ7Yov?&WGn)LH+TMe3^Fr`s-Z*?v{gZYtwW-t+47}zwLn%^UZh5Bvl=6U)jXB ztCgsyaQ&Db(`2G$iXZdwZ{#6nxWw3P(r)k8J)3AjtFrsQvxA0CJQoZtlise+3|7*x zwcTD$&y3^E8w$+x<;Qc`t4=4QGXOdY*XiX>w0ORn?;ZGYLuUx^u(F`6cDs79z%=Ucr;m3OPNwyE;Pc&I3L!z=Q=Gs z375rXR&bDBCQz)K&1P+Uj}MC+Gh5d5=IySqwc_@Ah0#{0Yn`H>Iq!yi`u6WasbLk3 zoAzUW`2A*=-~X}#9;C-Ta_a_{u)+`&FOuueY+4)&2EyHftwwGl}%!gROBV zM>r@YP=CnGNbs$FiLNYv|9wv`0gtt^^5!%ER% z4{1;~?~gbHL*q2YD_jKhc>j>y`<)#$bl|z55wqJ(rq^wwyKTp(%K>MP{BDI1Z}R@b zxooG`&*&ThLFqmDkj1mIgG|^4LuaGTcc1PGG?E>D{qD?@haA2SKg_}xqhYE*P=_O* z`|ZE{#&TOI{wufug}CykZrt#@r~3hhv68<1GBtdVnG+njQBx>0ofUR>o4W#z@_?t0 zS@DaHZ9D2J%=`pBzqgsnA^-+s)BHlaZ&+YKH7m4Xaqc=r{$vH7${1p>o3 zM?GKuYCNw&ka3V(NXN5P-79Ny?vL#}=ymcUjbdCNol2|9mZFsLg=G0AUDZHp}b9Nm2jP6PqIo zh+|~x+u8We%H;TjGQyG9*v#jYl?NZjmPyHk1@~rd`{c=-QS}ejgyPnIrqDH zBP>9H3AJokGCi+Ah$-S(T0f6FmsGKrG8yuXsJA~m4;t&s9cf4-(vqD)(`nz{X_}qz zdlj8{;^T**gJjayZ}s_>VHpygqW9W2x_Nz#X~3J#FK1Yl`B8>2AbnJxf0$FZ9@k64 zG8iyD=?7DbM_6iD#*+#=?DBhkq3`Qi>R>PJFqY6l$ zcYaTQorn8MM+w16r}6qfUeD@%dOhjD(;jX{j+W9+z4_q`-*h|O+%s%LzWr&g^l$&? zMZe3%_`(jlenJG$iJ2TG%Npi$`@*&i3QXE`dcVvEo%|sPYmRaIOlK!t-0>Za0ZC+M z%wll%*+My7t4=RhW8(L+VZmTX81A;7920Mty%sZS+@#n2=26>ea9~DtaQ17I)mh{M zCGIu7oMR`}=gDh~0++U)6E!@$c@jz>Mnu0}PjuIYUd;qz0n!+f*v~&?o5bOEO9&(g z3E~}I&&T(c2E77f@CG!PU(707m+>=$(>uGcL6`A6o1xF_Vi8qMZeK79DlTtkRIS(L zl4F@7zTpiOO8tsXq=@gfCP~z#smk|GI77DKO=dqV#iGLKzyxMnyOo{%e%8@{cfEI_ zv+?e701&eMq0)GCvk zBYVd-j1J8^Gg;B(!_#IO@#@>r_{!=v*tX%2R=SfK*75Y^&%=R91iPQmdNCe{eIPZ{Oh}FkX+0=SO*RE!3?@>f&+F8|;ZH^_2%JFXtnIoR&{ritROH1l*uWdZm zPh#CV%!t$W(TMI+*r~|J56j4ew~N(edR=`+EC8$R^@5$7)mfE2N%rO#yNDw{etv6t z(x0*Mjz3ukh*CSut8d2H20D%sPd8x&jT}?k-RGl8R-O{&A;|med*i%Q<&!;~0iehO z{`k+!gq|>5NPBrc{2b zlRNYucuvbOyz%rrv*Abe55YhOrr{_zUq)1Qyt$Pp-EME^(^--j<-foQhhU7iFMF{i zpKGYL{4iaLw!@<@$YZnKBoIsNtxu4CuKv$gS$3Y-%bNsyA@GwXgHgSGE4Q)e?MOb$ zy331Yjh+#`mKz<*Fj6y&YX5jMo#p9q-iFu-X?V7qRvK?n0l499Cg*cldsHtAW2|r1 zn_|2PXNw%oAAiWk7e%F(X*@Fb26VTf!lc&Tq)K!-d;I&aiTm?rwyACxrZIqhvtWX- zrp1d-c5930%h|z^WEv~%ZZ~&@5>XxWnGVKx_7Qd)YSh3y=skFsc5()ah(CUw*n5;) zTyf|7=jXK1c{a~R=+QZ5)K1{(JKaJOqr-I4(i_y@#eh8AeiuZIX)+qSbqDsh!2)BN z;G9Ana{emv8#-`@+0`tmZBO?acIzGWd@(&*8Vm{oRj2vrY*D1dc?hwdX*kwB*`Dt9 z-F>-kl??zArO-EgW_xk~aM77=cSmz`-DnKo) z;^Pv>;o=Qp^nYiy!uupk$->hVJ1RIi^xv-lz)+aiey5@{NZ|=i?LIuP9aDx8Laf@` zhu*Xj_6iliH&b;t>|M)2tc^Zi69UR^&-&W7sT&Hn$H7@5`v}zTdBw1G#Q>@-pPoouXkl@+4KCHb-#UY{d$91&~^{r zbzCl=%5e6qoK|Q38h%u~7IzD-R>AYpsRYSqFNEKaVsW+AY#*N3sDaFMjE*KjaRoq$ zuv*!4RXN#SL7D8vCzQG6Ib{ z%VppDw@zhEWPfj|?Or?Y*v=a_XaudDvW*8q+j9KK7WixRESyvP!s2`fq|z^g=`*X# z=Wk3;1E`rsx$UE#jeVMcZeeQCO>l0YlrYMFH5{yI2!piY$~l=I++7O7noitta@mtM z+HnC2^{n(-FRkdEs_6D!>DfDSEDbeW$Oqh5&RK?DXRr6zesJV@QvSd{*HYf++ zDkpl`A4Js0%c}K{2nQ0(2&ZIpYRv{Q?R&B!9DS{R-D!IW2DYr1QwQ`mLaRj>(1?9T zJq;QSawyR&qW(2{e_VWoY;CzGz2h6}bv!mot@{~6m{Pqw>TO!Xae^=e6jjuGk~Vl^ z+4>r-+Hb$>45_4u%N9ev*4Twd|SApz?uPdlv@k0ag9 z-#66lVQ}GU*Z)0DQSHm+9#~t2p*-T>Kc0O5S?D%J*GQy&`0qETTzw(J2JZA0ofyUl z1#X*;rlF~Jw%rSNS_(z!yqxw8ms);9t*ft_GAXCypUvN!`E0V;1ZOkO=Ifi~TsO20 zhzLre&98sXwyWkBJBfvQM*1DX$x?*fJ$?3ff^nNDK-cD69ru3L|KgL-ui#D$e%*-R zti=qY{G>4Nor>0nVHja4r9qe%tt~IXX*HYYoOG^IGL(2(sZ8yv07|&6pMCIJ+&%7C zFGZu#fYNtes8vR1KKG*k@*Bf4sx^H>h`GK8LJWlxJZ1Hn_71pQV7Af7VH`D@T;l;L zfJQ?p=BE_j5aId~bYfg1bdYp`bQtovK{!G&PF##k3M6Hu#Q+ze<``MNjHbW#I{f^* zp#>6Vn3l=oYWB6gV9h;jr#ka1DSn&C5p zZ~%n(jUqrX(l`C!1sqYvgoaCrzG*RopOym6pQ(jR3hZ4GGklFz@K*?kfA8r*8^zZJBj*~*%2*mx9*qG9S+}Y$d@$(F}~=%Z(0&003&1k*(jSnYCJhxP-N1yj_jc^?UZ* z?AG&H`{r0%&TOoe0P{q1^3kg(E>$$(CPkIlK-yC;$>?Uy$TO7#E` z?;kfS87vTu%RfI~J1g;e1!i(1mmpij+u^~%qMz)|)2aPqCWBc<{+@w>Yhr&>ua{|YYH)~G6)gDk9m zgOi?T!;jx8butmkhIxPLK3ozV6$S!j5159BT;8I#kv%+T*IOKIeUEp#wzkj0$;-|$ zw_jOdOK6q+c0J2q*Xo`47F5f6V>ZEomv>*le>?Dx@oL_5IA0ritxeTE^L4h;!4uA2 zEThs`x`)i>)4aW5Cqs}|+f1`Fo_hIW;8xPs?lhlH3Es*cA34hI`Rj)>juz#kT(?z$ z`*=L&#^<~BA{d6(TwkuxZL{OOw$l=T96$t+VPx?4|CsD8KX5kSBbK(-%$eKf%N@#q ztkH5dk2N0SgO=TXo;(DF0YXe)S}cZ4MsLgkFY|GOjX0>D+nX}`AcOB0bdDv2Qsci3 z5B~aR81g3k8~rVX0Abolo3n4?>9ztk_{$08e!#;02^pK7YX&kd*S2lRh6#uI*(E$9Q>seAEw% zLXyA2rs#A_yAvBZkHNB5P8=@9L7ihp%AD?=DK&1?u>YMM`SlBKW5fB`bT27;kuQ+=vW^?af!-KFkv&eK-E zhr#V`7r3Y4ukiovzbtic#r-T_U2Qx3(<+U3TqBQqn?68nS2FWdpXw^Y^F$qX{S6mb zw&{6a^{)d3E*gwBNyZdC(%S8-D zfMP%iAWIlcj<~=4dM{A&d1(^}h?&=JLf)B`+ecsAzSpFIc{Z58;r))*^}|G4Q0mLY z`2I>}ddeN|-XO=Y|DN^u@)@_G+GdtbMWy@odj`LJtLfDuH&Nn{@txeO+2Z~Y;{Xg} z<&gAqqkNF{=7jz9=`mjQrE3ETxj{MpxBVXaN8Uc-uFm22+kai;FGT%3N43o`o7LYG z8)fI0*~s3jfzw=dl^93@hRF>AjBmJrk%*{X;UAOs7MQxhFJB=K1yrr~z%(-K`}NTN zqkdSj8R@t7F>mLCdQUuIY`60%sce1q%(pZfxS^66veF6jLU9$3e;RiF@mFmN1cZDL z;cv6k+@;jlY#`m%>h(76b@oCyhV-+s)9P|5RTYkp1S9>{>Hj*N*k4JbI$sH`DjNAZ z=y~^9>Nf9_WgbqL^cROm1^m|P_uM^h{3x+oArf0?+237Bvx7$)jiXx`vVNtB9rBf&z8?f4nby;hEum{ zXpr>)HbjXLhR+lAA)S*rZY!?O&PjQ#+ojB)|2sSE^B3F(6otcnkeRa)cHNiZFlF_! z+nG)#xjKyAVZOx=JAMCZ?seO zV{p0_lUU_``HXVlwW{}HeCW-SO!MFW^Bn(P)XVeLRX2lathT${(KzF3J7sc~$(sRO z_a~azvLjEPctJyG>AN_as^N57_Xix^iowylR|*DC8=y>4FLL5(3ZEd3Tz zh5)t_O~vTM5J1s@5hN?uuD=m&0Gqj`R!|oLjku1qd}|fVuyL4iJk3C6SbP4`4gm~i z?T^jipdw{sAh#%8FC|+O)(6WS?E@_lH41;cu&%HtU2P`iOfgaEh(h0v?% zjMRh?Rm-52;cXcDSfy@I7CpD>`<|bj3xm3F{pWR4588jK4T6bVl&^h5iM(GgEI;^z zQIsyDp6N$f2M!GdvnX9AjcgnUvd*l*u&>hP(mQ^a`Rg6(k`k(H(UMkjf4yx@W!)FM zp@O~AjPwf*Q1-9)@@{n{`|gGcwcYkD;Q}iLt~WS3B^;s~K6vFm1=^cDL_!F!DJHIW zJ=)Y8%K79eyA_n`8uEF&KUDvz{!{(85q-hGZe`Z|8Vo66sR-VE8N)B@ zXTq7HXQ{lG1}kB{5S9yXxe!bl^QACULS-*RT)7o8LVB7D-6(?gEo;k)JU@b-G*O~W z*DT5qTnA5qUILgio*ZBXF3eKn0UNG_fUkm~A!gT<2St)wol1)!aT0~^-I6rn8BhWC z0bt&+2ox4(FvqhKKg$p(2p_woQarV?s32#oaP8a$kP!fLhl1cCu*j{w0t#wdDXPj! zg{C<_{J(#gfn31s;htu1`wO8TmJ*jrI|}%9nQspwMZo;wjIJ0VPX-#`alaJg(@JbAt!3eD zmiYcy?e*a!U=6AfuP-5=l<2fAtlp=`#Xf@xC3E-M3(J0d$b^X{TK@(YnOHL&LlPtr zAs0zbr1_gDU?9&S(M*E)OKsoIGyI$35$3p2Xt>0%H&IN)L)yMwpv9Z0gY4zMd>tfh z#2a~rNeta{Z6fd(t`mV6a3#zDgkUld0Q-PO8i|XJUX)p6ssmkgxWGjxcTOZi0I*3^ zdr0kKAY2j#i2?`!1=BxiCE4sMhycXGiz7^j3mAb(CKIHgqDC6=1>WL8EtSBhM4Gzk zdNV>04v7T-nZ|+F??V6(SsI9>mW7syw-UKC_9e0;@OyfpaXf}g;|q4p5eyI_DJ2xMuaGA0@|2>2b4pgd4ugBvE~lFJ)dCvc4_TJ>{N7H8Cfj{h-;Wh z9z;vhi>e#B$n}*~Kl8(CD_oH$8B3;x06argx8tOW#WXy+5&v;?!~LLoj0QzFEK`;I z>=Ca;<3~5fc5&+eH4?()@tMYs&F*@uqB#J_?3ic&tgL*sLKg?*oLiYHt>y?_+Zld{ z%#-W{nkQ<+M(gqTt9_35o&J)oUxEgu%2F;#U_M1;XIdN5u%)hTbsV4YEUfSy5WG}sd!xT#9mlCCqqr10w(~1o{0yxb592trvr26q z^lazwm}#FcI#T}tSXsR`Sn?#Tasr;mquU>*mZ+K?V}P2!Jt(cLj^+IP{PNU$NZ=XG zH~l4MXii{6yiY;hZ+>O|QbB^F063?kE~90#&&a^C8K>DHsjMq{i0d~gS9TLpC$R-D z1dm$9=1w@&dRF_0ImmRSpS%h(q?O1w+LnIA+x4qXyS3e==59eE=H@rC$hw^yGU^sw zBK^i|edr{&=tst-QDKD}uZ*=<@C`XG<_FZ|0?D@ctvq0D zPV%F<=KliCF}cQ*^y0YM>1QVz0-BoBv{Zoi|N}Tq>f@RwlmW_rwPWNtb_o|bd}{* z^mVCRJE%1Jcx44b&w4_VKUY2p8w@nbvO!)jI(WKYF4(uSe2RS zB<4YRk!h3Yk_kat_a;6EE42uPhUvAgPbF;JGDla5296dI36Nf|m;IYu z1V&!}5hGAlS82-j6sKACpoc$2Hzjcw^H)YRqApd@Cm3STBJDrB33bT~`E3YGFExVO z1jF7U8lWk7%Cz_NQOr#$r3XYpPQ{nVA=f50L7~AhJ!bF}H|R>r@34eP2w0Xje>;z{ z3Ri|Rct8>W1rdNxIYb06k)%FfsXdInh$SGB5a(UpNC%Q^&7LE5XoenoTL1Mij(^e_ zhHujhpy9aY(&!x7&Ra{g3<-$9lPb$=q0gSve+o+h#{f?ufIGJTD7a^x{Z8|-w;-PL zr*p|YONVxOYyDN+dNFX-;|7nL5wc7n2x)N*=~Z5rgY7`cm=NtBse*W7szKIvLUMyO@w< zHkLLqWTHtA@biE3E{a9$Kv?j8D`z!UgbM_IO#0J#+S*Xd-4?72XhdC8*%*5vpWS=@ z7~ac_`2BR^=-=1@ZY58#z<`Z34whdF)8Paa^E9o<1j!7Vrz3{X`~hE;e*Fm`(WLe3 zSCTtx4->3rx3HU;|M2d@QQ-zjBS1wB%f2iAG*5N)B}p?oWBR66 zO&NGwNwYZtDgy7)XK79bR|-J{Vt*&G7VWD$c{eUr8X$?Kb5DL4otMTB zjg}2HF6$O04V*I`2|!wJo>AHDUZ`3}+wYm2z_{q8?mXvsD=^&uZI(($eY<(424#@8 z6}9?@s{1%SlTLnWRdjQRw41o^`ueFD3@K8M11lb@SY*Q^B=@k&r*M(rxm0>*wd{|y zjW%)4&G^1^FILibR|TsB8etDowc8yUw%PD#yxVC%)nO4e;OUp>p6rJmA~671({j5r5vMihzaoVYV58*PB=}@L z{hXljg63JY0P&ry$j*w{;Rq}HgRoN{#A+B|a7gR}Y5m22 z0-NgCSd~PG)_|wy>fhdzwZ9+|em6=G<=)zT%CSl?GM^7ugQ5V^@LTO!pq3|TMG}uB zAkj@FMc3rg)SKCi`*ogf`Bse^{E-5YB3+Y;`*!ykoJDMxa}__u6ANtzil5beE zRg{GFKmMhQkQ*x1BDL+koR2K$VMDvUQh@Z43J_%vI|hk>=p+3|Khj6MHu3q@X|b^P z?X~!_ko0tsHS@C@*QVZrPQ)`65@UB{45bGEaLvA{eQmQfPFHY&ML_XnT74$#B}EHk z`r!7l=~q2(ZzT4T$o>4e!M@W7E5KM!#@RY@iXl z-aUvOeO*346W(>Pkl9%i4loRH6#l_eqJXBErj%85?x1f|Qltz{Nx98{g)@M>-6 z8tye6WT@Q4D-Eh3$9u>(yzZ{wwXnLk=0$#7hE*>z^+U+i1gs^k;M7g6sk)!>iQRYi znrbssMYAf3o|r8fAtRGKYPYw&t-Ok+C-%hb5T3$XH8T=A7T+X(-9|bGKXhhAj0KIlKR7Q~AG)8Wuy<-V|bRItjENb!V21TXd_ zohK!gtAbtlDHhVAk}pBnY8;%&oo z88k6;51{o!mg9X`PsCX^;pit0F67T(eOGE;RXrO}{(dKYbXC)XpCImaSFT|;ncpgN zQY)3zl0VjFcJ;EIR&lhd4ZL#u_U)#JI4+mff|EhxM(%l)?vY2pK_-z-n@y0~8iTm} zxbDrQa(!|yxlpN+1)H{#RqzNu=^E=vMkU+ULRK-@=(=E3_Tm%geUVj43kGiUajhMr zCs&%PJU&t*`)IkxJx(O4)~m|JE*jV|H#b~UGu|g_(8>hAGKE? zv~zN1zHFo6Mg-)&Z^cF8A@Px3ua|w}_TS`XKT}eO8)c*I+wbjVzg{PQVs#OJbCc1;GFQdja6mOLxyZj0E^{IZpww+n-)iuJ^L7 z{k-qL;jDTeA2MM=iPmCR`ukHR45>5p<5bbHl!edG;y>Q2ym|Ntxb;Ss=Sx97hN`U; z-MCRxwXjis|1bCU`Lj;|!zsSy<9sQ|_uf4f+uH3B3gumv{{9N^0X#LOXhd8Y>mnf^ z++JkU)uBSyI(C*{UpI+drbnUZE+EIV+X?}{`&3#cCN52bQ1;llZ9F70d&N4o$$lRC*(UE`cU0t2Q-py+I1!y!wYBt-l!F0?I6rQF)+OQjr2p$IWA zipQ5ZpZ&-RIVR{0G)D{r<_wy{vA+zfa;8^zO)zx)t~hPGeWTAR+dmtRLTDh~x+Yg}G%Gf8ggg2Q7$f2@jV^qAiG@J@NFr91nvt&g0@1JUS2uh`+o XOogO_4DqO01@TPvpX$Fg_^AK@%PKeS diff --git a/docs/img/libraries/libraries-tokens-source.webp b/docs/img/libraries/libraries-tokens-source.webp new file mode 100644 index 0000000000000000000000000000000000000000..3a7b15f9cd3bb3467e2765958098cca9127e1031 GIT binary patch literal 46024 zcmeF)S5OmO04RRT_lXU#0g4nWh*XhY6-7iTiqat{y#=HNLV);*fJ#%5PC$AGArMLk zh%_kyA=HEf0YeKRgph;~l3efH`M=(||A+fgpL&z9so&Gc|hkJlS z0RPt-I)ea~CcX)<(N zzMnnrvN87bCp9brIQz<}b9%BCftIm$d+y=x7U87ijC=T0B)QUlDH8IX$7`G2;rUS( zsrRG9oKOt$kP5Lki8foucBj+JzuzT>STOp zzq(BAA64Tv51>w~?R`MY8-A3qG{JpNPwYA3GW3`10Cg#mE<`*ZL$r6wDlau(7-7U= zq;ENOHyXD^a%dj{6MBzZ;bO9%m(tPauV}qt#@Fw=UFVq^LR>SW=+5Uh{z&Va z)`*PSPK+@0fg^_yvjcPw$<4f4}I}$ePLN2zK z?|ZG7A#)pia$n6g%n|x~_-#^3UUX z#&0bLy+=W%0vWxh@s)<^=OZFCwYxr4X@{TjSAbjEGQ(=cPwEXj)D=9#gJAFFRDmMb zy86-`Lr6t8(~gBQ)-+8uyw6ChgQV*t9A+jEX4M71GYiOqj^O1@V~v^*K;ZO6WU}n_ z3-=2R!P=)@Xx#7@w~Nz|v^Nv{w>ZI1TP!vBb7x;VJcyZnR8P#ONtwrvDq0QA{ zLFJ(77vqI=-WXwR3N|vAb8@gR@#qO#TdQ;N_bpr_E(xdnQ0X^5e%B<+XLnG31j>Vi zepKB}gv4DmZQ+4PopwEhr8X7&#&Aj|ysNy+4oB}2_85Nh&FZzPwJJebmaZ|6 zdna$P^EDlrJo`nEyYhBro`GeQ66>JM?BTI8xs=oK#nlY9G;#PLyt))kR)8uuo*9T! zJ*{Pe5Yv~34P|qrjJJd>%y+0t zFyLO6sw?rQ+*z>^cy1{=l6I+fyEZAPBG2bu)(4qiTfX0>s)A<=Mv)C_OW06<1#hCZ z{JC7qCT+p%=_86cR;ATLiaT`dQ+V+#p=iGB!+pS<^E6iRgkzk&9Z)Ac^eT@SY>zKW zyQ?G&uPNLGh%n+WAMJTVa4>WEVEI@w&&SF-Dx|?BNL28aWmZ*L(h-3)VHoI{t>~xA zFq1eZI<+fLU{10jyQy~%TtyTAdmGhue||^AFHgBGOX&J1;qX}zJ0oK@C$80H1ZROj zBjoy(xzMrj0&e*TJ)XTcwTfgM^pul>MzMYzj&c1yJi5>kH>b-j(kcnO@DHZH_wC>k zL35V-`cG%Zgh>3>$Nch!r1ki~xD13)oM4lLnxJYZX9|($uiU>RhgOseQ|o0SKEF;T6{B_WSY>O`ewb#3oe)4#kMv5 z#IuC&VrQMlk-@C4-O8;Hc=*Tb2Q2yRx5@>#vcG}98wT1~T{>!P8Cm}=gRw*W z@w7t%zq5&YoFyx}3=5KK#PFRU)rtx!ik?pMmh&4Y;{5j-`7YV+@pA30ockZxYK}X8 zxxad<;vRS|kJ(o_|7oGQBuCKBaqo8(YQrMW#*t9=bKq{;*Va0XSL|09YmeAAu01>0 znw=_ANU|k9y?X4kRJ2w47*s5Lu`aaV`{`*FK+)ZYZ%zd&X6eUvzUIYpaFqk_EsLi? zfV*198jV{Q^N?rBhrZ*{J*Bvm@+rOP)moU4lLNMnW8Br_QyW)}Mrl#f#MG`-RdttL z9xG+Iv9=LfiPgzqk&`VnBC5`_zOoadcS`}db!21IG0{a5{0{a5{ z0{a5{0{a5{0{a60TY;TdCiruwgDVJp63N8+JU!xeeeJ_~yrPw?`*E z#fhiJhR=R|GhhW(0Wir5)v{Gjj+|-u83PKs|8{G*c_HlIwC5+tjV=oLWSxAojN}19 zh|4JNad?oL{LRg{j#1;>5DhT4wLOYq>eV+Z@OpQUBMW6{kU*DzJmIYttIOy@0NEVorzVLe z9=9~kiMK?_%Q5OYpAoUM6<>?uXNrCeVr>nFj(~=NLj0+zX1#HaObcY5RRg$S;FRAH zwv!pjdQNf8O`gXay})uS@>H%PiOC5I;cNO`ce%9NTZ0crU7e>iBWyl5X;%i6yf1Re z)5$}!mr{$_Xlta_?WDZCOjsNQD20G^5f=*ZsuJefVc^->Imzi&qg-)VxSp?l>%Yw% z%%{3rd6>eQI82;5b>90K<)yK*@fTa_JL)7H{w$tpu4BiPFujUtGke)KabQ!z(LKn7)D&B-WAMhjCq^{3hW0`VmT=$4W$!N zhknKe;(v6dk*xa&<8)5r0o}hXs2=L0h08{OzD&-QU-rIY=LTux(DdYqdiM}=e4IN) zU#X2zZ8bx7+N0w;&JVe?YUyl4E$=l7tURjdZ0H(2$R^d-c(-Lpu;xMxn=*MkP9-$5 zZqLzLyFOid@A|*OPD{akUN?|I6Tl?nG=o5RQXDf8#3;N4Z1j?A`>1`2ejJlAX+P z`dY!z(9y#93Z)GFnq2O9sUt6Z0t}A8c1~UtRDAs5=2N=eaGs=3X=~n)lGi^n0W-pd(3S&FMGy{U;z~RM!pfo#cHktf$bI?5r_g|shg(6 zSuK#RXkjq|QRl3=B!8qa-)>aQ-!8cv0o`;0v|a4q<^# zr|a{{HaFC(W2#=)Q{^uHKwfflMna0Q%@XmS84vi&DXW&GaC2E?+lCI*6M3pQCD6YX zYg3gTSGDE4)lINap}nXLXFJta^Ii?iuh&!y|9tE0{}ec6J!nw77QEL@2pnGt*P7G? zH_DPhJp$J;qvKCMC^*V14lFNIZj!c#`pX|tTLQnvdyvSe)@@gRHhun2z_YC9GrvFr zALLmj5z2ZTSC4tqGW3c1F2P;4L>oRZN~?{-9G~s_N&dFGWLc4f=LA}xQBgJ>>aHj( z`$MXJ8~u;J+spQ;+9@^&Wj=bSmrZ zy4|A^k^1dntHr)7v)se?sw9e+E~3@RuSSb-_|RxsNg(I9k#sWm6&@8#PdAUX2APdn z+-kjl@~3xcUAZyPIT#_9!Dx5`tf+QzNofQ*!%3d$F>C#wrihd2?bW$&f3J3`xLqQh z`Z!_sKLdm~`htXja9@!F1|cn>6Y-elBKtiKP~D%i+!K3OlQyS+9^2ee*5n{mj}Ngj z+3H#eKlJ*P-);!dJKH^R#szwF#{Gb!PIY)|&3Xxx(ps_g=appIy3oB2j^*IQpHBL& zxYG~IB!Lb_a`f}`Bam!9P)ChUMC1x>F-^MTU6n)4N_r)>p%DF#3+g0ugS*obM-O6u z($eOdL#q2FjgEo*a-hp3q7$J}pHij00>*T3r~IMhnHvU05&+gN_0H(yKNHU=EK#eD zi?gqwtVGEONDx=L=aElpA&LYr!Ax8_AD;ve&tv=}w8$al0@Ddg)EIw3h3Dc;DN8g? zOf&`GW5#JX8f*`j8i+q6v>5%r0$q)tlgO&n+K|#ZfdBq`cbHsTix+oYduQVNxOApA zeQ|IRxKt$wPM3v$MN()mP3OLZ(+De-w)D$;*=FI%1c3d1VAxK zbN`!hY{~}O-33+TQU|VdWL%Q(n3~fT^JqQ#dYqdKTTOwsbGM50VK7b#Tt?BIA0K#5 zDm6_I`e&&p(W765ArSwi>V`M)8`QLmA*>C)d-=X|?dOK$wy_hz~_5B^}zgkGipKd>4U$oko zx7?3cyomdRWV!M1_OI+?*df$kmcidH++2oZLvE~ANZA7GHwF#r`^%VB5j#fgn_7N_ z76^X3J7s9>gF@SY32i5-2gtZoa|j}9hE02svbu|@eDcYXGGJ}7%tvUPA>#PGObBM? zSSTSplLsl4(AP0O3R->FL6&k?cLD=3;TO`|y)d7KjQ$oN8#Xqzcr)9BZi_QF?hf|s zO{&)`b^ykz!NF6OoK{!m=9N;MQxvNE6_mP@-G^MJE#&9F$QI54`X}4g_bcb)v^soQ zl#v5pOqZ4=lNHNxhdn5*!h`MZUt@edn-pi;EXj?MiA$jz&f>&Edb@7ePM0sNB#%-o zD3I2%8gv8ri64!K!8I157=@!Lm2OIi1NpwnntOrldgVdHf8MNK?=`2W;Gr2EE0s>_ zS1%H60g!tWzL4LzGBlChzy2MX18Ru=?EcY_-Cj-~H7B95q;3 zm7kP3kN(_X9TKJjrgEZ@@z8D36TJSyt6Hk_)=xX3qmBZ0tFGbL(e169FPm-re>x9x zsi&85S8rF!_Ih=0Ez@a#vnp(Tn9sDhu?djN-dZYLRTu^0k$d>%Eq)r(_HyQgB8s%; zBsono?(>m^vL-~BT4SqYO%A+y3>fEVJ2pXWn?+!^`)<7^#8h@f)I_>0%E=z+UtjHK zDsDvd^}omhKt^~`TgCTIuP}!GXshc-vPobi;D4@&4@6t)6B`|9cnF6N}hg)kl%1`!#ZfR4sY7;J$|NaMA{mmf|bE z$ynzU(#T8M)$)T*T>RX7SkmH5Ny(c2RO0Ty{N#i=Z-d-zMr-GG6;=+WmuP;wehd{7 z7DRTpfO)6fJfCd?SpVrNRTow!533rioKL|Rhg4aB@u}7@$48+PmCO#^Fs7WY*@0b; z5ndBOp?%iirW(v}Vy2{qi+1#xSc*Pe9c(d?z-QhaG51w;!;$Ob-$-4#a{`jCK*^+} zr5`>BdWWwAv^IOfx-Bt)OSf{;A!AZj0)2kQLTc`Sbj20H*W8&>A`@~)3ZXq^?Slw> zG1?_I-RAh4Wo@A_`moL}qywj$bLgQoeG=_V#tMUg1YKUqm&02N_6DDx+GxoHA#kew zOd*oEqaY_bPV8fju3cyN zVDt2!4X>%a#om4u>6B%I`+ScN1dkGq98Li!JLWUh_?&w%H~PJ8liDW{Gt%!A4X+|# z^~?A3@%rk6MsDSWhUc)+zWP@nA;Kb0(^fK%&FiO;f@hy7MVVK`ggAY={{C5R+3_sH z%ep#M`l158a2!0wrRKDkw(SN=-k?zjj!zL$)6eaPV0zmu|G?WzQ~3M9&v z5TFOz7A~DxhHPf*%lz*{Tf&dF$_w>=ez3bQAH;f^^906_5$b-E>v`!%GfKA9;)X`o zD!N$JS$y?ULdr@2TlntfCfSD3qUnbp?woW~^_w$#cZSf?@Llc3$N%jM&;MIHmFA(< z4QIJikFp&SriMZnNlivHK)};eS?*o`*46a4nmlE?*69nUaln`e(By-y@UpUz;u)I> ze{q2&BGP8;Kff}3NqS+L+e26WJLwqkWw z7ia%m_1?vJxMjzGgg?Caov1LtAj_K2?1)ks6f77ITGV68v8_j z3aS#d)8h%ipFBCq32{0+DwVB%p-JfR!FQ$V!ppswK@~s3YL`EgQys!7M!&tU*}maA z-Og;qgt0u&0Ox!ieFoW*c!WcOgJYsOUv^yAMq*{G74lE8KaJ4@(L(*G{~8~E+P2_n zM~*|#j2L9CffktK8(yi{CyOC*lfanOA$PdPvd-tez|4b*i|kN=>zuk>X`FHj0Zi%+q&uqGNALDbq8o`t#^rKl>;9#8|I6-c{P#Y=R|7C z3=o85ubChW@qapZ8Z_EMXcn@S+3SDncnQF$FsV$TI1IyW&3^lb)bnStmlJ~*6EGaI z`tTLoiYqt%_;jyrRdmInSi6<7A>kScD$&OOTv8e0qmhSw9ozMA=1H;Iq^IEktMKn8 z>tv^nS<)l8*o~=|XG)$x5XkVxsXu+XITtn@O9D&_yLwRS+G@CGw8EHlLsN)40V^

Q{@B{gOzdOP$;iXTE9K$D3iHWuhK5rx@d0#sw@#k33}1`7 zB`B*cp}$6Os9JZFeE+hjN=oI3P^=O_PC%XQBZXtSu)aOSs9Qt1zW01fdskSsOfOJ)vVFR4xjAV``I)4d2tcp9{C$P^Q=FD z9GAza>freL>ucZCPaT#z`M&jIZGkDY-%T|H6~$Ovx!W>SaqE%SY$y?g zF_IwYAwLMT_A^C+%(XaqDnMhzUXg(rjEZ^D5Ndy$qI*Hm`Wm{x@ZCv57P4(@Sjyjz8kghwPUOoR> zPW{^Uuo}#@`q|qCRwhSn25+v4&?-O5taaMFb!%DJ<|g#PMg07({rD=EddNhB*IQXp z?7KUu7@KmXY@k*m0=3m(eS10)CUEm>s%)Kz`T#-@Iu;puBgK9^l zJv++Q)aG>d*65kh-~zEW5VtC1*8T~@uQelK!=c7(3|rj2M*hexphZJs=YNIY4h;Av zXIsjqd*aU)3v=Z0)G@6~*{Z0_2;1id@Ww%IZ2Sz~QfGSj>N0#X6<*^fH$*djh*USL zMX~?Xbdo#?DBWXk&SxzbURDJhA^5xkdPnfO~{7ebe)QQiAhz5a&p=tQRQMU`y9XM1gNmsJ)l!u5f}_%-3=yBd=Z z@461xIf`eX;!(D>4(@l(V_mZ2uM$k?q9yZT_i!gL1 z0pwdReQ?NU&M`Mre6IQMsaS#UEveUEixbbcINS3F`O;dqTGH1JepK`e*!2}ycoBN| zcaQt?nA>Cek?v2v);4wjE=cxei#k;^E0hL|tyvD|MSWqCZ&ZGDfQwTKKgtJqt0>0R z{HUuIv+nZEmV6$e)f9C+p}~6M%{qSP32T-yANl{*<9q*X_XYL^_67C@_67C@_67C@ z_67C@_67C@{*MH{zvVsnCcUTrM*RIDrNnLWv$sk|U5?+=NVZr?Se$+7ol*MmB<17L z;_ipqIpQlM4<3A%8n8-O{a|x0@v`XE{1P?Tc8=`K+s`U3ZtGWW;W-YW%swn>qo*bP z)>BB_wV!VDc~DpO)>&3ctz0hnW}e=7rd*slU|R&hV|+^W{=G?1*U50#Ulsg2r|PEJ zhyIsqOK|Oyfb~oYKy)}LK9JM7co7uESSRfMEc6J~ukt2rr#ngLDad_zoGvr^&hX%W zN~o2~qA53Ke)6SEl(GstrXrS+**(iL*s#8953Rgbz4nGf)s32dFnKR&^X3YrZK4-F z7X9^fn}KYI=B1OX&w1O6JskhAWsf-<2hQ5$HS9@>hDP(tMIG$w@L@~%dAyRSzg>sf zIe9B_Jdfes6uhV`%$^cA^|RMcbi)_-t9T!}Epe~l{aY_1p*Nmi-bvYVYC_fZsoM0R z%!k`aNoSmAjCcFz$~#s$=zFz_rLAYqTGmC_b*t4NR8sc7-8pYz?my!QuZ6r z>8_&wDeIrCIh)$`GeL@xVrlahOO2i;)FUsoEFLtR_Bm%zLHN3;!u>9(0xvm4m_HTC zR*GaIvHyy!Zrf#KY2`F9DERCto(6cw$xGl8zzn(>!s}Uod;j+wc@s2+ZQJ*&=+F6^@%#v1tm^Lq4xvDPbkMan7oxvUY zr4?sO`uF$|==>57vSaGs@oHzZl)EVzIXICQu|%)^jM7qHHQhpAIH1%|$i+hX&S zwJTUZt@RtHbE0UHBlxysMPV(>@0?2B&Kvzm?xNuFE{Oit*r;jVp0*^`L|ShvS`s{4 z$tu!!_!fEaM}11IFpA3KvNl=o3upQyBq1B|id-sB#Mq@`4VcBh>z}utnLHT3?yMWH z`bh;gB!v<^QLxrDMd^E)x)fY(TW7yULGKLP&zuCMm*q&6}ub6EfYK(dCmUX4Dcbwt$* z)ZeE~*w;#EYi{IOu5NGSqtrW|ZzLx5@(+;7PdWX;Ai(3xd5#gm(l|(KX+3n&Z@Is( zDHlQ4#@SMMzAjtgq{$L?1c(}ysif;DXQpGXKK$%j`1194s%wv)0b+VwIYW#%b43BD zS?s8Ds&=)M^R>!~x-yRhiHIp%YL{u8QbS+%)4H1@hmAtLBUOVn@81FrmpJ+am|3>b5YP6g7m~5``GU{^5v{hLTIgj7`3uRa&IS@yF=Vf zfxMT7QoZZb^B4*Qd$Z84#Y~3L^s{oOwp!GTQ@8Hr->#Z9FP=9wf2r9*A#4BUM#|ht z+p1xYZVe@ylZkQ5|6TRJD9q21F9^{q9KMPNmJI(m?|Bb)+*=qj7p!B?l!5N72ND{^ z{PM^`!q;C1o{7&t;K}sO6Axdl)m3sNY`Bj+v+grzsw%`6cYH}vbMi19Y|}M;&s`<; zyV8TqCBg$=ezfY?{GH2Xl~hjEfzmIgram}7Fj!TmApG1gDAbYey2@SVak8dJQ|UB4 z29e1aZp~}fQoRssS$5~zAa$(O*h!adQMs;*by6WuR0s3Br5|++aYjGnZOu=ja8b0u z###=1E`n~l)Yht#re`zcx53_Ya-!Bib+*?lCqmojsAJZW5!=yhYkfIC%A;yDTss|C zno@E1M-eVw{j)Pho9B*4OLEsM4gUR_elHv+7YM*otwM=d$c7k}$ zb_%yYHYRv9@n>&n`?rh6WrAZ^B7IZomA!=UZ}2GioLFAs2h*hcn$uxwE%hPA|K2D; zPr~4l9wX_1?WtZwOVAVsPhKONnYyD3XDo@kJBfoXm;m(VN@O8$8D?#ykmnfm6it!4 z_Hhto4-#P^^Jc%<D)RqMx{Xpa#y{ z)oAA^h*!N~?7D`wTqW_NE5B}3Yv$GTPlog$kfBX*@zzq#LrwPgyIKsa2&A`f%^BZ0 z|M_p>_Gx2&k3B-E7`ZC@skO2xqTHU5%=bykKhH#6E<25(fN`YQ;npvjd+;;opraGO zs@EpVfASkgcX~E|eEGbA-@|#x>u(Ry77EHT$#`B(U&n47w2>L2DA7iXrm#|}yQ|v$ zYstLc5oJkkvD4mfMc$MolDm0(BtE{t9l_+M5O|sTTJHq`5u4RAfjmkS#c23q^+#DX zr=2f@htc}1@;N|}6_g>Ehta8rbf`pb|0no-j+IaROk6c;|IMyW=TZHk9P^YO#`f@P zN8~_fiT>U~JEw_Sl&QyXn;YQg?huwOp3aDP=UDg?`Lbd(cZ1CJvSOzYbUHL zM+>RNo#(3Kro`!30IRES%NWyH*vJ>x=btNO?+Anm5LgV>;5B6_(Mbc;Feuw zCG}fDi`j7?K9|yH-M*0;jT}du7A`0bb&@26g+}w;`D9L}>d?wUOK)3fGT$F>2>5l+hU)$?j0>{xwJ0#i0UE z-Z{VUs%dpK&5%0iA4#9T$lJ~9Xv~cy3UU{M`lt}zx+`cqMxO~;){0g-VsY6&+-l(= z;e<=`JF%1fJ)Rt$N#LZuiUSix7j!}~O;#5u6$Ss0Qt&T4)B!@$lf(L`I!K7hi3(kV z3ONszyJv%phkiaWJiYReEcoK9=gDgoN=j5WkLo|=$%4;7<#P4QOG_RFc4yC*UZ+d8I> zcp9_}_tK=SEnhz5$W{)qP>E>e$wX2v@92AF^B9d;GuBO*ErdBro(O^$8ps{oS_1zJ zo$5*?43pClBFQGqLRy#XvIxWv1rFcZc11+xXL|@pDz3>%_45p_@H4E8vhSjMHalUX z1^uqB6fzCAcRvL%+YJ7Or-m%Kj9Psnq+^3PykHIZO8v-$fhiZ~;ed)C~;7St}; z?MtPO;^9vo;U;>#t`%V6XdhTA&|+~beU98=I{g|Vk}@&2{V)P*unz~ zGydRUYf|)H-S&r{#xa)7*9<)U?On8Ta*UcN+uFfO)lCO8NE?(mRmn$E=yfqwuxk## z{+~gsR{GHLEOf6IQ+hY;ql{Xg-ODBJ;){@g=wGSh9%rsvDkdBZ?o(5U8wCNc`{DqYucJHJc(SB7o`=+rIRy>PDpQ_ zT2|jN89ri>ZYwfsw|qNhN|n6_BT?iF4b@NI97`BM?V7v$?QIBhtGAd09n_YR12fP; z3snT6WlBQloX}VD2Zo4t56c!w%B=+2n@D%%JA?yNgR>a6{tqV6l0wN&msOCpIcm?N563?x6@+3mAgfEt z9}Qj>R4+Z=ay=(>+ABPyPQIj0?u7Gs0PEH6Ug3nIm=0vw%6qaW>arsW5dwLkEN?wC zk8ufn+c`5qic!=>?JD^=;2wTSVNCx<1Z4U<31k_x%>Vg4+eld?tLe-B7fjirR)-JQ zuft45MhrkqU_pm$8md+6U$5hXALZ^{368lmPD}w*8J}?-{kUZ)takaG_ki_w|oF4CLLZnNPR4zy%`+2z4iO?%i#aK%M-3R_rZ|KC>b3T zrzd1&q@C)r%my&y-`V^;G+;dN2ZeR}@YcpGs}M;l&1L){gBPiQBAZlaz0)7QtrJRl zR1TfO=_D;y1`MX;Q#x8r0op^hWYi>ZYZ#dmo z^;>A<4XbimH0goiz3XY+w1x2@ck%h9?QmG82#TzwKbw~h%$ACqdcJG~nuNu7uEQZj zYV&0;sbl9|Y3_IYM_Dg$U%h+>l4xKy*4D!)!1f%5JZ}~OZ_?4&4W@Z<|9q|Lt+Fxq zVJ?C8xFTp|)7_dVy|zG$B!7VTF*jG07NTcI`N>aL4t?7g=Cb#@d%bH4~@!R>K+=L5vuX1B19&RpQ#KFMJ@l1n+vV;Y%Q<#aUOJgngRfD zAW-?U70)0ZNH#Qx6K$zNcAogX!4%`pBIowdf5+8S?SYt&JHE)X&*pFS;7jMiRYBWL z9jtqy{MG7`V0OsdlHaw;`yueot;H|i)gsARvF=yH($|i&YV?z=#3ZicE`})1IZBwu zuNR1PN6@)l{xH`Y(&+ir34(^4T>LQ&+Q?FW^;9i$aZ=~O+8UO_DtIaEH(M2{G#$|< zf^L21TLcXWSI)flFmq&$!=cZNQg;`lJ%u0VN78nQHPeEXrygC;{|NNyjhl~7o>H$2 zYkEIb=wt5ww`{rTif>QEt#eE7v_jaApgZ&l!)w7UZN^h5A!*wE(xf!Q#f?f7Sw~;y zptE_cmtM=9jqksAEhHUowXOqDprEDg8C7Y_yNnT%xb@obsLDw)w=pDar=i69gl!my z!}UHhdGLlbzI<_( zbx^R_VX2fdB2ew}tcLuOfJ>xfv@e&s9;paGZOiEIrFBT8xPkEgdx=a5&XtGHE;~go zk)`MM)Ss!$2rOgrxGjCEtdfuZk`cImbsqZM)C1D`MJV&>(yG(F1_?1J+FVeVg^8|= z8?E54K-EH~f~gCS794>MFD~QOo$QO=hP^WgmpfsPW$B7U z!GZi>XXVv(M_t?iyxiifmb#f$vLy{7hqZmew_c z<)Xx813`$+PT2r?S}iD+x1v&^6HHwTVfzAa{M4w*~3QlLNV) zkkH**%&TgjA7xS-!0Ed;N^ZeGspKE0FktnAg&C7wURD*f59Tdx7mk5jhVKlxk;_iT zG#pkU{F%7VsuE8BUXx6!Cf>!(AasbvhWD^?8(?#Ul+=4JI+vV+Cnz74Z%NchoEujpb5 znCf;Ib$3yCl!|)jqbf02``IaI z{0k^pIccqz1GcP?$#QPh#NO5;O(|ByxS0q!-ooXs6z#0e3%S`?rdvPvG8_FPjbXWE z|3n(t<*guA!IBx8xsCKWi5m}=i0g)@DFpgjlVD|Lwc{mz5CVn%=0b3?OpjArSo@|s zd-DTr;Y!pWRLG!Ni8RneZXfO&7W>BE4*KQ-cIIjApF}!8WctCv@Zct#Zz9| zGo3GuT$F}P4W&^5&oaK;3DgACO}kAZTSq-PZvLc=0{`lkk zDMjh2JXfc1#0AKvC#%XE2*eq|C;H=Z%`&r2&fQ)oBkMn1`}|G?m3M)b=T|VQ64IXg zn=*=)%ZqVMB?KH(25YoPl(Zc`F~fMFFC`(K2i}$YEIs~(m}8SS+pMcR_t-Rfc^1Xn zwjY*__ST*>jmC7&D*M<7EKM8JzMOl!mOt!$t#_{>q35H?A2|L+ieM42LKN6MWUbC> zy{U>Gw50pFE_$!V=P^r2kuB|Rih7Ely@s0B@}($tCI$yr98o%v+=+oe)k2xIdSp$* zT!6BTrAQZ8SRO*}54UR9z6&#&)>G@Bih&ptK#Xu zXD|7-q__+y#%eb~#H*s7tJPGUN-X$Wk5Idu7vj#jk3IBmJet1`0!NJWrghnfUhVuf zVA<8Mu07{uBY@TNmq0zG*mvOVl9#PfmPB_-CE&in`Bl75l$-7=y&jPg<_Wmi9T|K{ zMPT2`i>Oq^+J#RS=N@d?0Nig#XQyG3wKj%@5zqWysXj@((!bQ`qaLTo{Bi!rsnDdP zA2%J=@1^yevPkq)k&nzgm!i~(3b@L{K*Fby^;B?WMhE=6h3{fk%Cl@o&`gMXbcwP0Y+~JL7!5p%MaoOt z79AUv>#cnL^M@h+_mP(Srm#aVMaK*;XL$bm)?h82`<(a5pHKy!MZuFz+}Vi>Oude* zEiE~z06EK=dop;Hq-3L!3g4k17UPL_!#Z?)(l%`iS6V|>E!D~CXlB{R&XsRO(+VOK7biFuKf;(gr>-B0EUy`mQDWE$PqW7sLL34Xtu&QayF}&o}MXEZE~IkMy&*BnT}MQJYhf zC(V@Qe!}9GO}}XMc)tSK+M(yUSs$ z9nB+RF*X^+eC}s{O?q(@bqZH|53-%exQAUeSv*}eSv*}eSv*}eSv*}eSv*}eSv*} zeSv*}|DOWf4xaAa?9I8e4K}Jakuy%Y8=@aft)X=H)2->2Hca8Xk(!57Xj}Z86NMC9 zIv54kY~~URlkj=sZ6wDqPjLn)%`b+a(5>6a%%eZ*YRcS zJ|+caJv`lhJy8USlFg2$e_L8#<#Y}1*j^a-#6^peX|io~vT3s4bZu`QXMPYK_sO<#D^SOuPQFO?En9c;%e-RB>xJ-5 z`9<=22_@|HC{kzF546Z_E##y~dz&Hz`ujcY-s0lUA|LIDe_p$tBy1MR6KIWRo!Z<%~WkKz9+WXf)<7c zbp`eHAW^(E_!bMMWovz60Bx;DuV->GpHfQIlQ6e^%7b){x!8aZO>)5WiTv{ED`V$q z((~hFYotVE@Y;H3W86gi)D)_p%g@Fn>sxv_?YiRYM6bj5PZmMkkk^r(<^}%N3h8791X#q9N9{Ii6`!EI*Lz zY*D4)5t1fU6Sy*FE1LS_l6d_1x+iAGd9J!?MAVN`<&nrYBA=v8)A&-n)UPu4mMSMu zT;A~0F#n4wa`fYap3}DP%Dx0g8-`YO__lobLix93TE;YCbyDt|OKw zp;ipM_WtF=)heUPnKVsTefW*ee_#5u2?buF5+{>A zZht2Kho||ofA1|lMYYvue-AqRKgaIPv0l{LEYA^kH*M6+ zzNYw=Md`TW~h zaBbs&h~l%VNZscmM5z-1<-8jXJ+10bX?=)I%W5kT zz9u7guSz)mQTeGqa1iAMafdt5oY-W4@ZDk9M0-TT?_gy;{iC1%aSr7%9-D83n=R!z zh~ikU2Wq8cLrh98S@(b?A5%GKD(-5B&uPEK=onvD8i3XGvkJk1zB7DOze@ z95WHZ{}K*lF8y(KsDTg5`U|2Z5VI_JG`P0;hC)X~U;|PjN77eEAjm#@w7<&bxoUO9 zG)}@dYJ)mp1TQ9APPw+aVzcdMw>^%B#3~hWH^T9q+=>412krdVzJ8hP3(x)(-&Jf2 z`-wgFY7vjzAa<_7J7*bw>w)##5e=I5Z59b5K4w#wDjd@W-ShqEC6%j{uv&>_b-#8f zdA<}V!dKo^c-6e)L&@I`G(GTQ;GT;Uh+oj-%dK1xy0FWwT74Ot!U*^U{G1YCZ)cQp zJ{I)%uO@g&9!VW0WQzIt$o}qm#MB&5x%y%yB>g2~2;t{s8_$Z9G-Ttx$t=9IUYW}L z<=auO1KwMuOBXuGEt;;+wucv5o~sbMQ9}Qe>Nw(@Vb}ZUuyrAf{&_K4P!4hH^xF&Y zd9w5(-_nI%q=>y1W|G4<)8)}mP_{`MjY!|~z^VL_{+QAYUuDqwMxI*Ow# z+FIYBJvQi2=oOmcs|$-L_BnYg4b)O~;h}3*fjob90bowM};;VPY7_q2xy1?MF&##&7 zPcoB24gaIP^Xh8C3)+2be?U+WL68p8dlLkNAibk>q(tc*MS6fJ(lk;dy(7|#bb*A3 z)KH~{5Q_8~kQyK)c|GSFoSXONcs;Xbul2jw`&l!yXH4saQ>_&=0IrTy1sToEHTnfP zlT$yw;GCP1t3`%CnBO^`oC+T2GG$u(Wd<1Op+66MzYCAGwRbEYA}F?xxX@W#c^S7k z-nA7W)5!KW!e(vgk5}NSuh;({)yF^ge+2#!_+KD!MkX;K@~sG^_=$;Mnvryoo7HBdoa*% z$%^Q%(TS&rb!taD83Y7WxSQ}(nHo-C>OJ|2&~@ni@!?t{+tsAF?VZmaPIpc9C##Cf zVwz*fSiC!yW%EM@i^rIoN9H$C30*D@lqyu9gHFvcHDzp)UPfNM>X!`!NTC>q#j}lSDyB>@_$w@^ef6d}!O_Isb7Rf_Z(Z_lw zKzo26{wQOXn&c%nUg`8R4&s_gMksdn6Q?~xR7x2nmG!Re|}@N>D251vgKKEHu6T zZT{eHpMhLriBY{LZk`S*JR}@r@R@2$DU0KtP9op%e$R4YhFwYMQe@e4zQsYnbmDr< zZhXI&>6HC5<(%B)Ol8}9eVR7_JC!vf3mHRsA26sffbE?rFHDERj5O0mx<_7>L~r~< zW9L(oK=Fiacv(f%f4VwW53(E^WaJw1G8kj>Z{hIKx6c-S-SP|Zm=(!7a8416`t#{F z;8KV+`Tk|WjM>TitLwa24ZvHVlcV%|38#(TtG7Rum^?z>yOmPvZ!g&3aU886)nL;= z;p)6mDy<-0L8!mR=_DuDU@s{`1L1^L>k=CZi3BTKJ{+C6G2+7d9_L_vUGlahw7TeA zQN|5*9nk#Y&+AMx>JT>=p!oiCUUVN#+IS=1E&8`{P634yLyA`bS$tCfE5WTB4OuTO zY2>43dY9=S!fENHwy~1@GT(W@i#=|Glr57y@!#6F%iQcu9-BhD9A3N0BIt(GKVLUj zdw)IVX1K%c6o9J2Ht#p)3{V1wlNW~Yd^TlZvEfSyVh_MmmuvEjm+>Yl-!*K3E2F$o^i2~$7}m?t==k?bDg@9B-9^m ze%J2hk5+IirTZgE%0(gVL)*MTuG8;&euh1W0jpoS4T;Z}bn{FZte}Hw!pZ7YQb{?b zVfxHJyOj=Ep*+{0{Roj!6JX^l&&ZJ5Yd0`9{02H5&sVBC4RvIOVP*pWf6|I9JMPv~ z06eEV7lV~0<*G#PXWV73AQNbNcE9bWcu7L zrDNcWZWCn6oI3*2x6Mn<@u~M}bDa&bh$DXff%D1t6wEj=SL2r`M zcd<>dj-N47G75@XKvZun(eJ7E*a|(=Toe0xSe;}Apu1&VZ^T&B(#n#0VrnTbJ>C-4P^OieyBy`8qyL(um}G@bj7=2gu?JoiQ#R=Qt=xmj zRQWh}w+DST_j224KKqy|;k-jPvI+~|W&>~Fsq#F5Y^1#pWS>9Oc#UR8T-v@#(o^Ey~LdNpL{^Z{)&5L-dH(>=d zs8-rW)D1mniI>_PULFy>vZyesPZJf)`$k%K!sH`B*MeTyQ0=f*G?$0&YB`&&iG}ww zBl`cE4?}1<8Q*kyzI-8)D2qv9ssx>pF)=}Tx2qPz)+AgCBn)sp z8Trch_x9@5%8K^tA(`=-xeT*bp|^=lQZFoEX_Mz;jY^p%?t+?Jm4O4XP4PAI38J)c zMTjZ70$=;(wu4-dY(}f!n@09NrYV19$TQ*cS=t!wUN6xF(Cd+ zt}lrYXMSEmJBmDSw^zRC0}=5c0xK?&f1i9%X)137*|JHTj`*_)My@AXA_;^g91`Q7 ze9=4tJWmAPOmRixIgNdYtxO*@TMuXh*Dm^iWN)HV!2UI0=K724>_g%`u^5%p*1gAK-E_JuY6X)dR) zNdwtP$l<86SmP=3r2XeE1?03Hloq+#Nr-T$Bhl9EX!Xd4L2_Ej0?4B^yROxt~0(D$jEH(%a^+Ykr~`(%wJ9ugID^d^qDTY5FOaIq_$s3fibMxXo$W#@u>3 zV}ABjjF6sEIR$hqIE@YQ3+*_W&@2y%mxI0IC=hGIkv37a_R8La zeWbl5i&^8gx+0i(8a>*c&Ou|t$th0U+>sz-oHuaRbm7PO_X?YR+K}tv`?M+{?YpfG zdx)OkmUNbI?5oz+9|X1_MMG7ayFS7)08I|BAm-ZxKN*lpTj5Px9v{{+y<1?)K@|JS zq8@wAkp`6P^9#nb;wx!|& z>z4sVD@#a5*`Ao%QPI~CqnEl8kPw&jfQId3BTHO$#9}r$l&>9EjVJFkdrKJZOE7pT zdEUlEtvtixDCF$sbyen|z7&-UwCnjDNv_|LHh>i;hVK8i~C5Do%AqEmKi$A+Pus-yG0Qjh*E&c7Unpu z@xHYZRD&~U9IU+}4F(ZFZ zqSgDlkztgjY^Hs{JM+bnPgj_Yc6A@THK9}$j(RIZf{!xThA7qRGhTmpoy+>F=)vB# z`^^8bH62=M)fG)4K{)E0U$2o1S%?@B1*~C-2l9##$=y$mEd;G!0EfLl8;-P;6 z`kdvP1JSR8$WCKEIR7sbonJR#lHDW`6XtJhM`=L>fjO*X&&Qt37rfu4>s+7J8@x-W_$i z1*1S7BE=Q++RL~m4fz*I-khiKi>?~ukmhq*fgs|UeEddBZ2o=Z#PlvrIqnPqogqnIDwA2>*T_fR86&irKn;|O0l^UrspYgY?YVq%zC9G+Xb zc5yowZxzR?D>%BY!Vq`XG)z&U0d%XMG~ML3YBcEz%dwSG{VX?!i)`*W(dQ1U?Zm?a zQ@mVoe7c{ki_Gk#JUPMdk{HzLU&)#c%cM9a3v!DO4p)`Q*&S&*Y!=_t7awuLS@;#{ zrRu)fy6%`FYN#fm1e?nk3VS6fqI3`*>(ckT};Jd_&8sg{&1yO zemex`YfIciB2i7Vh}wvg<9w2=0YgKHFLAB^xs5z_w3>wTJ@&YIv%yMt(q{xwyO#?48Vo<0H2n4E(V;olS%wi zAmn*T`v~?6dI3Incmv{z()NwmU(89kon^VekpPsDV91Uo&%MHF0|`eEf2sPk$k5cS z(+a=OpMuh9$>*=@2wM{5{4;IxE-o{>Qk#6~<`h8qlb`6`Y;w~Dgei(j(M1HtNR6vW!VNk$#eq}C+PMnD1103#jIs8+(NCOd zDF<{pJT;=rtWxZrcZ@fI50yX%PH@&FZ*<)N^0M?Y$aomzAL_$Kqt|K*? zyONdA4J35_(F#6i$0135f4%%OefpcAZtSF$MH;BJXz6{NEBS)(_jfe}h&60ic}eJm zJk$|&pflx8bQICNQJ3Mp?0nWomP<_iAdXG;+<4hfuMZ(0H^wSNT>QMFd9)ixNWb-6 z#)Q+x&5YEA;xR^~-cmRTCULp5_2iwTe=>jACWz)Pl)>GhLnqVT-bk&Oit{r4OE-^Z zR)&O0b9O3h_vfmpzANn5A(=<&2DjONQ6wv+59JJ>1*Md6AIzeHowo9E+v4Wp3(Y8F zPC=l%!@MJ>a*l?BTu*B}In-EFUe7R(`M~}zLOJDA(kI@e>vtUZsHNYTMe$y}E(P^Q zK0Od$HFANdfE_Nrn}aWhJjlqM8K6<|^X-JZi_Kyt>@j_`8NaFyg9$1p*7Es6_+A{^ z<_X$B3A}9}L**!ZM~hP3lG3^iw=IkMvH=XX3O_mB8f3zEDcbV1QMG(@_D{N2?2z_0 zxrvQZU9Befz3jB{-FJHKY8|Q9GL5R#nCFHB3K$N}n%IRvyfogc=G}}l1}y+B%R#;C z7;Q{;-_+$`4oK4gXp@ z;5_Qx8%yL3tv2f4UneNiPv}joCOM3=^w<1LEWfj#JT~`5vi7uV_$o(gDdWDkv2Yq;&2>cgtY%6q|eo1P9`c{|t z=Xkujg49V=ysU{#a)uU#HKVF{@vNd|R0|>EiSUiT)6%_O`#`yAwtU!Hefimte|Cb! zr5G?PFXmgAn)67b{Su(8qy6l0y!UmtnGoN5rOKRP%fW`{^jP6%b4Qq3dObB;+~2dGT$bTu-YU@` zHl4om%lp6A_qKMO3yZ$HU};H)Z?e|>qqMtQsa{vRf?4RLy0aLrKQV|p!p9UCa6h9d zfrgoyKWIbfY<2h1aM@C*2g0HgIhlbwYj%(q)gTalPkuD2zMfK}S}g#nYV0tMPIEaP}Repo>{OS|<- zM>d&Tj`T&i;u>8C!U)eP2S3;jcDk3BSG8fuwmU3l z%CiIL-gdCal;<-{>VCJ-=Bp5AiI{f&yTU}$8{B5-@*NqLRVzAiIG$5wB%v+#ro2du zudW5bDo!Z+AU%R$+wesM^KteXCCXb=K0uGW=Zy|ft=|qZsC3TVS!q}tQGCvL z*1^w+*%j0_S@sF9yW?ZXqR_{R7wAfsb5B3lGZ#;1@ZYESJFp((cZ!*~qS_hgS>2=&8*+WqA)K3iWxX1ELZ?L1nv}gyE>=m=Q_K8rX$aJvt;FO0T}RoJ}kBCf&09&!dk2b8}n{e9%)6~SK`pt zhHewp>$Vu6iQ$()kH0M^2dB`5Vqob=?e|Y>xv-cGl^vgLZ=!g(O1$xaMjdN^cCs+g zCP6r;&zpql%C2tRw@-%6kD_Xk?nc(!W~SmFv&nojczKc_cJd znBfyx`CCp2wYe3!sZ^RS#bL`k>+Y|#X zewTn7PW{RPAKhnhK$@Ia`w}<4;q>*T8Pd)vMVo2mZbq3e#pE zHG3Tud$zQEjl>AsLHWvphZ3NcF$d?t6AMdrv)fZDkdD3f4g6*3N#PI&z-fwC`>tn} z(=a{Gt8{K2dL249ot*`b$YSd) z0Ns0?^E;ZE?K3t(Dyv^coK;s7+$5y3JvSPP=Du*`ObJla+qKibF*I1beA)d!9o0HR#L@b=Ws(hbor!+ z&!=1<)7so9u#1~E`>8?uC!ZYY=%hYJ8Z~PlG5f_bijk6FhgP78ci#89Jr($({n{;D zC9V?7Rs4-AeJ^o~Z_$kRZ`t6m#U^`>Mp)&n(ii){8=KmH`PI@ML6^WE^r&fPZJ~Bi zMQ&$aDAhm5W-lpPALgZ1otb(p5`q&ABi3-ol4VNSsO!rW5K<(B{uD66XGPwy6VsUk z;{~&O&}x76U8p~{S~Fs$jBKnBhePHAuHqV)uD(Y{_8~O=yEeuK8r=~{U z3fdj5-L~&L3=6!kk1L^*;fg!Id<{9>UhCxHP0VYV%SIpHQ`V$Rw{vZAR|x(tYmJ=A zr@v|~TVgd~6J%eK9|At}>iBcHlmqKrX;B0?RtKbdC6`!{|12Hi@_jHL=V8xUsTd2_ zX*1T)~d4&<C zeG{yPcaHBs>0D`9%{_nc9zZhdE_U97qJTq+o=(39w3f<$cbH$ zzZ*eWZvej*=K8tHltQVt?+MW39-dt; zoBrt;q!_0;$|tj{79nmRy+?lJ?~|#2WS>~o{&GdD0La$H6@XqFYQShbr{QK!c*zVv zBH?s|(Gn0CPb;v3g%CGjbe~$}eoUnI2}~~nzWT>n?nt%C*p52@GO9jJ_sPQRRFtK~JBn3;t9c4D0@nd$?*`hrT2&qb}uhLZQF$ zn{1kIqVcI=l;^w#QVKTN#UAL-`|6(hNxF12Iq+L*9#tBS+oKIa0-jyGBJApG;{K_; z$QZLP(}#vlQ*$hbDUmD}%6=t@k=i|UTH}%j^W{yQkaft7|Al(% tza#z;_($L$fqw-45%@>oAA$cb37i3+3kU!0JhS}sZFj?xLXimo_#YS00|Nj6 literal 0 HcmV?d00001 diff --git a/docs/img/shortcuts/shortcuts-settings.webp b/docs/img/shortcuts/shortcuts-settings.webp new file mode 100644 index 0000000000000000000000000000000000000000..e552a0f87a6dbd624c12245d4d40406cad52039c GIT binary patch literal 64912 zcmeF(Ydn*0{5bIX);A@hQk27V5UEIooGFqsF%+XHa$LsDd6h&Ia*R3T%qGimnDLd6 zoN}09HZiAdHaTp=Z2tXz&;O7A{|D|z+wKSZ?0w&_UDxMxeXjd~$-R4VynXu|jP6+5 zTi-U1`1@Kp|6B4tVd%NS-S!j77hmmqa?YJU5ucJCr$C$xHV+GKqG8;K4Stff1I|TK z`#iM|{Cz{ToMb}2xO5&Puh6Xv{hZ!jWW+ruHhkILjji^p9e;QxE9IiegNr5?j%H&$ zv$Cl1@M*yBcUhsB*aF#(@!*Zfj%Rh=YcU8F!3#yR0JfgU%GY+Sqv ze?|Plcz4mHx#8l?z+;I&;`)BhA{S&d`}+Fc!x<6JJh~qKVQ-iRLZ8_KA3S|=8=GZ9 zZt!!uXp&OA9%*Da@hLa@#)VqNn~1Ef5vg^7Maio-Z-U(e9=s_NxQ$g&K>gqzym&u_ zsHV_v|5!pw|AgVO2#bvU`}U4Kfjxmefjxmefjxmefjxmefjxmefjxmefjxmefjxme zfjxmefjxmefjxmefjxo$4+LZ<)P)PKsiCT-3^hJz${g)!^`@azs_jbbuTB)p*ZG}$ z1`~dn-nQEbmx)-PU)hB}7#y2e*)Hopla_WB)M{E5xs*H`;~1afwlPPdV%JoNtEYO~ z8Icb)oD=h}F^`ho1z?-Obs`8R*!WGw!bgK@d5AKICaIfn8Y37y_}5{}o+ zcOyXLr=DGG&V|A2VXGoh>*>nMW09;oBA+6-8wU`*b4mOi*Grxg;>MVk(6#JZ!Xjf^ zmCI9a3ENmfEbOLTZ(K~sOp00`8E?*Ro@{n=Q1Tt4boY`cAWo|A^Ci(D;bBD@XkTC_ zCfbWmZk%~=@c@LSO|qYUxWdj1+hrR7?2Z<0f+B~0Q>XF0Ie3? z(Z%ft{jRJ`1_*D_JZZ68_^kH)!hFxp>UkX(+y2(j4J?W|S%i9LA<0nIW((Zwm9FJ# z<+bi^v222elICZA_L2xOthy1O<1e>aOq2}5sVkEj4Xm6qp|1|xj}#Zvycaxzsuh1$ zNO)4p)<54m3Lc1pQ)snG(EetLL*VH#QnjCqGiaSEGKh;f-4MEQ&-mv;sud9(70kkR z??w@DoXw6REaO6g&A9widKG%~quoDQ>kC7T06uq62!m-0ajun_4h~%-PdrGu?7T&cz*?u_Bc1tVuCcPDm8Ueg;U3sd?ED0a z4{h__qILFJ4Hlhva{t;0b-OOC!5iQc8!HpstN#X*zZ0>(vAfk2fes(OUdU!W_cLi+TO5UPc!JWd@=;1dH z&GKfC!Z&C1c6U~c5eMr!nJom@xk+TQS~m?Mmb2uO{VxognUr}AzLCERxlP{0n~cws zSiZ|{i)L7cYH!5v0PMJZ+4Unx0+-}>l3jxJcDf_=ZzR?Q=w$JZH(*sIBR$vO%P%;g zyy6P1?|{LI`t$pUO!*=Wz#3jtVXLaPUjUtmz8?_Ug2oapje&)pL9E#6T?S#gbtzQp z@=H>FD=B2_s^XC)X(bV8^ea-7d;O8uj@6sIavBAuER9C2h!pDXf8y95!bMT^;LX3i zZ_NKbt-rg;ILZsCGr7BVshR(}gj$uaZO*L<{-%3?F`~6I-!U>HdD#{tKnWXGmH}@^ zi>S-ETKZ1pqUnNg6FBNGWH7?k9KPY-VvxDBSmGSi$7~rYoGQGNi{r>ryulMJ` zx}E{rx8>D3MjdDbd)I zk?GS5wgP+sIZJ~f^&Gw6knr7w3H&0!=g^^h2yjw{6NU7ljG(NSIEm>{D6`3VL6~gN zQ!;nD^zmacQczgXbqQtz)HtRd%Sweq=C?C1Cs`ZZrp z0v&`vt#XQbqY#OVvYI~}8>KwYr}D>FwzqeEy> zH*_-2M5_94FwLgnPVieRtujgEavEbmi|h-(-LI^pXQhVNsoI}>Mk$+qDeV3oLzVo0 zE(;=fBWi4apY|I`2#bFOtwEbnp2Mjk-Nqvg_^0<}9wrxw{drWbb`41L<|seMs)U<; z6k44^V5Ry2QR87P8ej7a3#1jGpS^@~4<9zw@pA&Mh}%$K7V7`J^G`A@yNT#RTZ-_k z6d2<*uFB1tybk8w*^%{K{!o&9 z73}S|9^gkhgx4{aa|>8f+TGK)Kxv5PZ-~44f-w#)A!}W$^Pg~Di@b~WYs;}6(>TD+ z44>7e_psaTvfcWD3P4atK7Z3H+tNcC-91O;aA6x53+|QuFRy3#iY+j_H1N0l3Rje| z2|QQl(MTREpIn^uL8dk8*VuakkDllDlVOFe3U-5B?NS(6;YHJx;X&(vV6Nnd?W*-2 zo)W%I4K-kaj%tNQP4^*k^fqTm6j)L`ie_gZh{w*TmqmXgxOVD=aw1aspVz7xn>Buq zh9bgf6b?s6yJrwl@ItdRA(C-1k~6_xtc6QdW|LQT+n3h>d8}j7&iqCu2F}BOQg*?O z7F#*Yqan4A@qGZYnydEm)Up^1D3)R7!jG7C@KkTv+{r(UuquhG$7%G=!~>%Ap1Byy z?N_iv9f%$slP2@m=2j**5yP%Ep^eNAKurfUT+3yRHndu%PB&Yzz5Kc%8-~%D>DJQ? zZyeucTdRc?75?f9WO3wS4&ZDkSjP3bciCvI<1+b1%XBDYD>oo~*9{HT(46~A_g2aw&P&@?DG>}O4IgwlI|!Ff#P0z4+1NaN-k|9LbVqsbG!=?rf({q-rY#O ze7Wz;4??eF)!8uoW&e!n#xufi)^MF`IDl*0>KeGA^sKCAm-=)BzgZ>i3eZrkKG()Z zLo@O|tHK_6EZ`)}|Lwye$qOTdBIS2GmdE#5x0Z~xDmj5ayA(myuZj?jIi(Jh2In${ zA>G{O_vI4mx9zs0lP>Oyg#iCi=z8#o8RDAW@uvF9H3FIN+^_jUWSbDS&e>;rpP^Fv ziT_re_u8KRKS*NsLioQ6Y)-@LhFrO>lh13)GiHr0eb>Jpzg1P`I6fZ(GCgf%bAeRz zrJ)p39zSGCvKxjH#a1J1HSZdp>on`54`0JZ1Rx0bc`ARv{f(mMjPiX3ks`R^~E^hf8wZ~z>s1u-}25%QcxUo}G9UTr5X^t+6f zxw^fjze_UCd_%C(Q^s+MY- zR&?7omh`k=v}ES1dp=6LiB4LOifnt5qNUzZP`H*Yr0meft&CFr10va||krX}V=@^0(7Fo;!0q7y4h-a&6BPhY?&G;|~B_ z>h=7EiI3ko?3u4LR!S8q@Dy>zCB;8kmf7K(s=kiMmFE{ zoaak1<;&?8;tH)H2Gzxm1Jn}+S1UvfUh-!mYm_I_MnK?!LcRJdVC-S!vr4NkyswTA zntKiK|7s-I2+1k;Xk^)H-_sUR%7Q=QQR}!KLb7>*rU%c(`M&;qf5AC7RTpA%PW~b! zefY??zw^x)xw37s1i9q%*}=Rftb{FwYP}}c3CR_b6MdpE@#(d$yrD{lTTiuf$+t_A z^zGo=;M}e;L{QuvlT}^$JIBS`d){g*EV|a9DHX!;ZdYScPHM=D8K`9Byid7oEB9K| zP|ZkX@pJK0lU30>Jw}}=X=%%WH-b%550~uRPFPedIe1P`WzFz^g0MSI|My&c!Q8x| zrHx|D`3EK8PjEepg0gbq?_>`jkGUagt4%mL#?!zy=%zo7d+BHi)WD~S#VASIezL9n z-!Cvc7pwuix)`KnV``%i`Yq*ebs@;I+A%J|PT{q4iP*V+51J|&o%`Qtju?r)Rg$c6 zE(yGUJndpyMBHm<&GggA`M^OXdxk0%MRCBv{($*9Cz?zzzrLR4EIOZ~XjpyTdQD{w z8DiP<;EmXoLdP1FZ#f4v(mU^-TeB%BHk?MRU(eFbu`g2y`j~lYE=cc_9$H>F6F+zN zNMP>OSyKr!rQ)v_znda|HkBTBpEIhS3#ocwDtcb<<58QdXW|yMgLSgXMmx(N)ST<) z=7<@@T{l!ZY^K3IERK){9$+n_}$>&{Daz-=?FWMh8)xGB;d{7R^ zs<6hyv6d z;WpAfkQQP_yn2b^nL73(T?8JqFPF@Gv>c;xZbEe(ATOQ;#Ls`BvpW+e2D))sFi%3p z`V_fYS!BfPk_3#i)U}C?%>yfkW8gC{nm0V)@Sj?4;O+U4o- zwFX7tJZdHVF!nBf`RX_!R8Y7JPMdMeFA)t7xXr9hm{qG6$+TFp^hlKPZXX*apDEHYZN0<8ut0!*4br^B9v%r4YR= zZA520ad`f2DrxyH+WhorCZ8`4#?VM=y)1uL~j#WyHllgjK zxVE5Wm=`^@a$sZz;>P<&{o1OQ7Q2W`ZR-Zs`9tK#taGOCPtCkn<;{EG3 zsVl0HPEzBfwlZm;@esG}LS#K+AA@h6GZuWtoM1yN|{aG3Y6uh6!dk*00@wT86@ z*duu&@+U22hA|mc#t!Dp0`qR)NO@0q*dQQtdw7({lR7wofo^FtwpKR8a zgmE5+76R~ogw-1@8$9Mtk5TfP&$=6{(gz3M&L*H7uAp7;71mW%OnZFzI+~MdKl1Hq zs&_be(mxN|I1Pi{iuzlML;8IhC=zv0V<;WxgtGB+kpUfi1I2fB5 zJJ_6rF`C5(m~0&>h3iqRFcTMZI%cJj4gmYRRB|#%%gPc{{{ow8omzr^NjqD7%Xw+& zI_M%asEnvC2=~j=jk3-UnQEP#_XPL%GFko>zTip^AEaX7`dmL)#$SZwlL6KAt*Tsc zaB!C$^22R+lGoPN4MrRDoExqlEp;H;$J*r6?6D42_LMRlLaCdOzEv6qX@?0UFfm-3 zi-oxQd*##)wTAJgjbLrh2CPac$CAd0u> zfiyM5QI0y-5{aLsmD|X<>a9uL+t;wnm~~>LLy|bj7xYW}`=NQfOx`%XUhh~Dq{{v7 zkb<=3p$i+vX2e^ni^XXt^{t1$ciKnn@)Aq)ABxEPcfQ3a=Uj8$CAQZNR>6PGU0!EA z+hTeeua9AI6PZ?vNrM;}Sk~6qe=KUs)_DzoWjIMMENI<1X9FCzuOi^4^H(3D6*|#h zq!*0#d zT-e|wg3VZOB5d*3+Z(wf4=G^4&Q}_4VmdHV%{|vU8`!d9lnQ{04G1QX=e7nqxA>mj zwPQrw!xey)_9p*bs>?3Ja-C7MTnmU6eRfY;f~*}nNq&Kem|N!m;&yx^8Spl|xl|;y z9!qD3OpkQRc66J{k+nCUs=lbjZiL;4ifDp8D(sG4oz@AR0dv3Xd;aKs*_gVr4sNO~ z_dPZ$Y}KKttK%UJG2+h6>>gpaV<{TF4-k_RnesRD`AlQaj#nIkD`@$5)<3oPSG3R9 zmeqElifBaMf~UBFwEC`_o>WA{pADsuDdm>1AvSMX>dYJs|6*h&>}N;t&McRdaN$gO zX&;vrz9=c8-aY0d9>H0T$9fSvxm2{}Y2fs9cq29P(^Cx2@RQ!g(83>lI}_%5?NOCC z(u1s7!XDs^TJRXFTj++?-B{a{x{1xX4oaxR{K11Ia4Ld41Bk+U^Xq1wXtASK#<$8s zt^&U-{GBB`ODBSsEh9pkXTsHM9eAr0Ixbu;VhX^a^(4T#;J+h2X@**sIaU_vs*?@d zhT!W?E4=({M;YV&i`Xu-xXJuOX}>KK&VBwcq?TC(WiH4DW7W%iM*0^T(Da55jul!) zb!sIkJhWz^Ox&R2_4Z6>!d5Gu8H&z#4Q*;({O!wj8(&@K0=+OaX5#{uv@qzeuHDwM zfNq-%VXqE#qw8MYQT@Q75TaB=*jt@RC`G*Lh+2O`5q+6f{)ZprfiwSpCoz_F6aar7 z$_gQ{iXNhn)kqJ&46fd09kkU6H>so)W)P}I#ftHX9p3(PV~?h@_AZ@kUoy4Abp z51IVcGPDCOlKgetq$LuvnW}CeI7g>;tunWiv$MUl=I1HjH)bD88Ask3oI?h0ZIUw4 zGL4^frMgtXJjJ!PNgon$C(CMU7h-8C+h%9t&sp8CF?8K`al8FnsH(^BotO$(bQb7V z7f|-lRzsJ+X{Cv+c=YwMR~__@XD3cgcn4_D`Bs?{aL87fkxUiFV@pgAfZPyNgq&}D zvJ(tG8w;XVG$&qRp?5vwK-!7h1;mA8ou`=@^SVWy|ho( zg1DsNC{h_7?ohh&!@Xw($vNc{=N;gqaEi3p_ z(8%ltMogsci{@oe;{FGISEQ=Aki_j#4UnOOJgXxV4n$^#n`J&Ttwkove~yKn+Tb3<4+mtw-+R<8R`=E&RIm48yyh`OX5ll4vpcs*_zD z{2~g5rF$W=L|%O!g*MG)TfloDUT3;L^)fb|$2_R^?&d5oh)*Plp_$6Fs8ZzIc4aHt z(=WTcm^6~(s-8v;3+or+=gd6ea=>%5d1WS5!+Y8yz`^T%raIc`K3Me@q0fItb;S4#IQ>~gGo zT#(q-nY)-vLI|oul4NGyxa2=S%9Mz#vOx^(?_z2it?{13B&7)IF)}!YXpOQ?wLPB} zfzx*6bmSZwE=6y2```EI2RHK|;0x zq&g`(VKv)~qH?p`5ukBZQO-+86Jes_&D>|fxT+zY$%g21L&6gKk3UM()Im`Vc(qE? z7QRMm)IFmYem_&pOzu6%lLSpKE$>Z>0xH=&n%>0ozx9&LOtt)Xet{O>K8sfmUGuhH zwq-8OTFZD2FX&(3f3-CSs$8R-8^~6av@;2@ z6VNty(Te#OjMQgW(+Pa<_X@>-2W*@h|Qw_+a2&m&~RZh2juPgSg@2hDU5ff%IrNVk2=XA?wgG~i7E?^{la>@il_c$Dyd9f#rfVT*k?$h7|lk}J*|wZoe615 z4p5C#C8DWU1-;fHaS+zj97QTcBnR+h3V>a!4&v#i3)e{X85)_=T@P zUdyWYz+oWONJCha2D&+T7@f@Gs}Mkj7WZ?#~!fmJY{E-+`o?~FbXVB6xSCR2Au z25Ul!P1tGpn-(9m7PLt_Df@%q7mk~{_0Tu5Xfpz4H9$h0D@d!k!~NE%d0d_{CRcu* zRQA;SX2qMxbml$xjuTK~fJGo73*t~eq2g2tARI^_#fbQssoS3ylv6B-D_)}qs`v#< zTjo!r1s0;A2$T-kBO4-VZwy5n~<+_!Mn2&`4V+o&kp=8+||e$S)WIY&G_4)ghz!ViCXU? z^7oH|4=sXES?Nhn%LvzfNHKgB*4JR7ju>Y=0oDKPQpm_z3QKK~CVmn&?D#Q$)y2pF=hX(@S#QX`hSr-`J>JJR_D;H537_kJ@;Vg`FlRhi6T+ zjC;xoZPP5=ZkjsKzjcr+88y7e$KNQFK1i{v0VqZN=_!+{4S-ENa``Y@>tLqC2 z#R@;lQiL_)5jwSvjQ@TI zljnQl(F2+s;=1w3KhGvl z_!9iv80;-!PNd(dG0=7Lh8mq66;7Qu@Q|&aIASZCX`kDG4#B}+)ZA{FMDV_XbX>%Q zCX)co4wThi(v{|frjFRp@oU&ww@1fUravMHk4%w5e{=pYk^fdsB*jD`GnJWC%3tVx(Qp_-%P|*; zi5mAPc^J`@KXL4AGy4~HtAOXPP6TjzP@MtTI-CXw1=y_!26!ROY$lz8E`5 ziT9&pH!8%{@H_M`0kI+mV|A0dDVSkDk{&iH^wS#1qAv;-%&kE(a&EWiCL8N`oebA* z45_UE^s}X>yQi5e{G#dL$}TzE`E2finIQ{h47zGT%lBG~RN9%Ztl!PN)pZ9Dr?y}~ zZN#tRgo(Zc_HBR2Z~j)lddL0TGa$6&=zNA$*Ag1SDAg0q9-RS^NYvMVl6Tn}KaUkQ zH+U)9IIX?0Sosw+5d|5E;>=BA4U~QvU-xE(PC;ti9?m~V)oWZCXG<;^8&5^KXjC8n zAl~#F>F3hgyRR$lR;&1xwf)(U?9np3#hd;57)lR!u{nCD)S&gz7?|Eq?9p!vrFCF+ zR+8fKW+GTKcO$2k{bET0^#O3a7{RTlwBcrnxvCSUjf_L2<@!SKrASow#&I;(*&AC} z7|a>!OpUIqHna?Dso1t9sNfDOYS8KaxMijDVYN zh%C@KrQw9wlLWt&re=fgm=ayyqpRVZ9#4Htjvd3dR%$M9M41YEUqvb2dWUjKS zf{S#agGw?)$zVO9KvtoLx`dBhXZ9snm)#F~rfwrKIQ(EC?7C~@pFa`4H==8>V(1H&SLMy0$rySuyyQ8Zf+~D!c!TqN#n#_t% z#02*St50iwh4VIU%ONdnTH)h#$*M4f7s-3$&hs_k<++VJXM!B|Jw+hOEbUeCn(xy# zBJ`*e>HiaX82tSmXFtu>@n(&<`Ug!vEy3(LgI2WZc`D$lHw;>&mf?n1NCZODiXwOZ zEVp|@pNC}!56-ZVQS#V|7n^@5BP>h*^hfd|ncaRsTH&uM^&T*SP3@;}aqNJ!rZsmi z0o7H4d>C8pJ4(M?e(X9!r$@8Vp29@_ zdO^=IHBbquv2A?9@|)UXHaFkZ_t7*%WOQ9vU|nU*2B`MESSS8Eu8*E#Ue^d*>SF!3SI1&kwD01ID2ck|14%T;6m>z7${m<6<#PI53$xy zs;t3Je-Nu_N~B}FLJ=-i2Z*Q>%7Xi8HR;$0h0Y;6)3OEjYE7;5zO=md=AzNty5tQ% z?%})pEjNU>5ppjIM-@ts;YyVBGKK9Ve}`1Oyi>2F@^o7>-~QRZx%;FosQs#z=4v?N z{`l&D@zqa`EMm=P|yKcLFtzRt+8&) zFPvk1>y>W$D*2iALW6WRgcko>EpNFvJq)>~7$l^wgO&N_D0)pqIiKTcIiRH^^Eb(` zv3y2IYxNMWOLH0b5OWg(H4m5>7&O!Zj@KWE)N3lYBF+AEQS<>o^>CPk06T;I=H9_Vz~^YZa+uoq3_e z!NuInim-3XTjxjNq3lz?2zVT;^x&^HpPGIt{e(kHVEcVHK!GQU&7Hd;B0SdF+xB74 zBA#5GpOLkR<9h@)UKU7AtU6REn%gm7fc%W57bHKrrIXXjLYS<*z|nhA?(5P7O_%cTxA(TUC?=Y|qn4H_Q)lg4j0>N$LSJT0-6iqhN%>VP^B2GuaJ z-`G1GtSVM>!W<(tfSrtj*KfxNXSCOQC!@p_P6G{fhmO17u|!WUVAZy=E={^W{pYIB zNIKRV1RA>~H=F5qu(^Y)zW>s<%szaR=pq>!b;Xiw?eBMTP86Yn$LYYTFeY(B9{WYh^eb=_- zpo^n+e7vh5yeg|p(7-wZ>KJp*sB=PNmKO562yAjmh3wc@6ov30lSm>Rf+;0 z>LUGWL~-Iw@S?OH+=j#9CAGG2w~EyT&`kiBukg$XtP>W-dojh#6ZzFhj_O5GR`vL) z0CRP$zsRZHHa?y!-)5CK7q;A*zr*?UeBEGiywxfR+_wH_;a$(~U6rqeO)K94+~EA} zLXqbyO`-g$5#pk@ZVh8hV7ubgLNs+(geA;H6os@GhlV+T{xDv=kF8LrtowUZThC%~ zA(ro)|Jk4HrXja|*zj3Oe`sXXdPDZqm4jH@2%leU!!}$M_HG1h{E0bRq?ep;PRRO= zRGV%=(M;n*nrN-+R{`UrM>SW^%RRgEyUm#KYR-a4OHnI{Z~gtQG`#L|2(O{}nFH_X zFP-6_+iipJ7Nl#{Pw{(xCC9Tw`@8ibR)a7L^x6z`o94&u*OEAq+ZKM9$`>^im#HFStUzcJ+RObME)WD7U2J#xa(iXm z&J_@>j4!zyPO8eA;_keNzA_m>tlIAFIrR5OyG)ksN??rLuF}>8?C~XjE+BEK`vFFX zSM=a5>#L{kdC#*bb;~4+*2rPj!o<`qv(jU6naBpP01~Q{$?FCPf_O!A`7LL;?d_IW zBx?(6K~F`l!am^d?E7UUR0&s0-pRjOu4O|!Pw$Nn!?`?ai+@+Z_(&Gjms8ER+@09! zm;eGqNMSYEhnsAS-+%u6i4_cgz+H&l{R1KJ`8gQY_jzaQiKn%VW--5wqfc+Hl}jrY zhdRU~CTWR&_2_u8@MOCYPnkL#2-G-n!0Evj1Bxr`{clEM9b5O=EY`MfW#lF_E6vs@ z#R*)?q-Qr@%@a(n$aLyP(FlWX#v>ik{qbSdMVAXF83Q{I!p>YNi{1u=W7g;7G1Yd- z%Uw>GLt5!x>#ewtS^Wjtz9;X?1BEf$T7dgdoie{N{-u-!<phPQ}pc{H~@1q&}F;?6&z}i@3{~m zn^BMrbVkmd+>fF`ES#iGt4rdw(@p#zh zto-OQywZIbg;~_fApYGeB;b_lXrt<~hTvtlgWGtpU<4%uAG%Y!LEGp-F_V!88*_u z&kRjH8e9_6a(Oe9g!8n8dra)m3kj!YC5w)IQE|b2;lBXwt`|CphCk4Df<=F3o=H>p zL+OF=yDH%ALaxAkNpspCa$A-BQ?Bh)^%s{{?6B?rxld|(5d?048`%ulD?G@|c5v#{ z{kzhbaxJ+N59EmDZtf8tu|HI_~b0O=Cr(B7+F|0#TMwT;`v78kP_>O$!fs z5$kHqJqv8T>fjX0SvFJ|mFN~!>Q~?SBLe3QN-u{#xB}^)EoftG!};Xe3&t!AzehcP zM;@@h&M$m*69D2d^mOiL95ZZq$SQs^e|(@ytPBaFy)sZ&fb)>eX&Fa5IRbtf->nWw z|0%IKs(pJ&G3m#c?k@XiN2`Kh)GcNrp`s1-?Q4!UVCv>>3)%^-(JDh&oE-1ElUYG@ z2L|B*ZL95W6ueiOt(Ym0xZ7Sw%e#unZQDjq$R5AxT>&qb;i%IwnQg8&Q--S#Y<;=T z6bTnm=C!KY|MPofjPEwy;z{RPunx06tw@+hpGyxseuY_=r|qi+*l!cpz?)B8mtnj1 z5u(z1HO8XS1~sDVwYo(1vAy=y^?Af%RSqGAU-C-2{(IaUp1faZ^h!`YjyY4A;}aBu z-D(DpZiYU`ZkgzjELS_mPruBEu%@rnGVdyy>sFpG67`lXzpR)bu^k;K6M5Pnb7hY;HgkA;2C|1~R{Oexpm3#5jMJq8s=V){iYO9)5qNxTNxVQg%&6?yXZ4Uc0H@g_p^bl|SD7cl)k>^k~JLR{}D=dg9aa z(;m%JkBuX3jDMZUEs<=r@me!dI^F)MOvCWTjLL(gA1T7;?}^+v?)lyA<2h~r`%*BC+!RiQ>2K70=EMRfT0nG2l3}>JtH%vo0k6bMHt>$UTE|3NLDa zrKc2JWI>RY?@I=iz)C~Kqb6c8pVyX`ucje!_ad<(<;=UB{ zgKYV2lTF)$vKP(1Q^nt8$-Xaf9F7~#MEo9BBV-~!6P-HwVxgB$q+j3fByAR5a&A}- zGFO~$5*uHT_|neu327lv=7x1u^OxCZBe0iP+@iDGENSdiQtg7WKU>HATaIyJGtzOa z^k^Xdj4Awn#cWE|KwLdkQOM4WbdNF!CL=CR8 z3}=U}gu%-9i<{IJCCIFYQi#ID3tv9ZmG(b(3B9dL{rB>{v`kvUlKKM=gY$@iPO7_!!uBKUtiPY%J}!)gsaq9&ADC4*6a)bqZ@O))V}A zzFRiQ@O)WFa^GdS2a)gvM&r!yDs=+&?=Ezpla_xsj+BpBz)>tLdhnP3;dMGtXi3u> zl?^_DmL+iy{`pmRyRq0gXyAOkjLTOGCr`TUy|j@ZRu**IoJgQyA9dur?z7G@&EPV~ z`R^M!wtr_}cK=BvyivGfF9#4xbV#@(m2)kL&1&s&TVJ~sF_r(z85~-p*(_Dkphd-a z+R{Vsx^}+hpgj9GGxPCR=}&=Ht_^lDMA#XHwNvY_Qp*ACxWUOd^T(($2T6Ai=hNbXLdM@tm``IcPD zy0q9*N>hB?mVeY&AEyY-y9Na$X{EKDsAwrSj#={r=6=$Aak%;0xgf%zV2R8@@{_Lv8eI5V}9 z=n#deWo?yNh**WW@YZxz1hPEv+6Y)i*wZorU`b7VVs8T#*$`IBeA%P%#TS)lJ?Um> z_w5ka_qU#@q93)*Ec@r7v zv_Mu*;yoXVYEpmJs>AlEmXf2ogw$GXcmhmX1wZ!`(~=?g{~{-GdcUUij%k+ZuUsa_jTFMPfS zaGpgk3r{Me^M#LhTd+W|4!k% zomfHn{rzXs%gO=&?{n?F2=)Z_1oi~>1oi~>1oi~>1oi~>1oi~>1d2eyVVm{4!eRGe z(Q>f<|7tN#7H++ad5*@`GRw?Ms|{JIEj0kZfndY);A0+O#KUK)Qc6}rnZ!$=#RzBo zvC}$@MkusoMko0~zSd4-{y_!)3|( zaQ9p%KV=+a@a%v))Mm1%c4E7f=_;JUcrnzX?ziLB?-lN=l4u&gs14P~a!?b-H-lTA z%X|?5$EqeJfgibavXA~!Z<)Rx9HhlRb>&~kXhB^1TNe9u!N~ZP8t^ENKzlxZ~^ew!~W_YG(rJW&2EI zv7He`c`kj0cNt+sk8%jC)l=77*2IE<4{th~H^FbL66LLS`;~!(zo0^6;fMT|{W+@B z%Y9xB1}FbM8jbSAvk4I?E{@Y^c~YbDwo?~&+H7h?)^v_Xuje*{L0 zEInzJab}szkQ#+US71$A-#_2jPPkm!$LW|Kdo%W?g*p{3!CiYE26H7bKS)nWNwJU) zP~R7@i!>iZ#L@{8d6k>`&QDxucGBUlXiu;sV383mR&P>%$u}7Msf`ap z!ew0JE%g4qb=FoH>?yjSMa6cq<21l&EXzjR6>QN1TAbm%LgXig;&%VDT5$`#-r9F; zrJtzY(Q;ci_uun^VnA#Um`4jpW0|kyYv+7%l6eb~@eA`q_SUGm*7p+p1Fhm%7D6=# zOu_ZE43uFe1Ov_mGpM6U}DKW>Y^2lpM(!8>+2u)2)Ajv;U zw+BindgnC;?5-XvghvHMefk$@n38OYvk|%=bZZ2>o9n=B(~?eK`};i0-k+evgnA1h z8=+tNIIjep3Cw8=hpiuBy?S`7MXynI&{DgJ4%!GuDfq)mGwdADwWyi8E%uw}q%S~e zKik%9z3`~kVD9WTSim&n=6OazPrr@;cag_lZb8iideVbr`apxkLA@Xtlm+qW;MG>3 zn*G1V-sQxSB2o2MOWAmnl^Z4cL%cL-(F*v_cKFbLHjg+v2ShV6vqt$X7 zR#Eb~p6XX%tm64{+Hb#>W8Y160(Bk7<-|r7LO{*jONw5j-nP#!tg3scl5bNR(xYI+ zUNw$nDJ#*t8HSOUe7=Ck@+XI8H$URB`!uv1AohRQ`}Rks-!E|9 z6LBd5FzQIs~lN|>UqiDV%oorWT zi1;PjpbBS+xu2_;!}XC7LjQY4{!at{)4=~U@IMXwPXqtc!2dMx|BVK?Oi=zRo-1l~ z3dbqJr{ZvvT~xSZ-JhOD^ASI4KXHD#PEAkmp;X(X(v$aHgThFMNy_djXR-gu@1i07 z8@4C@EpPEM(|gqHFrq5n{u*6}>R`v*OR;mv{ZbFFp3pLrWwl>MT2&Mrn;Wv_dn>}< z`Pl!|?r7;sEG2ee{Nu_lqLH(Ugbxjuw2X|#chA&%-M2PpP4Nb|JAJ8KTw+!0;^ zOFGXfp6S{SrcAF5(GuQ;dRXPXFjlklqbl`nzV?kz_G|3R-xwJlD@ZU9R7(~nN0Td! zo06T{vMh7VR0>97q6r?f_BTq&N>WoN$=Y8MxMK^JilaHiwy^$kCxRt+bA#Jc0m+-V zy1f>cyvUXcz4Ko1UJ>aYGv4Vop3HZFfY4KJ~iEgGTfjJVO+a#&6Zh>Os~0Y2idF z1``r0BA%gzIg_Z(v;D~L=AB%V-|re!cDZ=5Uaj+}&YJ=Ja4x}U*bk`KT{#^M%G2JC zd$qRx6oX9^CkN9R>Z5e;*|K_D;N7=49HpoUCFKQj?3IEtS8=vcTP_Ua zYugY!WR-Q>m`-SKo-v4M3hMNYo>Q4=OuAKXj+bq5@@NaAJL{BA9$eu&aP#Jw&IsR1 z*C&}uel{84;QH@kUT2O9tp<~DsoP=o%nna#)K}58h|!L<-j&VujbHS-2k~bMYcKk} z(YU-t`FtwIXx~ve>9Zd;M_RNB`%6Rz2QAB%9J~XAeX{3|cPS2#9=v%7_{6JYB#rop2IOPj>@LSwkXYGf98|cc^N`J&leXBrUL<(I#Q#PS0@4oIf6CJj^=A1G-*2MYUt?T+h zp&|y#uLJ7Xk6SQ@ThO(v)iq49ZbThH8515-r5RX1hGkZll?pS=i#NYH0cB6n9@-u{?uuMxi9{3IiCJ}b(&Hzcaz$2$zDlS(oC%( z)y#v?GZ_?MtLJ!f)(bjG9@}D2^jbg0fD!zZE{`S4kmOq5CerreKF-$T8h75zm-glD zKyBOh@B#;!<(bEx{yq`HTWTs>mF!RMc0#|jN#l(Lx$Va%eT60K(AQ%69w#Bqg(v64 zKi~89ZzfASU%}~*-!bKj6_N@G(ON#;2bVNvd~jyxL7#R(CxD_24zS^VtbEVZTQrC- zGMVP;Y^%pLZew4tH#sI#i+~SfsFrGMi(wu&+MM=q!0yht{176@#&6e-N#*UVtm%2e zc2hO;Y;PYZifGy`Zlg3&M0vUHyAW!wvqkSju zRXo@%Mw7}J?RldPxeLc4Lirduh2d13Bzz1^&xdZ(T8`T3Zv0-Gjfb?1Mz@k4O4Sh8 zR(1QUxaEF3N`mUHKtQ0PVZuCGI`4~(sbjS@H4eBE4nI}5*izP9f!d>%Kh z+sj{Tb#d{Pn2WYzS)lEbSc-x}oX1ARxgPFsVM^!z#h@$S!>kXlNT1ot`Y7YMdOm&6 z+aKS5r&>#^SFZK$Gdx-tU#vJQ^WthD=Ce|f%TeXqHK3Y&O(O#kR;-|0TA=F2r8}Yd z-d#3kMn@LU7CP2`{FDA!U$J;C?O;1;V1%fDFWogtR3HA^rs%!23iwp?ix%B8Y<2Om zqjC?e73xv%VMW(obp=_(Dmoa}47zn0Gc~^A$E;-|tur(8ff}mwWyjyNTw$2krrnCK z9waH05qwGqWbS~1FW!-xcS&W3pDA}x1(qNi%j zgBO?5a=nH#5H~6A=j@qw<_)XHfzYC}YW2Zjv&&Q_+?zV9Rq$kKfuS_0@Xq(lM@37W zk>sGMC=NbhB$GFqP>GFtG=5XTQgQ3b^r2eu)RV2!k z4i>r>W5pB0S&akp_4o92{$${4!?|v@O_6X0IVTj#ApdE8jXV5V@8avH2J&-d<3}zM z3dzX#;{)<7tch*zPDvGUa=QZr<0cH|XCF;--yx)`Nwl)Yn8;BKYjzR~jT-i-;>}FQ z!aquGwf!N18c~gkph=^XKe}y01j_sn0Y&A=^Ck#n8F`s}E6)@>RZcBPaWGV10_T?# zVH>4i%0wB%P>+P922o)pn>za0q%A-How@CSnaP5Vm7jA-PV^A6xthiJ-p;rDdp_DwI5Un%kFg-=RXXb#jo?9Xt8%Vv}MS+J!fj>X27=p7JP9 z2xP+{Jl!ez9F%*1e=*f$=c!s3d#c|VzOlFj7cncd?CeMOY+=5HfqQ9Rk{#Zyd6t3k z4oC1FYMoRrRd06@#hXj^&|byP=^lBd@cQ-DOKj&hL@T48L6-%QyGd2I#^LLpGh_V> zW@M-Co@1TwY?>3FtN6OS=d(yzyEEtc#fE*wZTe6MO$GE5;<`cLLD z)w7imH&z7_28cdW4ov5q3P(Cww!B7I)&{dX3=z^BB&zAxQu456vedyjIn9_*#4Rjb zneYAhkyBM=8%A|^BOhxL>m<8QInY8z=7+911xwBm=;1!fkE+kykes?0YW`Cpsi-($ zs!9@B8-NL)2%eyrJ1{-$68`ARhk!f68EavWRB)=4motIX5U~oDiW=u(hg%DY6)Q|8 zXJa*-YS&)Pxs4zX3eqO~iz(b^J9EqNR*~sbb{z{-m%^dyC69`;8ajA7G)Vm>fm)mnB7-fiY3Bfk_wy1VU3m5FzD>p=Ufo+Z8&6z z((Z1oL6I7F#n@ow-xj{#w5nu3E$+s5s|!@-+M~oZ-YoQ<(eH~t9mB#|D<#BLLKS%} z#LpAQVV~0W1FNAZT8{Jf4xx@msJuDh5fq$EA0i7O)fAZBUm61LMcGB0dyxAmq?EOpVA)K;K*`7nXums+L(>k&z`a|O^N36L(VD1jlDGm$OF0b?`rdLn~5cq|)CB)Mvb_+9=mIr^&SEC(1g5covq7Xvn=JF)#8>d_e zL%=aJMQ>YIXIYW8;0&j&`i{)7B%W;C23L$pT|sPfwrptuu0uq8VPHTo`QN^#&RG-P z2U)`@zbn~GKTtcv!rkyGj-J^FMXvqdW>WRU>;<}wJ;?H;}t)2w(J#9gQ`$9*X6EyS%Ok*CD5XyBO!V; zxWSdm!l@{=>1aA6n%(Vh#D4Zp*5PdknlsxlYn)E}<>I^*+*;z$f@w*UWkpk6XrzN4 zR<|lMoHawH<{?qKXCif2OH>N~1}tiflnB1XI zB5AWJK^DXq_n`0ik~HA#)FGBukq?gr!IWW*E!3ziN&szs(I}eHLr$Id+L$2qht}cX z9Ht`7xh(lqS_X;JF}IK$QZPXkVsg>Es;b>t58*3~WQ-Q#My`_MJLFxrC*Z{{KMOTf zBgS&g7sd=tI5JEC#cuvu82fFPI)#n$g9mN5U{~f@HG1sMd2)0N@?`nK)Xcd$Z!JGr zePtw5XcXEc7-XUaZPH=y66`$D>Q(w>Yh_u@Yhp^H$@T~-<|exAH2n!P>^gh;cdxMim9;l|&037fwTI3PBiaY; zOqf)QnBSN7H2m(j9r^PETVh0$HH1C$n(HrFibc%bafJH>?|h%5jg&{*yrKgcf$K9a z>?*jX+xwD5zv=Z3ok+y@&+p3maZ4cVHo6KsMH%I+)KNJ>a5^D=P>Lf*7aMhMOJA#rcthx{8uf+3C*9c>9jaPF9n1rQ zX18~zcgcF;;k(@>c}Wr&Vh`)5^KM<hn`D7ScK7*@Si<0jesktuIWVTU^yI-V}v8w1{@El*B zQRSAT!tU? ztFbRP5r(nMygVPvc*Kgt%^?xj0tv<8i)>cJhl4pg_czEu55IR)xqjUExb~d91S?UP z1rC$c1xo4@Fvq#G{L-L1hEz+~q`E`YdZsY?2o2HV(Y!jAXx&>)8mm7NF$apUOOa_7Pk>v7mE`=eE z8F%1Bs|~hl^#x;ijInP}OF!3#HBL9wQolO;j5~Ntx4Qm{3C$R|eE{jK{*AzwABQbyDP@2}$ur3J@=*^W8H; zbeONnGv3*I_bwx=LKd^Jhk`EV^WD1?U;0axgt-uu)v)ZW{c&x3n(+r+Rf`S7_=i#WoQ#Jn4qGpt;+^Xy%v-|fw^X#Lnb_>ehde@w1E2Ne<2!kX7@To3 z`;aA;Fm<>f_@DguUuU6dG3}bGWLxJFSK}DfUV@+^wmGQn565UJ)>$f7C9SogEa36h z(vT~>r2iBb#$4<3RI+eV-&uP_uWPth6k&+$8~a(*5PT+>?@7fhG=9PQ0p8%1Id*pw zH1)fR#f3<1uaw8&hNtY5G6;8HOUD<*-+Cnx{Up97uF0+}ISu(y#FT!hxjnOEL^gDo zw`hi8KAiMo&SmbEvZ&+gzm>n9Vw=2z|Irp&^6VAie`v*XnE4zBU z`SC$mpDKdh4GL+D)zm-fE)4ho#HDg4dqEi1zcZ7=9@J2u$Z~hzErPQnlu_gL(~q@` zfFrQ`o5CMVR5VFVNy0XUlVO}o4SJJ7TQjtdn)@1tNK|+jb(zB(CPLV=Xf$H#HDCZi zSRG^Y%*t4*o}TY2pVhL^Kn6H77FrUDUP^VzeCaqT71x%4;gEI)^*93_aPD_nvh0*E z|1xBhL@wA~d+rp({ho}4uc1Y)j6ZvW%+OMn#0WLaYDaP=2a+d!Fhpu1iH(D6a*VK% z!zI);l*T7oCU$iS18>n%P%ICM;T(Q<1(NLti}L6F`OwfHn+d9lLOs6*gElOK97?_z^&T|BowYT18)MzF z5a!^)poqxXc_x-_wWod!%v{YAR*7Yyq3}&OZ+c3BMlT@vHM&E2jFl2BbeBf! zcG5*+wOEyi#dW=HIB~0o@NYgh**^ce6zFN{+!_|!6msAj z4=8FTs02H>Yd0o13g422&*Db$&|T`{{%r!tX@XsXy-i--1?B2$vvEr6!%L9$HSoUa zs@y*3?FG>mzkV8(3ky1|z1iuEc>-a)#GT-yju!_ma)(BEP*z8tlg>UNa1^awR_mLy zjt0D2G5so+(*)lW>KwHlq%%<@ur8YCtjn#-o56`fcwr5PqR2|HxV92K*m40A@?iCT zXp!cY-|m_fO%%_@5VSnrN`72$MN$skiOu}BQ>CbLefy9;X8iEt8SR=Me2NqFmvfQS zjUUd?q(+jF_I9QoA#8I|2_co#=hQZrApP@33%~9b-M-}W<-$|n;yc$KcHeR0PSZyr zyn*^Q(x0Og**qzvfSzNnqxK_D{!18WNMqmMTEgDF1`eYwBS{LdjY+03 znrjy%RP8qi-8A?ftN4#dd&VbS0Zo2s^j8>0bw|a?YQy z*&$zFUP0d#c`JIuu0n#0OmTD=Q03^fXl#BX<7O zHz@M6NV_C%pYSxUMZ&PFN#-EwdG(ykal^3;YeVS$r~6qC;=+!3qhjw2>Bse65!?*D<@!cvazl{@6VnDvo<7T^Nh_ z^&Si~Z!MgxS+%HX#hs}8M_4#txqHM{Xv}UbwRcn1vTyU(`RHHwe%;WLMyKf5DL{t5 zD-52Ge%6Qdv-yEM87Yq=Tvx`u*`gIE_5|1=5~L|fZjkr{w6-!XX6u`on@aVA7X0Ij ze6!xu_gSjCMOkDv$bo(#ZG~iUAzhiaLjD)JekmpoMz}<@tX+J1nUL*aAAi2*m*Tts z2b!<1H4o3aY-FS0JG@;X8`dIsBB-Sv0F^(Bt0~0I)geFTR-)rHoX30L)L%}|cxIg0 zXrBWEZm*lgf_y`1Rv67^HA z#)z);->0?l-{-UM*9Ts=4^U|Nikl01etQtFEaNZ*I9F(EjZ;@hznUv_itU0wrCfp7 zs1`1sl)Z%l@ZuTEZ_3PiNVAG=23{5mx1U>OcDYfcoxZx(wnoW{100Tle-{NjbTCu8 zKbe-IBkP-S zbAj3WoxFN~XJZf0Cj1$~B!5Ks~SPRnpg<0fM?$4vn|;Ee{)l0a%^< zWP841a{tg7S6@jLR+N?OiC+V@5w{dp0&RxQR93pWLjp3dwI|6(bml9niCh`N1*h>v zL>q#=dLOy8IwD3KeixZlpErb5e>FAl9C`+ptYg)GO+64&xO^x8XyjX4Fw~;y&Qx5r zhTqjH`!)Cm>em}^;gNH9_S8ObZ*^NdcllpMwFUKTpRXqLFt8810P56=QItrx?9Vw!6$$0%NYVNjM5JD^8fo@)8OeP{^y0Qm{S5dH{oUr z7o>Bn;MITeZYvfheck)hv1H{OD2;!s^S5KE;^s}oLIyzXmZs~LV1N3`IZNPs+TZU6 zTf={^0|Se!$DI0gI$x=9(Ioi8p+M<|?g!3?NN-sN*X_#^Bv9|iUE~FS#5vxTwN~U` zHL;R$i+22ZD*c14@z`0>`c44X(CGKyZCqC9^Zt;7_4C;Ls~o$+BFx`A zYBBfsYu=h4`&390)p%qr&%atxNIDc)f5DB9l&|z--xWarFO$T=L+*c2WZjy7RUilO zT;Bgd_%9%$;JALI?NKc+Qob56zUy58F5~Ob!~Z->w%T91*Z;!UJwwH1GxsgeM^zdj zZ8v4^TaTP_u@@QpeE!aXv!GRT{?9kM{P|2Cw#Od1cP1BkT;KGcv$7WZ?nUgEjyd%6 zczQ&u?LWW|P}jfd`)l&;k?!R)NqwLucJ@TJu70V^aW}pql%1G>KK!i>pM<$d-Rd>R z7X`{O0>Ji?fAQ9xfEBjBMjy!8Bn{_4lg2}<#!HOdG z_ft(;S}L*HE4S|a3{{s#9X-T4i5og@S|l>Eiirrjcjpgty3KL4NVcr;*TEHk1tZtQ z>XDk0Z}Mjku7sJKr+hMyTmSL7e89?cUob_H_xR7d`aNSxp5<3cE9(QZa);k)9bfKM zg$Fbv>jTGgd%t=VuGp9-HcyV(-vM`~4;xmBBE&q)8^v5%)}9N`fl5261hVq~XZar|q2k=C7wNEYpuY7}|9FhkEQSQo^GH|mt3rXFGjmduf( z(C6q4FYHgCKW~(o9dvKD`LH469p&wLr_W;fK5b?F^JU#LB6SZt<{97oS=&x%&SuX- z)u3M=<$FgYzkY75gr($E_w@w7P>5E<3CVs|OZ&f}M&?9z!jfn*F84t3y=*(q2YX83 z!aGrQ)1*k1{vEY$HL0kHsG6`46%F1Kczb7FA8-DAFT(9=2toTfO~G-it4R(X$liuE zQ72PHl=6tX^Ra)bPfC$%zXV9_1N6o89JuysE_o17i4JzatMcl!$J+9gcewM(ih~&R z5OFLaJ%r`;P)5~8&GEuKXBuqF^IzU>Ik=9UInL!)hocBm%=feAg84_!8#!$m zoeqTBz4e8^$A#BlorK-Xdean4?<~nF#W$T!Y=aIlh`SwvTn-O%-2`PTa_g3|ofSf$ z{ixO32M45&AS18dx4t!R^SwnPq3Eb}zNyuSh>29CX;YpLqEnbnUCX+`E*HOrlBuHQ zwM|a0dFSM3?YgGYKQ?*b)BL8kL+qPQP^?cIbVNjA$dV<#lW0y&0b)t+}yac z95Fm+Rr7Ao@K_8I-?Vy}Qa4Df;+4bIH^!1Jb0)j8tQ~FL9>-x!_QpwUq@>(*G}&7! zaW3aCqq_hj>Nfv3BedjWCRgM|FsK#7-qC!ByE|^LI~Y_nitx&2}HkK458JmnV>NFB@s$R+DmPXLg?p@Dr$F@j0uw;m^md zv!9+)QXUsk^1pbdG-YkB{<9ZbEFhy?<)7@VY^%Y*VAxfK^9_N)17#$J(bI*MZ1CK^ zJ5CELi7ATL^5aC+*_q`ko5oE%UeLoe4(4QflT_}pCRTfOd9$-Kx}3`!=sXk^{QIv7 znS3~@jt>hH9)O|5uAO^mQY2yv{h<;#p6c?n(tSS4N_@}`48Vd`6<5pHu?rHn@*Bp?@%n2;t-SYKP=WT9C8*pZm&6x{7&}$N z@>JG!U9FG#G8rb>Z_wDZ){pNy6FzB}lWcS!IAk_ICn>(UX`0B9TV#Y=D}gF1O>eb4 z&HVSXOc(d$Qn06s1!{VsvAu&N4pzZ}weIq(J*jNNdkPt9(l}J=pTZ(HtuK#1`q@YbUzUY94n?Xf+ zk*VXv;`;(Qp^Hnw7w4}Zt*A0>X>RWCoPL&Q&=a0>K;y_>GWo8kXj7?}#Jd3Xt_fZ9 zA95#rzvjz#*D8ue7bpwK2C=Vy$&6FAwX#$2woz6;K6m$8ljVsB&QO=U37?TGAjx(% ztH8X}wx+({jgKR(YG(yjAZ?y~;pJ(Jh;N?}yCSz3;u&QD9V(XhGEwjhQvNMN@d5hOe*V}&EHJ>D|B0D5l&~?SSD7=c@=f~ z%I}R$Iq?OkSkj!`Qc!7keZILG_h$ND{0ZsBEl)>_$)QHu71Ne#pcL7Euz77HUed)u z1c)O46y5T_)D9srhCZ|V82Bp@0*)RHYMRJfeD7?KsZ^0r3B zFtz@(`OdU{Jo0D|>vx(>KR&QLTKh{e@KCny4L6Eaki4sD7O^O*TF)@_s%%NI zlf$oU7e`gBDR7?xSgxOv*i))=J4j^CBn$r^mL=h|aAy@k5{Bpby)nfXdt1kXNFSUo!4cr06S;T2Sl1rQE$4bkpe>z&tiXKIM9 znilDO9A4^LeNZc#ZmiKyvR3@#U>F1Fp)aZPk4@)%wtJ|Y=&JdL115Yntn1~LsK=0a zer4H`nCwS3tLDl@mUXPOLkwHxZxc=i7L{_0;*aAcpP;?_BFlcn15|#L%8X?Kk(+x6 zK*d-_9AIxSnzCx@_9)MNOHO>%VxLOg*W3FPpM3tk{cW-);e-4!rJ_suj%7jLZ^_5s z{T@_0aBL84^;!l1$nk$SIcaEX5}b6~>g0#FOV&B22hQHKx_vI!)GqEO-v;0v_T*nW zcTC9&eC~el*%vDkFE7P^1-!SO{Jp2&=D#&59r#dq>EGiU$45|m0mLHBOk1me-lkih zw2BLS@j%h=7;thU13qc^z~q3w;s~m+Fn_;kT;^B#V`-NTqHNReipspuKl#SG%%m_# zF1|V~EUxU_QNv?#Wyed88y?F@xm47W3|t3fFO`o!r|{uNQQ7T^wEXvgbkF4skHv>s z*`2#@Vs$&e?6#(n)$OA1rnc#C7pw(N$KOsWJ6_`$f9wB|ioY#qC-dsK;g{QS7p(H- zjWqNhocmyEm!yAkpH+@&*=;}_$7RY)fHAz9NIz`->O!xXt=+i{Q>(p2Y5cYe$I~vw ze@+9Wvi>VYUy*#~ao^fY@zuA_8QCQrHwHd_O#?V+0342`eU<;9V0F?;uE((~aBtB` z!+R#D_3adI0`ksV|8qC(ZsvXyx%kh2l?Y&`r}SB+o8B|A+6%D15cGF4u4O(4)Dy2? z`X8Myci)yd!uTKlb2sB_ugs64OD-ter4)d{m%C|Sdky3A_XBeEIL4o|O+gg~?M0n5 zJX;oIyl8#$N!(LKprbQ@U;^B5$?7j44xns(vc}rvLeFtKR2tuLnfPXl-i{^ji!apl>&Pw3w$3mI3n|yH+GB57= zm`Wa1Qg(b%{r&Na{686*XS;^PqWN58lDhu+Zh8^`y2zEB)i`tTsL}`F#v&nLF#lXi ztGyDef7XOAyaxDxZ}#rtvrgxX4ou%w&~F8tg2ykif#~P{U&3kE#Q2PE0ZawHH~!`~ zN#hepJN|9vL5#qUw_o^RLxFY2_3xY((8r1${#kWcVEKg13xmIblFeU6CK@LX9aS0< z&%bT`&$3+%|IZWod;PC=K7J8h18gH5Krw4aqxoCX+xfuC0(+jn0caGZ@d*_IVn6Q! zzMKZo&z?H#ly7tT@XuqtKp>UY&PS0J*i#=15IliJA}6P7evg<}i8B<-V}&>Vyn9h% zYHOu1@-1Y7=KrTo{ZdG<;i(7tPJ=u1gIT$)1NLMW1*@y)l4!|GX{!-5>~=ESY1iwq zO6j-R_&0t-mRwL=bOI+?R`!%26k#QeKmSA|8SJY9Qftda@kqOQyV&7=ZM3+ZQvi57 zm1obI=d3_rW2iB!#KgAbPRC$KBV$Ut3S^cEUnjrOT?NC>5mKQ6A}=YkD`w zX2ZGEgHTS49@lwLH@uCxvQzz5HwfP1qZTna7Fq%{93qQ$yRu7+AeO}2@W{}Rv5>7Y zn!RidOm3d~aP=J(@veV1i!whf2#Ptv4h{9t6Q6WO-^qrK1Qm>>BaXd*w?dyAYas_wN@dIYQ&FRT?higwP_b62)lFE==)pM?wQ170ZXRCc3AsA~DE?S%sy z99lGDG_@&@qrUwS23uc8jg9<#;C9#G&~E*LN>Z%f%&^`(oAr${eAPpu8JaS7 z6%I!dUPz#Ks5*C!8k?3CWs4O-vGy!1T) z<#&nxINlhXt`PpL;##52{@v$)hoUhY>RO_fTAY_fgnt@4^1=sCjO4fsby6>yDLF`4 z#=HvW%~qce);mq)aCaZC7X|Kwe0*#|j10Wa`QFYZW`SDWPz2kRsRvS0Q@mlWZ0i#g zZE9hr5V?|EQ~U`&zbv`l!%jss5*nbIwv{yg^*?B(+|vVqRL2rRI8}~%JVbOd^Bt96 zVVthV2n%r{92#?|;<@l9xe?LuHfYpXNVdWw8+OsyLfahsrh`1#owqwPKiVH+XEF9u zWcz22miFda!jXQL9Y3*D_N|?NS7)a-bdUrK-EDvMHnPa8hUguTWZM;4knJvMkO);) z#QzX%`_e|*9r^Caqf^H6L&TQuj=-KoY^*Oosa`oEnXwj=3F+oRB6iZpMAo}T*6NY% z&Xajpyka5R9HD0FW`d}|Fji;Vb|CXe4R&hU|B>+V9yfxO>i2G8;-0XuC@k{f^q^D{ z^#NpLq3m)9GkJ~c&D#{DI@fovg<4GU=|%8dEE{$hTPz%YFhh?c;EV}(QgHJkmS54Z zhH1jK2it;L!%^(1&i0Rv3%7pHZ^acbWjJ4AJDMsO5a?#DQdd5hSFWZ6Q*GhlqJ_7C z*SX75rh-{>nULJ2G@=$P=-}YD-pS^By@eCEwN3x}SO`usw1z#z9)xRSILx*nvp#yVk{)kjs&g_GF*uP2Zu;~)E$UpObB+_o zKTzw>;Q9!8$uxA&{GQ!~_P_h{n>nI~IH_ZC>W3h(lAHjM%x1?m1M6UhD2;l|V?e^fN_b68?7`$ih$X6|iOxYwq-sS;EO6KiFSo+95U| zSA?V_fcHt~3Ct@(M9c)69Gxtjv_?d$KSO4O=E6e>5lg~S!aO(Xu<*qu&_~b|W@=|~ zYsDdzY^jqySvwUGsNNCdj;fdBhd8cDnNC9I*vBWS3Q5*uAPH>g;Lb3!1dCQ@ZO^cY zIUvUtNzNR%yT5-WmDwGN47rCXIzD&-Vugu$66R#UJ~jKO0q@%^Z?ppqprg**c5<&q3_4eeE10frDN!vv z=$vFA{}^#UFFb5ZHd(ewXUE{Q@uw50Cv_tyOnavF&JZ)=p54We2c2gJWUB?W#?5t!^0=NmLi7{;)Bkx&5hN5GRJ!W3x%m;y&Hl!JEo49)8>>& zc*GX+u&1XMi`a$}A8_W5b!<3fq9J-aFnC)yan|3Xt#xIV5UM21ou2uO{4MhyGT^so zs&$+B4<2ZKHmZU`qy#YnR?d}-gdxhz^H#9@uwGiPl7Ob@!fG2#LLyVCWd zw0FPth(~R63w0W(>YM;;droJ^m@GA!2;X$~ZfJh4vUf^{NPV}k*3%Cf5QaRk#8ijw z*wWT#I!V-iVLwgy4Y74KOW5wze8_t&qh@BSHUT0P&g|sOv3cX@Z;yX`d9TazzgAjh zf5Zc%9^MwE#^vnH@DN~FaKygH_X({*HKELT7Ot*b52lTQHh>4Tezb7?VPSI-o1J3| z$=ywPC_Q#%=M2IEt0#xO*}VX)F!m|I`>=FG$ar{W} zR)1s?{9B7Vj1qEffBxlkONmxNKW7N2Y8*@8y#@xOo;`}Ml-*vP>2Bzk&>B8>qBk3v z4AgBn(!WcqyBxK;&YAiB4>J+M+ga-|5+dsAWMA<$A`8;4wO}>2)r-7UuP5w)u{t3q zSZDb8=|$rXmAZjjU(Pp1H`>mkK1&L{-X@O8V6;%4Kf+0V&0ii)qh?k~GlvI{EKw0u z=b(+v2J&PaGsBH;YdX>TXB zPgqwE-QwLAdA8F2h`T5?My4=_z9OD_d6&!$uXs=+&It=tQ?*Y{D&Fpz2W;{Zl_m2& z>Cajz{&I+c!jzDdVHSR=>=oiy^nO9e~LRC6vcu`6d0qAkr)iUp^A z!1k5hWWfZ81!Vn00h3qHtU2FkpbuCcKhwz)VcltTDj`##4%b+4Em}`ILj6$#4b4Tx zA;tULL7cECn12-PfyGbEAMbGWPXXJFPqu%yjh(qo%NTJ24-iq@!!_p)Pr){!I~?;n z{8x)@>&A=sA6+619ZqGj5O}3x`jIn%Li*62Zkt1&W$V8UcZ{{yw*)oNI3!9MW+!J3 z@Alj^h@(a8*zr}e3xxKN1x%y-Lg<^1H(u^MDJ#sV$Am^P>7@S8SNI>OeJm*y){}ty zg>2+@!i;>>t^@7LLi8caF2{?T?5ra;CoNy73zqkv-TWeR&tY@GYu{Gq@`1@h%;|%L zS{W)Ed|@!pkT-|{XAG<%lJ36wCAL6M561ibIHD(HyMUQzzD$03@BMq03ot>M5ZMPU z;=0-`O0vMi{6$on;(>0L$nL;$V9LO2*XLJMVnMjOqj(xZ)xjKPIf6z2tcR zV{(~Y-rFlLVvU5O&zd%loz~*3EUm=Zq1q1>U8oH@E}7ugwL#dZEANKU2L&dRL`)l) zjLsuOHXNv;9)LsS9dUWkC<%oJqKlmPu&6)qZZ95{sduCue+6B@MVfuZq4K&%dv=t1t-~&7zHm!RYo(CifnHMU%wuqdy(xd|_|QBenVFW9JZ5M|cQzd+ql4b^G!wbeA5#2R55o?yA}eVkU~g z8kP{UL2yD5dPx5*hc4hPNJ|LqP^$=GmW+(MCpG>Aj30Mn2(7}ev8}J=7U^Jrq!@T$ zb{L}Dy+^em57vN-gr%4|>B@3@Eor=rUYBqe9;zHu-Cu-0_QDuCERGJ!3O-&_>Fq8E zkz!I~Hhvy)5&J_Ip3+8I;GTL3j>As3m)jFuO@awyfI0lu6%o)A^4jO^3b3lo<>#-Z z#Ktgr{=DrEGm}e9q@NR{Ex3jWJWl{uVmyV3C)-7jbxT=X+@f1{HHC#-<-A+qs$InE ziBG|v!L)Vpmckz2?A`H9N0{nS9H#&sxDw;o@P@JA#cEtwdqeq*a+1n{#Nog+pdOD!8zWT48)*%2LZ z?Hwp!m(wfB6pvV&2{jBm6VV+&;*?b3E@|3`Dj_EmlkLDHHf5yt=`jObkxav1zbIKS z?_vm@d|%69W#4)ZsXr5$OOIKb>XBUEea6V>oXTV%^SsuG_2XzSwQ%AFS-SNfN{#oA z9U?=?J-AKDLa8@#6y*AZfc0(DY4FBeL(9iX94Coh6g$^P*+Yw_S^!NJT#0o zQxIw?R1!DK#^=Fd5fATdPWUqxNFxrSXr?plgO$^nKB_oz&Z7WLxKA+l1o~`8Tt~aM z(H6$&qVAc=N2o;9glT4d$)ifhq6~39!@cI2TI*a7sP`fDG!YXTzTQzjNU&!9Sf0tC zkCwHs>Uk`I;Od;z?m1gyS?f^d)|mwd`3)bW=#w~5E8Oa+)lZR@s(vKkDoFbLf{(Gc z+q(;Z=ub(Yzn|5e>Tvj?lHLk;d!t#-KC(i6?B8wg@_*=+pcEw7`s>Pe&t5Ap&uQYz0CiPS{8@e+1<)Nyb2N4 zg+>NKX%Xx>;gSH-10Sqr8!SS-qs4t=BX-!Oy73dG^gcY4yMZ0$&O!kG^mjk(`n_G|n>bi((>3f3L z!Xrfs#}cD>+J_r&lw-$u`+cf1&Rjn=Cbj{!@iRU9JZvICis?`#do6 zg0C*a|JP}P8`^5DbQ^Hy5%(jFNn`ELkIuipYj9?oJX~&M1URy5+OVC}sh?A7n9GL( z#eKj$6?9b8);fa5$x5;O&Nwujz@ZnL!Q2{m(t(-IKqq6_@+am{L#xh=!C3vAKloHr zs&5Gqlt4kn$j?Vm=`;!i_Un3^+!p<8%^ZGQ=jf+a07TJ)*}-N&W|kC#e$mbaU@?(Tz8$8y;k$WuE+}@GOKFYJ{1giY25l~`BtR^$q$f+YVM#r>P08J%A%eG}lhC60 z$+GbNou=q-+#u>~;o;tpb+jzRr0I@5IO@>u$V`cdC04G-duJH(f+@*+kdIp2b!ZJn z0WKUoaCfb=yU!E3jN=7YC z@9E9&w0Htm%f?Dvz^2ULUU|Tu954V%k^#qwd9B)lJ0Gc8ZTLZW>CY2EP_`F2Yz6S` zxjWOdTL}YSuZ+A#{=0=yE3Fc;r(Cuxt2tN1!jd$ySYCd3YW(C6o%U~?gqcXuSUVXV zwU3ipEjqr9yxbf(;En<@dAw*yv|<)#7cM`#mZ!e!Kb)G0&6Um@?zst(c!;h&=%^ivAr}qH6X-^N zP>M8g)$Bna4S(o2Cpi+5n_R*9lIp(XtClQ}{LH{?lN0iIYh3JZ)A<#%{Fe{5w}c~| zcj@t$OZO;on_C8HgBU{iPUqc6{kq}9_r{@0+((`1w(fN|*0v}q*d2HsWu-2MihXNx zMj4w2Ww{Rl>6_dU?6UK!t<#eRu-<9lbZi5}b@t;7@t|x%)g!57!IqaUds_=^A6aJQ zC^d)KIk!-NrYet!uw%v|&DGq-@{*BM_Gu8ueW8=umD_4LQm&u);-;a<|-?(3jBYNR=MFG;QRZ8gIz4j6#aH;{PANlt!wbA-GTA+?=aO4BN*^VO zNLaq?_^jAtvg{XN_U4@RZ5^nM!mzL{2>e#$Blj7Z!X5S@u|8~^j#H35J7nG5xNF2H zfB8la%?B*J#O%aBg-=zql4hJ2@ zk)59Ao;YkG5;o4uC9E2xy~1l6Eg4*XADtsOn4sOdEDVAWL#1t3s%T%HHcoq_B1^{BA*Q>`X$2gC@+j}*e-vmFnJV==01*SsFewerZk^ala zw+xxuDC-QC%})4FBq8axh}^nnOzf6AH}p5j>e>(;iv&EMOE4 zb>^(VX6<6{^c27C$V&)ftLv&H#MM3|;$JCB-M#E?d*OP?_^h3TNG5TLIf0k~mzcKS z#I$wDI};P`vOo5AvKVA8DRP*#yTc6$xp;;7QTGYHiT_#J_Ae&apfqz6F%6W&#d%0hD9*d@zj+|RWgJ$izXj*mOz2EyqexQq$ zjvq9c(|$amTXV*&kU=-e45z6Yi2A!=qx z$5gqdl5_4pg<%dsk71FydyA4|C?+~;3f#y?r_2DVHlVXRl&}S5d2FV+g$6n4BDCUq z>GdzpwmB45TQQD^{F`M4O*os8JLxn5*^NQS-Lyie?P}1)cfsBbuyI~c;-9_l%+pOk4<3*s03Tv;>YwXUx+Nj*&VE&H zA>vN8DqRPIyS$$dS0GeYKUadnNpiSwF2gxDqIo9Zpc3AYQvWK?++99pMD{`e((S%@ zvp;1h*S}bZGnwsM>HY$^dqsG&+|Oj$F#+%Fw~pD>;8L_^L@{agDGevxqU5dAmQ9px zm>DP4fl|epiXmR&SIe9(O=cS9y&*k-mMRA)HOmuRe?9koU@j%!dMZ9cRQl9<=m;Ut zXjj+B{O%lVqyCZfpp-x2TPFXEs2i3Tb7V3|AKXTE*u6gpOx5g9=C z1_qr3nI_>sB|>aIoJ}+{(KodYJU-oE0hMtoF@;rDi07@NTf$O;0nYOBAIAC>fzz1AT%|i~B07^6P zH7e6L9U+RfZqLqrdJOuiRGLA0*0NY=ljUBcy)IV9Ama#J8Ds_h{pM*~NqET+JEq+| zs$!R4%lfOdvGhttKs>!eE5|VLtl4LtJ?f|U>0Rrj2kf=vY$Q71vt{?`}862u%r*%*ouqKJSnGe_DyJ?_HRd%-fLOrDQAt#N6*;a zwY9w_xnv++_PM>Fm!w6Xwg7 zx3bS>)fCDwieG$`*2~Uj>&W_F&v!4rP$awQvAN5TzSC*~U?tya^+0Y2o}b z>{1^5CS(Rwd5edj+=huZ3bD+^NP;yY<=P}Pvx5~sKm|7nx5=N@mSm8}SM$jCKZ5D1 zH1Q=F0E#YAVkY;sleOLh@RJ^LU%0l>Bqh7<;GAyKcWVnJURYWbRe>_ivNicMi2|Z( z{aYvxg%`yFkHe3qT$en4^=y`kpoMa>Y3(`euPBX$*pjHOE0VUd@+FoYL2|Np&SCBO zj8dMxAAEl@Nx$UGZ}0S;#hXv=)*36yAv0`Jk{ojlzh)ZVd4(mIE}1O-;MJXxspGjuUZX*eS@lQ<{XWm?KyNmXf z{TrhH)lfe9@Y4N0Bdo~i{_X;G_v(UFNm6*?aK-cd)+&mVkc?LjRh9px{Oe2w8_gpI zmK#_3?&@7FVclir@h`R(VE@9Q+Zz>eh}E#@q~6X#Xh_}uL9-w^ofW_=%lgysv=)l@ zkKL9D7z0UM#6-(yu4|-MYaZa@E#qe#dYU`LFUB%+(gV_Y6HwE8n5P?P>hOy(VI}Tf z9fPWGckZ6h{o?xY_vv!y?kUg}+e9rRcb^V8jxUzC^)0)-la;5tcZY7liyS0fG)NXN z-6nl?VkxPdO0tj3;MvSb+T!4DZ%=9&9V0<6(i?VRY)!lx;w=4KK!MH)bLkD5SzZwv zi?ho?xwFoN1&6NzEtFh)Dy+K!n_xKEhwfmACIf*ulOyGdkx`KoeB5PBk8?X&ri>5N zE)M$Z7iopc#hA9>RvnjiKk|xBYX!%vo!7=>u!ob!vE5cju6QoRD>4pG3;%K4(GNBf*H@Z}B*#6#GTt zbil!WCzM+u$*m8FM2>!AKRIK(Rf>J?;ASi=Gx;<#!);7;|==VTsQ{{2et6BE}+zX_Q7^!Ko}skpS`X6g{WSXHKh|kR#L`A;XYM$l@DY&?u2jh;EF*^`IULX2+x1hQEZtdW@wo>hIMYC2Ul~f&*;tT&D_88X0L7*Y z?=~LLmk2+f-*Sd-SvLt5TM1GU?&kn9M}h> zgJX2rsf2wl>onNqB3}U60uV!WfR-(6K=Clw z#t4|Z*(|`>gmG4g&j|g0XZ1K5G0-6>7_h?|qa|}OoGHOr)Fc6jf*pQBf(vH=IIx)I&E660{%%tlmc?1s(7$k~ z0*&-BXxtCdSznjzpzkzrhq(GXT;>Lj`!k~Q&w2B9Mm&tO2xf2eVt2+p4|c$*fLsR% zo6?NpQpx)(yxO?NYA%bM4`m8-Z{ICv!MGeu!2!2}9f8@!6uQ!y*(g9z=wnI_AY#=s zV$F(UJ`k<~v903p#j+d89UplD@7HEs{8CI z$%Enhm5N?`+{Xxw$W$M8X;7i>`DKN+q$0${zAmg6(%^U>TU4tgct+ic7{Z zK1B%a@S49Jxu-&j_wSG;VR4z^U^R1rM3qR^xtxtdRxdAw?eV zL;jOH9S@ks?2n2o31nybAI$SGIQmI-C!gpQqp5-V&Pry;rQvG?;Ln{E+H%<48Z*!r z%Ld?M;ckCQMm&i3SN1gQvk%m%QNb7#r=FWmG>DmCIc65dvIl6ZmU=71cviu*^v3fk z47mBzl18h0w6o);`l*Yn{#IJ1?=}o&@9C@>Zthx79NXXatyiQSCmruRq+JKl<&YS! zYASg{B*^bh`rn?ao+(j>$Tuc-R9GT8^8e>+Fz2NWZND2spYO3?6&6fdG&Mum$zD`p z3LL;10x-50w#R!6ADsX>sIX^c2;4vn%$xx4DCaA1S*ecVe_gv}>wwC&Q`m7yT{mmn zNb+ulhs9VJ_?-OKhajvizZ^a@PM|nJRXx zJ~4SNM(>siW{X+#?^3s)t;R?3fNPi+|504ION$urq`?idr6A?Rmuq)E#82lw(Hb<84QU9?M?tMT_g-_Y%-mHmK#UAXS$bU~duy@+0 zeaB$qf+7Fj=M7#wu5~%{O#j!;IsJr?TIsvZVtS|A4j5+8(N0LLnkF|k{C-|_zW%!q zg$JkfP4tf#T{I~>A}TTqrY>|HG!Oh`cTLQ@H~BYPIE*S`wYGhk8+m2XwBUg-rI?t= z`8ifRphb6Y_lk4X3yB95%m|g|Ot+3>Dx!#C4=KNe$1515A#5Hw(#D4e@LCF|A|8Q) zffx1IH%9J%$?dp3fRE+)t{PKBiH^$J>8BqzNK8wjqYmC$aeUdI0X5|~(~6!g^!Zij zR8waYM5$>i7r#8Y6a1Wco`ouO;p{dAL^XG*##j?>W1>EQaKv%jKjE9t& zbT-%3?H3kvJqF3NeP%mepYIZgp(7&IIZq-Mo(2lg{<<_uU>Q$Yqv9~g_LzP|RP{xF z%t--K2fRtknAks>EF3!t0<`)#anAl1tridDsWofYdQvy>%h`06MY?X(9q-}7d?Zn_=Tm*e zf={!*v&Dcn$v^zNGIp;AR=2vhF%gE^-t2sdr(7b4+Y?et-!k4$Y*D%+56xs#D16!J z+2UA9K%V#z|Iq4_UA=Rz{1^uqGo3zYPe7pyJVDO%rU$~Ap zn_a)|F{6^#u9`9!f9Jn}Az1htiBd}k(tAgX;-2<=_aM&quvqv+E4|WqikeIg(Z9fs z97!$7uX(W&+CDzLDY0L~{X<_`WY_EWuuO#}+xY~Q8se&r0$A&e^MbrZT9(yTcPCtp z(ixbK>EU@I@as_LsI*z0*}$l)haQa!4h+RsOEx&rt<(pGSyi%S9Xrcx)<7*iN_TNx zWifc4`749I{wF^R17+-rf+Q6q1mst?o#?p$rJUhe+PDk6{c6>g|!`{qEk_;KEK^< zw+|*5+>*-)^mZfqlty<~Ng>un8?P?;E11fTE2FGv$OJd^`xl2S(2g8JA$ftphQH1?tC1ntaHMTh_Ri8lXQ4kj z?YqR>;PB=>ZH1$n&d-8yW4Qb{wHnWHrR#qiE}u@8Pbq=4eS14F_b}hZYUkUTy+6o& z>Sf2~O9gSBXA^}xV`8!)S>EpKG;SaKMpMcYL8i%icPmAW|(LLUL7BRTf zA3FT+In5*1h~$dWbeD%RtCc>F4Au=3`6u1byN3K~F?l=9*Rvjf9MSZNEIX;5R~nJs zb^TnHad9U?YU4ss{-y6aPPd+#WGzs!bAIPkI1^O+IpCnX8`D|-|6^rPZgISf?y#GiVf4^VGIou%!v$#0E;$U`PH>arVW{svj}s~ijsMY$D1cf2nS8Xqe^QR=-n_a^ z8$rb?QzMDVLt%qhElf2$9j>>js|Tx;Xc`)trQ&J0j%_jfj+(T_;esqfQ)(9P#S7qZ2AnGyho528CUr)0FTTxqAq02Z#Zo773y9wguib%4&GQFTug?nYV~cXFTOzad^ZL(R7y&@ zn%K5f&S~R(vClm9eW~lmHLT~T$xj%^>cVL6zrf3cbIwb>a{I?s>HgKy zS10Zx@@#ht$WZ+&_G-HAzvON!Qm~=ZutJN%=Z#C)AmBJ?fJ(vQ#I8i4299D<=!T6nSmeeb%H`Eb+sUJDG4%kCS>s(nfdM z5X&LfTE=a%S5L2;Fj6eF%T~DaSQwKbSRwm%YHvB(WL@lV-21-VuE!^=PszD{eWh_v zTH@NFgjXg)#Wtc=ebJ_O&K|esO;MXIujJCLsv~(O0@*ucbBe7pum5j?;f?$$WVC8| z`6t7(KNXd3`lv?*v`eUSmH;tTv+L1Vaiz1}31;#yRKkH5mENcKneQaU+nh4H^7Ng2 zMX@P(-+^Sy-bXnWF)!w}3g@>qbzO*pvbdXBo}D+#{Gx(%fWcPP%TsKUy;l zEkrivl^uqT6&?VZr@1pKN5pZX$C{^-(`ErtXr17CD5?5ww6AmbO#40oH4b3!bGf37 z^vRBin}=2P3fQzoOax&6_W{n>L&nGL%!m=;-~c-*$YoAEp+i~xSFWAQa<1!YXrbdB zM4*%m7?#B@I0RHijBaaH&XQ1Z^@|#!9lzNvJf*0}gPf;L z{b`IFXu-(f09@=$O!yy|+L47A-3aGY@#^z($^+5h%Hv}(hbo#hG>z|%b`eM4&oh5| zWTV#R>z6c5e-6Yq9c`mYfX(b9Zh355)VQl3W-h zu(P)e@uw}IzMY-{Kx(>J9n36hGoY~(>|^3_-TE1b;GA;$vHl!Dp{T^U&^|*fks=|l;>TyvI zAQTXNG#N?dl5@OdhNUA<=aVMd%EUd7C!gPgWABlH60fkyWMN8^TmRDYxllM%FTl!wxtVaRWs`!|INeSgk52%|Kd>5xzN97w<7X zx`ft9chmKTJb5H8)-RE#hy_VQJ47xTQx>X^oSRu<5=&`Ou>FbM^#w2|==bQq5xZyy z0LEul$wH~amFBkF3R{Zpg`ik?Np(VO%oee?jwMgq+gMYt((PbaCX^9$k)T0ROp|1m z{BVh6bc>1kDuEh(z&?o_67JA-V!H7d`7FM~S^)7Bc}Gi zLHoRA^^*E*a^h{z+)&^LKSPMaqry}}xD}Dg5}Xi*5xbNU=F0#Ucp_KRKiYf=su>=*Vmq*=hlgHtFU(3%xb_3a#}RkovXy1>{>14hm7lK zawngbl-;{i6_&p739NfEy{`m(Fq%hakNZ`}$<#*#6eu3dxo1-5m6T|NG{N+@P|gN! zyECF;sxl(DwW>g`gR|=wwc14o(s^D;#>Z4ta0vEe3 zl5LFq&uVZ9Z-o>|n}7o(ORP47MXrr4b0c;>)%tJT z|IEYQZBPh%Q=c-wD$cKc7ld)%;hf&lrH+kA<_admb4~;7DygpG zmTNsz&Dx`Ke{#OH83a_{ey8Q&_&8HuzTzF4 z!o`l{B@u#L2)LN|7eAgSGDC~pow34 zblBOQ+PG#|RP5n4>Po{uffkhyq<0XVqIXihiiBPK@L7bvLdUz$L~0grWGFA|<;R41 z&;Ao4M=ILZzg!=Rm3b2q=~4Hym%~0q_Kq{n6ngYl^|j?n!iA!+3B0XSo$Tw-x*@$X zOJVVoa=>e@ryj_gdkZPOmtBwo&W0UJze1TPsS8cJU3|4W*E_JUIU|4X@{x*DR~}TJ zFf8$N2n2?a{-kW)9z1Nha`#s8Q<{41xiBU|_Q9Usd$$C$eI)_o0QuRCQwK_8)+quV53PFw;0OsYGb(qb-s@_t{u>_1tL3_LEmA6c-u zmh(Ow^D6o31tSrgV;5}nPyPHS#fQyq>(d<5_^q@6+YYD;CN|gD`gL3!nv6v|)~dyhV@q+kC&9P9sWt32aE+{mctHTumbC z!tMu9P{-w!pN~!%-Y2|IZ_z-Nzed{qYKZ^8zK{Fg1^=hO|34M@ChKry4ov)bDE#%` zsQuSsLq;ap-8ktx{Xcs7zxM{cxGX-r{^3K+i3G=;q=$EfmEI2`g8SawjjJV682UW0 z2XbsN`irWv&l-;{4z&w0=AiycCZ9_Zy0 zmMhmz`#kTDhytnum&LB>s9I0ATAV~9L>hkX;-BulzIzP!Q_o_^wR$nfE2UQx@>+zZ zLrTdfgT`jbjP1yAPn%7|7Xv$KT``wzf-bceO^iMH8NEkWy3EbyE*4Qxo2L%rTmGVD zm7E8apyZ|^#r!SM_g8x0+0q_jwNy#D;kS<;{Cz1SdMK;qY5n%68AsOv(thgUqw0jl z3d=6#TX6g;xIj*NC3IXXpxXSE?ZBcy#5%65*+Pk!3ly4+tB)_EO@fg59M4gmwDTDK zkCfjtTm;GdOjc9o1|sx%{Ih?T>`Zq=yto;Eq(HI7>jK7y9=lJ2q1g6ntU77(zF}tJ zJvXTzCTC4v^0t^)WnW$4|9b7+k;7-c*!cwK{+Ez&Bk!l|fIaxpKl+L3C+^$)BbB&w zvZhWe>&TCLXN3-1pO89s;=hcaQf7vRpMM$~8U{biw6!0M%tR))#xTcCa|E~lezwbb zt*rqxPa1`McOD-tMf+AQEU1I)aoz&b0I>#h^d8gVjo>Y!AMhah_e|Y@aG2Z^XfU~^ zGXRAs8ZNF(@*)Pug4Onp(N8TpCaFA2alcbapKYnOCyi`q?dqo>uGtH{67%>TQ6jBd z38U>MMxO)w2efd4O=B1%GLlU?dBu}!{-T-=5QtWjwMo}+Pqz_Z#RuCYN9Bs8kW|Os zMv#C14@YI|FRCRE=r=;DZC>$G9LhrGX<6tdC4KqPFFy_P*j6?a*0giqe7P^5?r^e2 zs|P&oI171KsOZmrU!n%My_P#*#ht6B6NgY3LN0_hWi05v>Z84hFd}f4MxzM+-0iI* zqLM>&bF*7ufvD|B;`;J+eCcA44Q5LzS8#P4Iq5T_{V`z1*_V)7hso!)Pejfd6Fkg7 z4`=2lX3Q%H+q=mxj^~U*9PrCEP9960Ea;Fx$m-1ZEEKIfnrtP}SBPC%O&?^fVIEdo zOYEAY069b@dv%l~Gdgm6H++`*J!zOFTsSZSX763D-wVA+gFM~do}OTjF`hHg*Rvq1{KkFNbK^ zIo0BF8e0)2Cg#w~<{Kw#Thp$Fgn8vU)saELklRG{bn*jX!hlzLY5a;63YOoa*D9A8 z?e__Qb&w%QfNIRo=R)$SA0|BBU6ZW{(r(>&dd?XL^&cmu+iN##AF4ozyX@W0RcSMS z0BuTM6~Nq2o|zagb0|$einl10v@c^!z=GfOT)cJ1ES>CVa#qJVhXL~G_x2w&zCZEl z>zPxRH_DbGb@WYvez6fXf!aj)A8X4K?wIAxNT3P=26bUCOwFK1$px0RwQb@y+@3lD{tzqMPbCHSBc|#RrD#oY>7V7KI(im%DS|GR8C#jH9o; zy|VcWBb-w?HnFNSC0t}kz^+iOsjIMEN+baL{0h={p^ytN66M4f@{X6(Im4LwbB#TF zxfU_raImi#qv84h5s#KPs411p*;KBnR$NQj*)eMDc$@8bIatp&@k>vpPSFs?R?K{d z25f9nR8rfSzH+&qM~as!Gkh=jw(>2aK54(YupFISZC^h7uvE3MfXgY}3afLk7j<^1 zsL_iK92bo|Nyr23{go;cwYk#>w*-*dBG7AFq)H&-h5C_yq^uk*E@rZ_i{d#waeLIA zh|x+kp>;PFy-YsXdJC7~733*FC0^epA`KGd&zPikMS%L={LR>lPk3H^?Mb9%2W^-2onQub)GjyL^S!g z19Iz~kKIRc7KtnYi`1sK7DmT;Sr&j3#R(L7Q~%n82Qamfw3ODH`R|r z&WwU%X6e>Tc>$J-KhVd2Yvhux*gu1RKSKBV`DkLCB%^xFnmK_S1WNGYblIQq(`VQN z8y~yTl1RG;J60_Jfay`$*;OL|K=k$R%eehYJ2l1!b-&8E6NhXLfkm5zL%r>D^lB}3z&<4)e!BtThSE}VBqeqBD+*Uq2%nffYIV)xcX+q-WZML32b`_g)rdEj_Yef?+ zHf{f8z5Uy5^EoCG8VW(L6-hd$m4V=Ae~nJ{E&#c8O|Fx9l3H-8V$+BR9t@e7zLJ7V zdsL<|&@2EoJq_oK^n72gx`am_jRFzS6)D=@tu5rcuBtkFP|jTR83XBXoED+6mwp;; z5QtZ|B7f1yos>~sRxPqBzqMXP+`tqy0m5gbtw>iz|75*>`ys5PrEcJkf#Sq62$&af z9%5X3PK7D9x-5O!4E-4r0VgK4t)0H}EHd7aG!QE?WKQp)X-xPDmQSd?nOJ`5={4=9 zx)=#1^aQL{=bGY??TbEr(DXRHn5|ydM>aqQjTMVFRd>t>QU5wk<7=};L}U)@xW_1` zk~05ipiIc|Hv3# z8$Rv1xj{>Y8VB31A(V40{U4tumNpwBqzPVzUv!l*5znBHr|qYp*WbzL_*;C8(fRPe z*8f3jr_<`9#Mz-%#ntLOTA_?Lz)%eJC zfsL0S(4`(-zg59OGQ)wR++L9e_$1)D-&-@?+6zNh>a~>%*st>y2_QGZSz7F!x(#7I zcYSaIv6%tQ8Rg4?L@B%=zM3($NK9AE1zONUZT%-!1aBe#)9s~LXi}to(l0i8($>2{ z5Y@~oV5QGOojRxq2j5sb`Tl&ILtao+XytlO7#pFgeK$V%E^(X(UpYjVSM0gcW}%~3DX z92U(Rmuf%hXcGWyMxxehX;u;ijsWn!e>2U$j`ZweveahCzqe*JY@0!0u*q5a@pznF zK(oW(RGJbNtig5KrDk+Ry!RPfje$-B@~ZXZjWyUg_rIAilXmnOnclx<5akKijWMD*uW$F`2k>%IzTupM_~_u~1U=`28VsB{`jb zI`Zph+0rJNu6xz2w@@T)Va{{ffiNCG%=Hm}yZ5k2W<|H2#S}|))a68JNQvDV<(KLGY8g83wlvC&G9l_M-z`B{e}1K$^MCw!tRTeaY;Cscbhj zdyy!foRK^$R)QF~*FM__AdCop)jP~+nALR(n~`*_u?I|tRxza-a<`sDyELYnfeEr_ zaP7x5yk<($g1^4dbMAK4^NZYFJUOtRFj49mHMmUbAGVV^<#8qR<%h2y9+|*rqBP)> zajgNCWS3=^#@XE+2F^od>)e?)BUT3>6%^7mt1yqS$!o@kVtKdA0NSw;;85sIfKK)5@dA*ZMB- zs^`uv-m4xwZSVsMJbU=h%Xb}TJkH1nU|CF4-ABYrmW5h*evV`zGje`2$J7}r{M3bH30l9)+S@LZlmzE_0%JNS@MZLL z$W-e+>vB(eOz_UZj8pYqw?7dv&#wKr>vv&U=uOxC!uGVXBWGS@$AGADo;qi2Gx4+{ ziewr*njZuKtNfBGnZL6ot}re+Ep4P=DB?C6hIw~BEWZuob@x}viICYy=DJLrtD@13 z3k%HFGD(Rk@#W%rF5Ae^JS9<+8vpC~YOo5fwQgYjn!U@6y-_Vf(Sv7MvfTMuLT^5t zJoaAs%*l7=;@93Mmxw9w^M85fmKhOWUxCZ!w@z?TkoxZ>Vk!7Srq+}~ljT>4MndqB zJC7i7zl-u7odQYUyPEy-U0=+=8^=B;{#wx!a{ZUO{(8J}v%C^qp-prmeM-7=w>gi+{$27JEo?2;6=qgQht4gPiVihYtSj8klyIL)i`U*_rc-M zFU3^1a{Z3Kvc(R}K!pbjl@){Y{+(YJviwN~CPfnRLS0v!+I|=dR-efyBG2p^pAW2i zX^MIdhUyvqlKu7Ss5PSandFg$3)z|hfv=9(n5dV06}jM~DSFNI2G1zXs4|I?%GJ0b Qi8uK&UB-R;*zLss1AC7lm;e9( literal 0 HcmV?d00001 diff --git a/docs/img/styling/stroke-per-side.webp b/docs/img/styling/stroke-per-side.webp new file mode 100644 index 0000000000000000000000000000000000000000..676be32b83b2257fc9a2261525f9133bd22f7996 GIT binary patch literal 26546 zcmeI(_g527{3v`B6%_;(DN0kSbm^U7p-Ge8iy*xVp@)D-m)-;fq?Z7pw*XP;z4sD& z3ncUq0_5RyzxO`>!9C~xz@D6BH@h>LdF_<u^EJfW{}G(-u`!lm?|U`u*?hZEh9fljQSf zJ199&-l0bgN?gOaE5BD)x1@XA@E2u-#I4(k3h#cDenFO6ml1d~4p`Gy?3os}A^< zdMsy9G&=IYZORGP0Q;Y1Zhm0_SuteuSuXASWA8botXsr&WhIY5aXRiVuMnQqE)BXA>dBXA>dBXA>dBXA>dBXA>dBXA>dBXA?|e?b6` z0q<4faq!dW8Oy)(IbGSkZ-saIfuXYXP1?FiwK!tC4NZF=Zz;9rn&Zl~P98Zvi}j4G zLij~*9FgYi3&HOR*a4WcE^o3LX`BdfcevO`uXLcarHMlyG$V>-byHSE$AzjjcwyQ( zg_Dn=h0jt{Q4+J@J&7zQhCEuYout(NvLQeK=Z}uh9O;~iq0Pl7t^#v?bqLHz$H~Rt zgf8Vtp#?OD!O?QlkyKUX_S{3=mct?Lt`fOWD9IvEA&+pTS4U_3)!1E&F4e*D^!iq& z{JI`d&r6m69bkaiTt)+w7LED2lP8`PxFs`EsTRI0zCGL$Jh^oO1{QeS?~{~N**3J zfnIWhy*}9*=}VMxKP!qET-<#Db~}C&8NvYUH_MpeFMB~(#~sKkUFK_A<88rf#Fkcr-)m}j2n+HY~~sr7^p3PRxnS4{OJUMst6N@%J| z_rcC&f%;$4u`$Yf&teutJzOfyda%&+!zN-W!S9awdD2aYy^ARQminQ_3su*>%GtS4 zF8pT#e{0G2ocYv@w`d{OYk_*(14ceq8QVxQ(!PuFTf%}4EdqrlqV+!?TLO`6T?Nkc zz7CC;`LY}Vh__}JWri3`X+n|-SivENt#Un7+4vbx-MtZ__5%fwD8QWkZp?ac@uGtp z14xABU)GiL&0SEAX@^*xVA3^d!;*C0>ve?q<~7sK8NG3wkRlU%^t_wF zf)$ZSlKP^^B#UFW8vPAc0*9yMdjz^()KEa-g|@Mo9!EJ}X6fh922GHHWIhi$)A7MM zcJ__Ny}Rp@-FWYm z1g`z4z&++rDAIYF^#OroxsmfUPr&<=w_hP=#T}IDw@-n60BW_dPnlZ|kNynURW4k_ z-ZrZ;A-4Z=oC4*?BzRJz)B`w4V%RNC2s$y`0hQ;}sYws+^?0U9J9;NBnoEZ*TukhT z-8RbhpQ2n3m+MXWiMRTbvBU5B^F!vG){^;=jXv^F7Ke=a5vsWR*tx3N^e##RVf&D@ zvV1kP)2%kA2hwptxGLt8d#BY=vX9iI_OmnDySq6`{RAj&Q>V_S>(B3sszHPI)P~6t zWB@Zcp#G;`M(nq+zxM7Ln6a7(2nd>`>lHgZQ>nS_u<=qq&?eshd#m1GZjSx$1aD*^ zsn4HA3OdN| zFs*@X56S2~eSYDk)nw!ccI~&xPm{j5)am#sO6bD0?+%Jg4U$wk5{N43y!6)a&Y&1> z$2k;khjvrxOXX|5_x|}S+qiH=8dzCnOptIqn`&4@AgT(H`C9gHiKw%>wuH|*4e=$| zaJ-A+cZa0WWPAAnp; zQ$z=21=Hzhn>{bcSSNvKvOy5)4A(nvvhXunT17>8V?ML|Vd&IN4Xz=+~8_Yp}7Jbzw^Z zQ7!-O@BBw95t8|GlT+!p+3A>4<`N%k6M#Q8s@z>Pd!fL59uj@$17kS7C9B56IFfwn zj>Q6vFWSlGL{p5GEoVu011PzlVooxYAOA!Q-?MD0E<%6trB~O{M`-~Z^?zVY#zuUY zI~AVjk2%NlX|ijqg_q1~&~h3#qgXcT(->V%sVLqt$9Ip~ywaose)*@af2xUw(0%>x zu=H$Ca!-=`#imkz{QdNnp9*mw-(BxDL_+{oQL68OjNw&1wO=H671-~9Bzj+d*_^vY zmDW7N+Q>+n{pwR%w@!Xluih-B!;|pvquD;JdhU^0=BZo0an=MiT_+ziMWHtcDzUYJ z=pL+3s;p@{klDXZ|KA}j3#Rb_*?i($+gI`bZ8M>+GNR>kzctSk)F7*$Y9@MsbQT?| z6SBwLkTJU`=*1tzqT5+xLCx3CbkF;y>)uOPx_lqd=NUMP^C|BWXl-1Jm^LN1OyEol zh)9$x%%2)^L@btlws$<*Al{q0;&ZjwQ;5qnY(DEj^6I&W!GT~$J7_6g_#Kuop zl4X%tDyI=Gx-1Watm<6HxMDUY)nNB1nb=ya&y@jUx7oGgb zXRx%AhIt0cXR)j5TwKX5RUDu%jqp}0RcaE}tuUWuxSwNh=vs zBfOTX5Yi#q6fs3QrDV8%ZWWV179m!4I|LzQBBkQfW9<5CaS<&uO!#*0xPCRH4wTPqxcmn$XM# z+xhdHJWd1A|Jc))yA^Tvx)-aLAO`1)bvlgWRi4vY68q&4y!_jYsEB8^udtm%FSzYF0B^skq zU4OA=uud~;AKG`iWGJ927En&GmC$c9F-0QP%#>lu)bc>&%kp>zcYT@c zhMneI_vgADHcvW~-iaC~YfHAIl8ig8@KuXu^3{m7&QJ9DlEvBDF^EKCKuTv>VGG3SB^BTS-SVu!!e`XodiEfUr zty7_f$~R*&lY%Ud&`AlIhM@hw7BW_+?JX^w?=w#haS2LFF&R?cW{&U>wQ#~idFOL| zl*N!qeXTp%M-PbRN@gms)U1^#YA8m7=i;g_udY^VhNBO;ZjHDeCoT-K&~vAri<^!0 zTO*sKMe9H*X`eA;yl~HxZ}sVgbOb{K^#?cB8y(QRZYHvOtr-a140 zgh9(;|4SdA$SSL;_RFlHvf4BjrmD_CbFJHl6TcWrqfW{{C2e7u8?GFR)y0yknzBpy zc#9$g<92N(Lt>geHpb8Yt<~0XR+CC$!**4DK>8+_;&dLYXh`zYXCzWPLo*m1*{bi# zjbgy!fr-X88IZeyhEZ%#(D~plY+W{nRaN&9DcP>~^;z%~gl_V9#n9pXYkb22lvSQoEqPl~|UM%}4rBrjuigcJ5GP zAp`3!Wq8F~ZFsU{MQ9MT^56~RI_AwRvKIQ{7DCY6^wkGPJX#fptK7sMMrP_Y{shS zVgS`1dH3@VHv;Q_oNU52rTb6Fd|%9!^Uoe8N51bU`O{!N+2}f+z6d|M`^N0dy`2_s zvhv2%kx~`RZ?*e3US5V&dTV$=eQ{6=!cAe03>S?35ZT5FHtObzU2^y znKrgIcF8y$avYaFwkJ6=*I2-~l={5;NBQN4q?jRC_X!zAV5Z4_7UmKgOcrC11>est z1NfuH5{Bv=@ke*1oVBApxh_jja-q;gWLF`VpU4U%2J{;n3KRBf+J}2;^#W*(;%3rtZ6t)=a@J!zQ$WoTI4-w=jh zt>no{#KF+92JZf^^=zJJe<-~jkW6)!8oB?+BAT(%L3Rp@-R78@~QdY?0ja0 zPxD_9mFb(jh!f`D!mYp)A^idEdNp>h<*E5f!&ZUnQ+2-97I8zcgT6`9`>DQ4C%yW^JyLZADTem;EQzWjwULaZFS{e|afx)^?VXY2 zcNLQ?) zx2!gdto4-@hwRo}CGM^tUrR~f-wt11CW~SWsr5~}qm7V$#1~qN3y}+d)K&bfMW#l9 z#Si}bM*=nd)tlFhUd>aOC2S_EQ7BB>Z;N_DLqP7R3DShM=Y5Tch8HX#RDwN(xvIBd zll(sB;~iWNsjk`-Smc5p$6lv*d7VSebY_1Nn)-7&eE90fVoo2~BC3g?|a@;f`g5qd1#ZDKu6vNm?|+4t}M>-&m#PDpotgAHS00tV$!{^^97 z8v0KH$M0-U`~Lqq@w5B>qx|zQYE%U9HEY37%52J*0O*!<3g5JpH~QXegK@8w_06Ee zO(Mx_3EmucUs|%6IDxU2cskFVjwda$O#$uu`=Rnrq^?}vd$l5m&-XmKpAZEmzDTB} zMETXN4IhpFc(RjY?7bs9HVauhW{G|4kqbm(=BnN@bCDtp^OU$Mb;Sf9)Jp%5$O8P> zQI+okVvK{Ie%lz`cy6)B^+Y;9GG70lK&)k}>DQ+Fz>$qm=6Bbd90CnYk8l=~6S zcdr~A7G3DnwJl=I!_xoE+c+e^Ni)ZJFB&iX7di+dC-@%+5#f9hv#R()MD2$M>3Tt@ z3x*AIkr`;|FCwOd!Th?vT!oHfjZxG>Z0H>&3PrR=bO_QeGwr(USH2?tcq6>y3>?n+xvbwT0?>q}KC+ z7dQU>tyO59&SB@Zj_P>KpSt=r{H1b4EkWl{Navf>XZD7nKR4fP9DWl+sP5Hzd5ye~ zp_YDJ4R5UQ)_xrZrGvV2yon3XAG%yQ6rA-<#_$?@|9!Bm%(7!p=JixWa;4x!e zT^iTZ&-k(__45j+``Sd_z!R&FKuti#tJ*ia6f;oLrz~M|y)$k*6c(J>r^doy+Kn3KGk@>cKEnFb!tjgM^`)2ifYI0Ib*(gx4ZO9 z{ep>IsA~JKG5K_rjXW>UgO20HFN90=0yCsn4yvxB3iA_N1O@edt0(gmUlCjG3=bY7 zO)@;wC!kNmD$6sV562V_#9t{HI~c@E%@&HPv#=wO5{6E8B^oim9!hf8KsI*E8N`J6 z0ewY19t_luCQX$-hc2@n`Fu0BuA39clU)dm9 z!o!?iQb^nv3d=y}PbJ$Hs(L^WKQ*a@AJP9C{i4m!_YkAg3bS{dRmc$5xEr46DdZD& zCoE;7dnu=>)N1}s-DsJ|q+^}#RF$!CEp?1~0yXasdesbW>mdZRahPr3b#QYeC!=b# zyNpkb(q#U#YMY$$-gOy^;qmVG{+~@fr{-q%G#($lD6=-Yu8i~DuzbRE`_-e*h0(|T zJI2qcgu<;lG{0;_laSSOSOkr4y`KSA_x8L6GCPDjgW~nd`;N_@%~0tkRJZh(yinl5 zRJB!MBPQXIlyp zWr=)wl*CNRm@i75R4fei-fBEfI&eHj3Gy4< zwfpL!#Ru}!s@pRVVISh!i|_fm*`KCnt!SfB8b#r#;=wE5yFw8EU15go*ND))Z1x{z z;Voqep?x$MNTILm{k68vP}__YX|j}^-s;Al(!PV$J{@VTq}iTy`ut22G=;Ejcu(3a zwm!+M?V zILQ7d!UH%PoGW8_GUy9*Jy_yP)KRl)-}I{0YHuj9NNDWg7Y@X>**m`LM=WX@M;6u> zuKLc7<`KM;d*xrng`k}9;LHBCN!r3WE;}!IZEKYRuK3rHjXMpsXt^VDI=;;?=;)I zu{1Mq_F&_^;PR&nfzVY}mj*2R#@-TMAEApgb?)=z^~*y^JYStt;kHxDM5KXg1s@eR zuoNSC<`0mypP^G7doT=MADxH~bCeQ9sJ4QEa>0V#rD={GHB7tu>rzsd}1RbBXhyV`ijSq2f!%G$Pknc`de+NzG8E#}1lfzXfy448n z?897c%@JpcFhTWtSTrnnErSa6Lw zU!{pqNg^Bbv*up@;@dyBAH~P%lm}uTiAXoDaIy`6eN6oE{ee@)6BxLD$MgSFIK=5i z+w?M-blsu7w7s|cayvHN=gMikJ$Rz0mI-<}x6;l}zk9V&&g&2b-vivTYHtit6Xv zOXHkZZ))fBv}Sh{T=$Z*{0=Kk0d7k`uPP*`^ZY*HpZZ543*J4RC;^Y5b|U{AH-D0) zedJYZv)!F}*y&uR-|o~9truNurW@{oF5cF0nh>Rr+Y2QH?+4?+{AEUjG0=;h!;&Za zBlB!3UOP}fk3Nw}B3oB;v7&9>KKO3uBS63p4Xk%xE_C5M0gb>e1e0%i>Yaz4>#y`| zRPvpS-=1dl)&!R~n@r1szIi)4HuEjO&P`u)d~Z#2?TxcD3%nST^PKjbbP2qm9eX!n zf6r}AJN|Fl>Hx8z1WP90T@gd^v11vE$Lk`bQ33!XtZ!tS;PWQ6XO{7;X_^D~Y;P&2 zfPceH(h4OMyub55FpJye4w@KsBrtaOoFRKSt8`hssC&q0TkaKbXk+jn z>!hxb8*^l|*w>#-gnVo;=uEG3ZYZ}O3TWtW?!DJ@_mRktv@^XCQ#AG2#ngC63(^O_ zryF`Drr1tHd>t!};k}W6;U@aN=B_(h*S(4^rS(%COx&N(kmcg2$%pbjeHydxidjUHlrq@5I?aK7Vsc$Y;uQnm4Io!uOnEZ-fC(K&` z)Izgp5Kafcpgxt`&xpUct&=OCYOMN=!KqsuTnaCr1sKXT!Tx5e9e1YcK5*V)D3x3w`B~!CV-{7DV&If%>CO>A?`O7W>Vtqj<#YSW!t+lP&tx-VSs6KuK93qkjtwx> zR2T_sNXZ%V_9w*Xd=6}mTbNME5@Bw6YS13z_&My}E7j{O@}t}49M!){G(Q^Cg;eMR z@{`jYKiB1K_e7+#GEd83JXq}GiqaoVW78)VMN!8q>et=rAk81NmE~lJmr~=s zyqKE;iSUS>$HPc3yszg6*S)zgqMvLC?TkXg(e{y@;)vIQ0*7EGHFDw!f>T{7%Nh-F zJ>nlax2Ba&s{TUmg}#Pudzj0}mk4w`KV(Y$3GpoQ-aY?r4jS3bHRkMK)X%8M569m0w=p@9@5L;4%2MT-C=|t>{PAZ6!nsB>+DY zJ!Giwn`3tVu!5y-SPUSSwM&^im03zQmiX0>Vh{SUJbSYPGDqCN;eETi5AE59zLjnf zYHMtCzDb02J zV%DV1<%;Vnm}()-dFCkjqS!kTYuh`U++y};0(3a|x@(6n;~ch* zu7O_v50H7r`rsOB9~*D%x)v;Nj1!srxBH_HjIUR_0Ks=R`o+k=zj5iN*AWvj+aQC` z8GR{K`usY0`QKB4j-Z0;);>{PBr2hT zHZH3!lQWvPFatXz~9%tes*N$`NE2Fz`xT#cXik0dFX zulF)P;ctH_+xQq_<{0-{?DkCS(pRd3Kao@D&` zn4bvyV7piHA9(l7_H=#VmE*G4Qzk%La?CA6DSt|03(_GF0?B*uIF5*kU+dtVDa(W)i z+Z1dkDK>3YsQi4zP)_R6V9`*Z^E0=;@Zs4zUPImAQLA&Tc@fmV>71{ub05BvpSOLo zSGZT0!JWlBr$j**c9QGX+N{NI6m%Ue9uT*T4FvF>ym=SoeQ4g}cuG$0&n6)=p-VHD zN8L5uZ5<(L?CiKg9}=xTl#H3`lyYj4i4<2cLH>jDzZYAN%=BH+)e+pCHm>EtD+v(e+DsHoserWvN9@a4{b2S+LNpaWOy#Y|==s`*D!V0gjC z8SE&lZnHi3dS-V-RKf!LVw(2{eEpXW%Ar^P6M$FaOx;EfQYj9%t`2GrhFQYv&?kT6ZUdz?avvm9Q36@J@Eu?jRC#HH=6G^~k$rC93~I zOL$=N4QC|4?(+|hfAxo{he|4P#W7lo&dZ;PR|hmwOQ$)+7HPNg zAjze1r(>kLF`w{r`=ZsEP5OuWheyh*bsSp6o`$q#JX#`ev`3Wc$6C#WHf$qQ;{U8R z9$J%NKBatI97X5SmC4mPHDi(Yc%PF@mBy=%B6Njh2os6N))zXMM}fvb+xA*&xk`OtMkH%(cm`Ab0HEF>z_F18G|cm}+rdW3vl z(zy7Rf&0zJ1kP5geSuYG9wCq}pR791Hl(rtOQN_Om0@ku^qON(D0g1O`w89W=+c0D zI*r0g=k`t8wJM1e!nXfl%w~mZwdqtggm%too!94ty-9H{CL!xV=!wn zZ5gFy>PSl%R`%W4=G-*kkLovxy;}+tF(NU!(<+xg7xWtcURTF6If5xEW+n=jm#kd* ziOwZJjWSv&hHX|C7!=f?!v?BNNn;M=_?)wxeoi(`{BWljHYs|`4ZHyC{QKaS8KoD? zy}RyIp4!$CbXu*)h+fEd(D)oEXNPyaE0yimmk6mE1jg8V^VfLOJAO6)IK{~Q7H4Tq zoR?_Lo0bD$*rZs!L6iDn#NX^*qXiFvO9foq)l9)!Ef%r$Go)jP< z7{o9D4XRF??P|-uOa#xEwYb;p+^OEPZu)afcRj=zGK-mm2PQg$1SRg`-CBCiVMRmy zbAxWrLTuqO)#?8wTS$C5ZVB}qX4Z5-$c#m4kdH&_3D-rBD3^R0ZvR)P3mMT(jj&mj z;D6)N^rU4#JLuVBXxF&(GnTTGdXGpn$V++ez4~F>%iy9Zre(fbGFe5#*jd?(1O&F= zG`qC^60h#MvnI{brqmPi>9Lv8V_jKuPVyK>OVr@yvrC~*REhsgg7X-+?z|!xhANoJ zGV_GFmnq3CT6PJ47L0g;P#*mS54W^zQCs#wh+pvf_4uU8EvYi$m6&Zrc3&egzw z{i#1eakn!4G0UXzNp6X7C|__a0jtIfW0$RC%Z!vGWIbiAX=7pj)$yNH$`@gQhn=%J z?F2s`k`>~EieX^s+BWy@wa1kc*Vl5hL#Q&`xj$;q9I0?PkYtFS_wzx8X3j|_q0vxj z2ML+0zW7+iC*9I$@0klKqk;L+AwAR5^L%zE-{J)wj&IR8yiY!VT7 zz+k*(oQIWQD^1LS^0PuZul^GVezRw9{rEBlAkyNqbee*y!v@~CW_iOHdMe&KBY?^!sNs6@;UW;3bSU=Jl6wg+o@gUpftF^pvrm-ITy)u!N_3 zA~E`6z5|WlRH%v?%;P>Zp0rEEq$Y(T>*Mkfg?X;`b+-!;DpmETO>nRKFF~$46NYMD zQ(4gvyi3Cb5YXs-oMO_@RmOS!!!kpjTs*#GgUNGN8P3!-Nx)^31hzvelV4OkeQ&h~ z21T%|zN8_?lT|0-@0)LPVzi`8#2)@&cLQGSRePWeadZ>|O4&M7M`wkYAq0)B!fo>I zmYkw_-TG9d-FAdRO_?@9qR?A~Aqw`mg8!8T4Y9i99k$q~f))%uz8Z|*4UF4@c`QJ@ zqJe9AHeBt(0uvnT5vARCes39tk96m?Ce}*3hUuDJ-@L`@J*OV4TlQEU6PB0sVJ!aG zpnH`J2eOF<_AY2o7MP@`3g@5p%9qm^q=qLXq|!Yq+vU!H3TxI#jzAclXXW6xe!J;~ z75tam6jd~(lyCLQ2>&Mg-2kBJ9Qvv^IFDmlXR27h@ML-6zi*BLSqSp+sZJL&4alPq z$8pGSK^sh8Ax#d!@oRSC*$>Pe+*v+C%_q$`Rg;RP!>P*{aqbrJwTP)!Et444JZ=FJ zwVb&t>-F!gg}AXCZ|30l%^%083Jg))x}c@m9W}J2$L2^+`R=5Cp762Hrup>x34S*_ z2@R_|*4A}{a>bKpe|2s-ph~zhHBo(7t8VKd_}qxJl8%t&iPHOUkC+PlRFvnU>)Ku2 z35S+X=f^eq>148zmhx(K!*3+@eaz510@RM)$r+?Gw2U6J*j`3)3MD+CY#YK_J;WNd)tk8WC%4%?pn#t?a5^SYQDHFcbAwOK)WZkIcen@W?-i@ zt;Im=>wD26-&`@DrH1sQ`sWE?zf*A9(PXkqL6)2^GE|4+Ne>sXwuU3!?xiAq9IYOz zW31XN>`W^@Mzpq=x|Q1UEXIRX=4u8lzE5GKNeWI6Tx=eU-pR%WzMDGSF+{ za&+Pw%cnMjl%DrhJFXgU!wYHr`S1BO`)Bu!-76kY#0IT5@$O!aC7Q~LPq0wqZr{$A zWm08glckr3{&t2M`zPzv@2^uq0JT%6#`W)hg3#~H>=;NxlY!G9s3(t(q=wVgQJ$u} z&VJPTadc@Nya#;pE`^53Ubw24N%sfXMzC9`@OsKuZV33+UaDN#Q~I=e zf5sYmZj{4r=hYPe&PU>evKAc)iAZX%hxGK>(6;R0nPfp_&xe)%&5*3!oSA98Z_R}# zQ~0IdJ2Jr(&WH!`7h7GNbA!V6()B5IZ)h$cNfhYmV$~^v5>g!{6Cj06f5(;Upyt%1Hy3HgOqvpP{p6?DWFD(e{%^Uuk6?j#J(qP;UsJ z!1?WeiV@){zdCaw$@i}^yzFUt3lP)r2RClVB#4$C67@AA%N;H{8kQiTDa|V!vH02G zczpltu6>~`aOG3Gx6(G9zJ>I4|DvWKOR=mUs1`o?iSQSu;71sfDhg@vI_S1HkO)>=@NY!rOazVz#7&p=X%12k5q_h{M{cjZTYFd`+%4E z&_)Bm3=p(f!D@Ufqv`NGoF{Et9Hk*RMgcndeO(F7xv@^a`LCvpShQy1g_}WR{R`~Y z3l2|hPJ}0S1s`O|zVEu3mWyjaFP2cTrqD0$n-ih-1T_AVC`@JkGKQ8sWM|Q59anyf zJxWn<47j$pxOjot{)QCUEQj$s_sKcjMz&S~JQpaqeA^}n6fAmHQXB|tF9+&V0MBGT zMPE0V&}&jI{~qChh|ssf+HZ+|YqIPnsIjs&`51)1 zaloak&6tuB-D5NqXDXVKZ|`(Jb(oc5O#GHT2Jh7&8kp-*HZh!R#@Nht8iF?0YWm+C zi{PS>^wq_7JGVYb`FNhZCQEI^z)4Z;jhe`M2AUK9Z6*2$9tsBkej&o3ay1An(_JcauF$3dYN4oIowQ0aLG9OW(UE%uf-h82WEk0waCkeKj`3;!+t7CON zIe&%nHg)7v3OQjOlQF!xCkHKL3zqrr)unH@N_$2MBKEo-Rd6l%ORv&w~NA*LmxiZ*EN#9rhzj`=%j;`B*KkX$>Kal#toe909`OZCR zvO_xP0Wf6g`C=;}3PM9zmT@~Shb_>gHJi3%@q2#g_meMJRH*94`L3hls<;2#5_!zx1XN~CMHEPqk<|Uo)|o6i#GKy|eKDgZP}LuY&NI)`80>@A#qVhXj4A&N2>~V` z+2jw@g&+R?N*A81_=G5|*sP@@>u=wNFEYS)y>`AJ^Ocx3gYIjWx(z{<-}5aT4o@;u zRN@^dq9-#7QB1dadnt*jY_53H8R-~JvgykRH9vcl-N!*Vtx%h>Zc4)S3hf3RUfGK%@uU<>@@M!@CzH1{^D)Rvt)!u& zD$|`7%gheSI~Lr zEgb{#e~jIBURm9Lp#QbulV%reG;2%Bu{EpwJ+~u;)yL?mmqdvVWTfnaQH+nX)Aa~f z`sngN?@Tz>!izQ?XKZJ`d&E8xeEKSiyr#>RQbA(ys!lGRWwKJ0UtOooi;lltQ^>j7 zAHsU2R44lsB1u~gG88r)?Y?hZG4Gmo`(Vn43Zm&PtJChi^Zj?i7Y2pFspF?93Rg^* z3013HmwF5>9dS*UwQM%9$Undx`88_}x$W{#yov3oqlKXfGNf_wv@R}yBV4A@fnHsj z4JPjlajDtJ>XkNsH_RmD{E;DnH~z<{bkU14BvhJv^IxD(7eg=;-xbuJEFNil;-U!?X g+z8wV+z8wV+z8wV{68je0eG*Py^p}lz9#tp0HW3*>i_@% literal 0 HcmV?d00001 diff --git a/docs/img/teams/team-selector-organizations.webp b/docs/img/teams/team-selector-organizations.webp new file mode 100644 index 0000000000000000000000000000000000000000..8996358accfab0af156fcf33017b4b76f8b11a8f GIT binary patch literal 45812 zcmeFYcUMzg)b@)FylvRw76Cf~N<_Mps3?duktQ`#BfS$sLZYI8AfO^3AVj4JAs{96 z5D@_Z2~t8&A|-^H03it>?eM(sIA7rz=ZtaZuZ+D%vey3XYtJ?3wdQ_kVq~;jB_?Kn z_x1z32ij(Tx4#2Wx8Y(3BhG#iwVwM`YeEYW7~c80`zG#`)15oP-)lv=A8W~ioGcRY zB&NXD<)6>BXU;~a%@f1-8(vl!9o5!vwFpjOT86muK90Vr|E~Hoa&PU9LDLsU&fGln z=9|*jPjMH9V5IcgKKgIIiVs4q>F7P(>@Wh5TpQptTU%QzDEAF4AzpRxbh`et@|dpn zyVe88U(~!i+7G~@in6)_lrob{3Xhs9dfor$$XdMey{a#9M=##-6!Xyg zqtKc8bG+7kB}=Q;rHz8TSkhV>))BL>Pj==`s@p`Z+k6>M^f#jNSxvwIZ-oEy{JnSM zalhr_op;}L=W5;ADfYkl`5rP8YIgL#bF+_b%U+W@WAZlPzWe+Eugj+m>n|(bzV%MJ@a}74XmfrSwdaMm)jS0QeMaCfQ4ywl|qiOJEHM>#J? z_ZWF3%*(VrJ$GCx08;eMedIFl_LXDew8Vsrm1*{cOYQXU7uqogJ8W=b}!sNtMUT0;;*|!$?YrK$Y;Myb>;HX<$L+9eZop)LrA_=g3486$Wo8K z$>|3#p1UQBCh!PO9zz(XBo;oR#q>%icrX>JhXFD=yBoX7KcplZV^5kn${0+M>7U~t zZ$c}CzUV?No8U1j>#NLuT?5HIFKkV#anRA5=S=;g)3Zx79~Q1S;hC-b@raefaT#-L zDTBr;zPd^bZzdI=t#7lHowk$;+*i!skSDFP3jSu(O;4X2`gHF2p)TgYNK1?@7PSbL zia#Ao$i!m}SVS0*gk4W-grG27L8w-LRSpYmusN@myYXkC$B1>S(gd^G=tz`yA0|Dt z*m3h%822pWxoX*ty*^&5mg~wl5egce#3RsZa>Jdr0a2 z%WOk;Js;k{$l#zFxbb{WPUX@NN@|bXnm^x#LF}z ztJ3@a#|?DmJM5VGUAvR}9ZXMjU}6^1tG9I^DeARE2<++O^JX6dM~IeM6fS{Q?o1YT z>6JEB*X4R@jp)u45tMU`bHFzSi!J3aLv}6BzaEmdcogAZ8T|E`c)XA}Q*lAdS1eAx z3?8t&Sd!nk`b$?e@D}p zS%AZW7aQ7W4YfhM{)9l0xyHdKSwvVo8s&io6YL}?=*tkFpeE8+bP9)DV=FFI(4 z(7R^j`Pv3f1(pNN>cs{zEL1k5Z>wv@((g{)CGX+&GoHymip7MlGe=O4L6VLnCj)%b z2HYD)ixd!ccVKYBq}Kj=Uh12&$ffF6*$-t(2H5flT6}8EmUk(N%Drp3wak)-7?iu} z-;GfN=t^V!YgLz47zY}g>XsG`++1ml4+35jAXtlvr<`&(>7HKI$^Ql+uFccQ<;7~O zYU3UrQKQmI%fJ>3EgB5f*=DyC@O_+F9&K0|sJ)O)olL^vQimUX(kCsN7Nj?)v+a1wo!bP+X>C>(HU&GtNz)N71?a&?P?2XT>n{t5fY z?vsbeK{ZIVi7Zwqaqm}KNG2Uw6WN}gTQ$63Y|ln$Q&2o=;V-$kz+Gzte^w+bNKyT= zxO!}3%!YrHU|`C($|;FvIHvy^QOrb!X1In>J;Mbxp9k}Z2)Caco(uBlagr00gsWB!|jqo=>$G*4D&K6Z6B*2ifHC=CtrQUY2EmSM5Gu%Zql1L3@uueE(U!kqs>slV zFKo}Lidt`cG^6-xHOhkjH&U*)vDY3C(kpe<&*isT()ArDnhmT+uJnLO@${n^{C01HoLaRLHa#kqffg0Eo;{j?GnSZsPcD)+sAd(Pd)%$-Eif)hZF z?|Bim=Fe1+r0``dCl^vgU0Nb+-Fx`P07=3Sj9Bfnh~`ZhBPVa3lWAi1ycz;-DyIhZ zMhn+RAgAncOqonXlwU`Mt2lP4xK9ZlLWe&rxt>)L^t+=aFq92kr z|F$MnqtOg9x1AGo6&k@BK_S`nVdGT(dCKq?<(*oy;VU%_x_DS{{q)$;r=Ag_we}iT zwY@8U1YzFu3e-Ho2p6JbB}J@KpCuX55(BTjMa@K>%2N^a$cw79Y}so|5P45uD`{upp}+p`dKIx%x4m8kLx#Dbb|G7X5J|9JOPyBex%u}bF6t*Q)27-BcZUF+1-%ygB{S8x0P<`FBFe^1XRU)Br=iB1? zg+2;3-BKckhg*k?MtsrG7rC zlQGe+GhV5tRdBi6*X3`oR-Nm(V}RbsSJk<%Qv?O%CI-G%p}G2oxJWal2s=>-M`lH4 zXxp`EY^mp}$zSruTA#U$?b9l&7#p%(x>5jzDQMF`T?H#TW=qn%_Zw9KpY4}~l7=J{ z(0LVi^_o|cK=&|S#~|^(<+4;UJN6|&limXoFA1fex~$}n(w~I4=)uGMXZc5zU}>2y za$obvOa+vBxmMNjLCTheee2ht+FB4{gx-Rn{EeDiSxE;--GsE>52Lbgf1Vw?<0}5d zTQjpN8$J=dT%cUFNFRu3CronKS}h-EHj4tW}JwdT5Z4q~i_y_{7dmZH}W>V}Fmw&Tre9YQL z8Ro-zjw*}H)D$1Y9S)U2qv26eMU08$=d#&q>EMlf4-SQTog@NReOm9w$F@1z9_`bg)t|Vk-Vru z)l>tDxJyT#QmgCU{1WG#lzqQ_)Oy8cA($Cl0E@3`I!Pz-`<1Wy9ZRnp-{v}e#}u7g zl1^{V=i20*(Cbr7vT&Wb)HvJl?zsGs*6v-Qi(C9Gv}7PxkhvD(xHB!Kk+{D&Ip^RX z(|6{+jnFqo3)QWBWG-t27JcviVgVW&9oM#x{7=Ru2h^9w2K>87HRyv~9rW!^;A#FBLN_BuOoHe@mI&4I$UJaZX^ROI_1=NpuFq#i^^cjNVA{~> z9LuVyMh~amxu<`ElT=ag62ISW6DTJ*Z@3W6^TKnNK~&rfg5QhZbOb^lpy`2; zUDle8G{C*igkqTUt?^<~y z`1nk(`v65$TmIKk27m~$ot6SU5iF!Kni0*g{3CtwD&tOq0j(C+vJ-%eglohX6lp!` z*Q2P#3~2kqOFg1LTuBHqy=)+R_LmA`Sg+5S6YRg7+xb>m2>B>~F_6m4nBZv{g*|r6 zj_2^F&GWMy?zv4fm!kL&8*GgPI=KW zilFJ6t0;75xERavMc`&D71=n6WP_RHhFcl(`;ED@}L@P0P1xlWM&YLAk?4Rq+> zJ7n=Oo3~RZTDnd){%G9tY8yeY=f=Dvc~vlk-{NL>AxbmFX^hdaxYpfNwL)` z(#z=hno-w{c82U4Qc#|Z#Am!5Lfc%di@A+T`_}3=(cxI%T6;-ViSK`8AYWqk8-C^11u*n=iLhv`=$vD?!gc z7phwOfbakUb+j!A&d6&Uag2=9&JC@`3i|mk`dY42N%BL^`lXbtn=4%%9MFlGmPm3H zd?-|Jca1u83;xBwCNzTa)xT)z@mx>EdV^?H0xj3FMXflg;G)~eV0P)s!B>~lGOYy% z(s5mBX)#1P4!JI{0VoF>*mN8Yfk*|+hWb;li!IN1ICiynQ@K(8!O-UvX_z!ZGQ4DDHJIjM04yK-!O_xBgXxd0Gi z$A`n^V9WD-QYw-!0l$H!X`9V5Ld`9)^y%KFl~JzF1-$6N6Hlt(56}= zIsHqeP*Hi2!RFd(Y&r@90R5jj`k0Ta4utnICmz_~JldlFw(!H&UtNbpPjo|lR-b%b zUS0`C_Dg&HljkdI)X3%Ysu>fo*%OaV_z}|Q0@N%h4;mFO0d;Z1{#Wa2TFX)hF%~4 zqEjq>{Il6SqC;@q?dFkvB|~T(2kD5F*gfsxUO`id-zt2~(PoEy_xKFAv^HQ&mI*P? zw|+a@5+6LcNPgMp3iXN!WiI9Z?Ei6sA~Nw<%GXlS3YSILN;?owPJM4j(-+3iz2CRT zb~+#@MjhXz$^TUD+uKcI)K*IZT>%eJ+)-0$yBE7ak+g~1OXYKWDkyt`*XFSI<^(^6 zwDlw<^3a3CfqgjI;_-t2F~TJBOfzV3KR1qZ)z0D22Sk@*D61thC$ibe} z%3bj|qxiPy=Gb*AG~lBgCt~KdlZK~sfz2C;2QgM)8M8Aa$7*h8!g|2J(UYKGm&M$b zE+u74_5kCf4{zO5@k!;@)yA}t#V_Ly?Fo;2;Fxg!=#HP)Uc}0j zJfB*!`J(po!7p9YR6ne1*6(%u@bt+O`)Ey*W0R;Rud z7=iB{YR-JzGVp9XQ>qABT4c?!PzN+5UH1wgYzc_5@_Ul4MXM*RSBe!q7_|g_!uRAPT zQN7er2Mg+bZj^d~sr>8L+tH_GS5j4v)x3Cf=<{Ax?ng2~mTm!2Xj10L=lWf9KlXk| z2D@bYlf36nxayD98@Z%h7llYh=K)sdW&wHL=Xv+ecdfTv3Z%)MYd3JWljXqPNVfY; z=1BOE_)jHPAwdNk`iX} zt0bZG5hs9M@8`{ah;+NY(c2T_6MB3=e;o*GfhL_&E0{|(OOyv;mJ8yQ5)a5c`tcRU z!<}pI`}M4w85VU>_-}S^g7<|A*;-$wH-xIT) ziU0;rBJ3iIXHyy%4*%@Wk+(?3u`Ve%*}tDiluLpuk3S9-yC*b+ApOPrLx`a0sAjBt7IRikbl zSOsejy7HQmjpGw1<2ej*)}zQ9xhB!obaHM(T|XndiZzz}tPb%AJL)@@)4a^(k7*f1 z1!1eF{il9n#`e2jQGs(Zl+NSgtQAhe%+tGH8JkpC6brr{`den7Z&-0UU?T6=XXi*B z8&|FKAVLSSWCgpcP~&pwOhW8T6K+n~ZI!(_SV7IHAU80gQOkTa!%!5H`;^UDOqOFCoez~buT_z{eeWP$gl-=Ih|pSK5RxC#v4S#lsquqM zl@0b+mr52lBdgYllU7=1fBfNB)X9BX3|%0^&NKiUaKT}B4;5qZlS5I$QA}$Q>BwVp zhtjo0&&#S5vv8U*F=dD6yXz;6I$dgGqYp^9eP0-Q%D$!a0;XWy@RlES;hL)xJ&@cSKIh|loz&NE z<~jDMtga%ax#X*+%|s9&060{JB^pY8qu%rMP+(cwRRmr%5KfvHAI0bqd*7IB$v8T! z(4P>)(F>2^8xE6@m4fh!vzapa#*mnj9hu`84I^7pMF%=wX3$FKkAqh31wtHJDfh~q z!+vezcSGlDe6C&l>wc!+;F0yIv|Zc|c00NC@N;c#`gKZJ3=$*Et1b+QwRn&^b>@ui z;_rrc2ceBZf<&JUHB>xg^&P9f3W8jB54%q6d1|F#n1PD<-RM5|6IWo59z;0y#ae29 zLTS}mMjn*832tam{I_hGD31!5buYS~OB^JQ+h>l0owRHwlw*_cUezF^%qd$kgwv$H z&SzOcd~>T6{VVsaQ3AL+=)eIk)|0g8%kWPQixdKUih}44hD@=RvRKdL@fBpG{PwlYA)@uo+9F;GJ_9rd;qh>6RBBWAsbFcA&iCAV#ahuqWVAlwP%&ouF+Nw`PgnYku83P` zCDUfRVmC;Sk`o@x6?Bl2O`OK+7t(N@XsP9c2SE+^uW1pkHgEnX@;AE$&6%dne9rY2 zv@U-Oxm*EKSIGKp1$2v()lkZiGvK&FCbo7PE#rn@F^W8SY(Tls(tr;UjHaO%ZY9E1 z9i??m(;X`&^|NCd5gmgp;0l{<2Fb9%p8VM#e7NdxueENFhOsel0?zZZd`PsbAg?wL zrVoMwhu)`X_J%_ajobhzX@IL^CoB!J%U{MmwJE`V(a??p&n+}TPd=fpw~oiQpD8-h z)R^`Rp|;jo>?d}kr%xVVi9a3biW#Z!jPM%*L5E((*s*G( zy7zHcvQxu{?a;1hD)BwFbiwL|Vs>#Y0?~w9CEnNE$;j=kK48txTpJBOpK}?%y-PyO zH|KA$(q2~BT@%oA<9}RP?HS=I58I_Ks_y&W=@|HLl>Z3)N8mpK{}K3)z<&h(Bk&)A z|GyDfMK<6Llj)-8tKHpFwu3mck}Jx-X(uU;@8!+)qsBE&JfMJ+%G5ONIrRzy#iQ0F z&x@W_RfP}Di_*h2RxYQnN1uIjI%}bDcG3PuW?&I`SPdKUGp=)uX?zjJ$P9q%M^@VY zT@qS@&53aE0of{3J9`W{fDGa+7=;ym}8z=gDG{xP-Oc)v1fV=Ugq}E?>x-9XJf!a}JNz{Yv zW$W@~)CO#G=pLD?-%Hu7dvQVj&iY(Xz-GKKaGk(Vgb9=OH2VTT8~cxGA!)sR&7AQb zzrMwjgEocBx|e%g(KhCxK9Q8}(bXnLz+h40gRHK!lsbv*AgT-xyY5FNyZM_$$2hX& z3{=PE_+!HyVfPNmn3i2vmgT){JeN_UzgbO{6a`L|vrRLK(}`HjAbz{01k)aVG*Zb( z9tDI<^2Y^JJri!Lz0Cz93;qRN1;Y;I>gJNQy$7y36L}{1P^Cl8|K96p#!f6u^&ZiG z90i|h9~b52Coeg zKH)I=y6ERJC0%SUnV3ea}c9>c9~c${MxMiGUj#rZGJ}@Qrly#cf7SV9R`F8 z#+$@m6tHsf55A?|S2hGfgP0hu9ZJw$gq!JQ>5W}>y|FS$-nURQ@^GmrHe6@--Ct&l zZu$NgcEx5Dey(*ho^e9n8IXb;l?@%TZ`hn|8|1mKWsutx<-^&5sdF^x2AJS^VTA!D zi>`-0tx`@?!Rw(4{G4#&LP{M;(3*RVlN!rY|@Hc4%!q?iQfS)U>> zofz3Hh??mC`Bnyl^LVmv?buE8a>T`HI=xf0+MF*b;^Oec2R_SB%rrB_4L>tB{lCw9 znuf8}nt=7ZH6WkD5t;2)uvE7g{h0elyR@_}(gM}{gfpCH>5bYNqJ;{x2SYc8ta^U8 zH!B-6Ko;>cZwhq+1XP(K#@E|n`jkJ~QN?ya1~P}H|AOTKd7(2$#17>}{b6j*NYR_& z*^W^DeD{Mk!rrY2Eqr8+3|{zGt8NiiXB_)Tm;0Fi`hJtU^Ie5;>i5HU2#yvXRf&`{N> z`Fu%Tdux+u`WhxC{cUL%w+^^j*COPDj6y;i?i&X6{2=@&tJDp^Q%)aBF-Dux#KiN= z4$}SmnulGuzB+G{14vH)LdO1aR9%j{`~cig7aAy9ad-PWJ~>+tUtC z>&Bq+7uNA0*)hO|e(n~gVa%D;^golx=m110F>ADCe=hF~L;Dfs7-x`NC3ZL}+aZH-Ah`yy9N4h5P zjo38KJc(e!mn({;@OAirpy^FP``i6M*urA)#M<|onI);_L~5ASEBj2bJ4c@T_1)kU z2f?jiOT+AL`<^6Dh7sMh^R2G4fd!@Hczlk!wM=ULe#22c4bEmRWh5v(dT{{Atx z$*2^j`gRr?IS(<C!c)u|m}0i&Y$5ppQS+B@ z?t$j1D=34B(RF)4vDXpPoUsy<* zsw$JuxZP9Vgw|eAfB(9xgZ<=EjkIuPN8hse3{mP~PppNXnZx6r))GOD% zoq~<1466X0!b71O^{0>C{!&skvP?k>x3s4{?1H7Enyz|&Yq@d5V*x|KAoi$bkas?pZi=;mRfW5)d$uRDTk_pJUsX-t zx!2_Qs#=~Na0t93k|2wf3rx8;eqNT};yrifG-5Gkh>!A(D+w!X%Xx;yq^r&>h-b%rIqI_kC`AgUXAR(q)1kjiqBQo)FTWjgdILa$dOl&zrE5meihed&X~-G zHoVc7_A6m0FP$)vyj8~P_w&yvIoGpGeLnP>D$zF~9WL3ka_`W{i5U-$vPaugr|DhV z-96ilBoit|MlS4QSUqCTwT&`Ivj2+d;7)Vf8g@?gXA*H#tHAJ-vP@=UQce zSMd5PcGQm)7YhIHV3&_3QXMhjrfc{9VVjm#)3}0MmAghM&nw#*dah|ugp`8RnFOpgr-*T#=Tc>XvG#qq3Xa-o!CxN;kCU@!B-DM9MzeMK#^b zsPhmC04ghN{P!E8Ha7}uP3F=QmI96M#B*z=xK zK`(qt{C$e_vS_wrf& zjWsriqx+S{V4^o6DZ0*0XD2k4x_kG$vBZg@M!@)$7q>JYmrgkNv2{>EF~Y_9lKhOU zB&a>xZsiIUx(_N%$n48&u}0T{hGMlpV8wIoDdS{rJZ0y)e(uaxYzLtsKaRdvY<&+} zve;}+T%S^A4#=Ls*!D6TsL>N+H=h+5nTy}=F16lebWE0*g$TEHd$Z6tHy2 zCO5Ly)4HE(}U>G6^PyQl5w)Mv)s`8P|4VmQM|pb=%O7t3#d_hn{FzW4h+ zyCh**&}Qsiqxo{vrN|hr;pKN*u zCS7#}wWU_DXaQyJqM;<3UF6#1cV0fBdAAXu{C#H*(5&f{o`;hYzn{jR^!x6?@CTA` z2lR&I_WbzcQd_@E@3`;F9M)6eW}e@KM)k^OYtB_`Ec=4XNEVb)kly&~6z@FFaO~m1 zEVp<6UZRHKOfcOa7H!Ur*l8CZH+8qtM4>jzDytuE%qkvf8ub;@ksoov5h4K))=S_Z zxD%q;k_o)u_&V*!;WwAp(UEvjVJ4nW7|`WVMAZ)2RrdJppD7Jhpe+jmEhG*6Ir$*NTQ#wR9j6k6?A>%$F4Z>xJ9Y0-c*I&u$fcji zFb@AlO&(Nfl+9GoJ+zdQ*bSw!OIYr<(c~)4WVx7k%}m(;l(n9jHFcVilnEqsqkR}V zL1|waF~A2#>ue1iv&D-59#T$wniluoopvuX$em9wRM?19DX*g}*w@KkJ>V;EzI4^u zg#uDP<@{&KQa?smOdK=be&x(L<)+`wJay7gvm55t7zJgzWnP z0|WV9O=${5COVDWMtVEemZTfso+c-mywE14kg26xu~Lp2tRKC8g8@fi<1JsW$0N0( zK_Tj(pK`l`}`uPkQOShgGR|p(;Va#}q)_S{ku@d+`tB(0efR$A zjiD>Dk9MCOqISKy`@u=6gF3Zg(pc}j!5=IV13upE)b(z*C<_(QT?>2=X|3eg;F z&xudVt!;sJEvLG|tLtL?PTk}_X3yaWi;S4nR`PhUDE%knb$bg?-0`^k`#X%x#3Y5W z(i&}LQ$P1#-xROfzwbVlxFL8LGI%-Z?dM|qfU`SI_gp$&>s$|YcKCSGZ*cwaUOz~MVCk;{aEm6HA&kV(tl#>2P=12c z@?;wFNd6al_lmEcLVn*ja3}as>|`U`aY^7{&SQ335(>sPliPcgBw|jH=AywYYo5F0Z;)-_iA~Z} zAbbc+9|4>?)mwK`MRU2F#3l~?%5gGGt$57g3lvJjbY5Qcd7@qn@(H5g;2l0%{oST) zaK%JVM@`O}FRUlr&v$}6(?6cp?$;rOO&(->gU!Q`+r~@1aZoM_WK-KnjqNfe45ax= zQPZny3r6+qYf|GX;Nf0F6Toudn6!0`lXvR&-dO!pZAyW(8?x!eByoUjDjb2S z+L9OcH&>{{oSTw;_(Il1Z>QW3E5(`w)E&XmyMDKP6gjFd`c_5HkkqS@Z!Sp`t62UI z+gmi^I(sb77e`r`KGB|ZkpXla&6UTsK!{}7PCP+CR}y=w-$f!fqyQ)#=96*KaEE}1l>s2<&H(URUlty3WydQU z8f+33l$A>YGb~r9BX)Ty$vafGm32!TQxHr@C5PeIsj(XiC`nA5&)L zS)MEP7*eCy#ztz2nSbB!3*^A9*N4wnLJk=2L&<~X3-4MdXx#nPcf3zRG|wcei58HX zjPy#43hxd%fRGvjX5%-N*NooMQWuM0C+O>vzp97M$u>?l>V*c(UvV&Sj!{?Rhu1@@n}cKae7(OU z7*yF#<Ri_irT@_~=I9Bx`k?c^3;|E#oFQh{%TV8vOxL)D-e`fdXDo zqyD0IKZl-E8YG;qrXW9FK-8;k)Yr`sFvwmlM=kMw&hk2t;c5n-nFRow)?PC+DJ_B@ z#-Ns^sR0d1dPe{F7?7V=3j&i}NbPQ4Xg{0$d$ zy-&hc|7c%3s|v%5u-IDlGR8T#p~_A?A7SZHl7VdfrO&QeXWC70aYz zv|uLD(>)`u8Kk1A;HC96g(8yx(4Wvez2jR+m>jb9Mgem}2#FMK)B$h}Th+R!1RFk% zO)$(kc?##R6DU`=L@=EzZ)O1HgAkSxv#AIulmg`TF@W_B?9c$3h}V=Oq~ft73MHPWp_Kv|ZG!RJ}d8%j|e-yXsc?7?t?9{zJm~5VcNS&5^$5C-ml! zTF4trwmC283LYxDb|k(}dtWaaP#f9#C+o$!3C8kaNv}$+y+kP6o5wO%_?SAZLUEcb z!M;`*me!5%_=;>AYs7}KTpI;448S;2Fy2d_tI&6i)YJ@RrdP$jZK0lgTIGMnPGx^u z($30rM!xcHVGj`>k7pe|cjL_Arl*U(;4j!po{>*IEBsry9#$BPL(bc7bgKLsuQPy2 z+;nk$Zw}E_%7tW1x3DVVx@V-8U%qPV`JfRI_tpf%;g2~5k)x-203f}^A&tQSyLtJo z(iZkaj%h~UF#UIMt&WN2NTl8AzJUqN!cfQt60Tia9X8p>8OSv@M4>36Pm>NYnpska z;J0SK2cU)S#z(8S5=y2j+6WGEhIJu_ZUwp^8smnC(L`<-u*Htt@+8{;4k@C9>%B`l z5=SpmPG=h69@^{pJQ|NiM1EqH-HsuT5rLa@RX^TiAPgUnuII7ulJyyt(nMAQq2GRP`blNR@d`TVtT@PM;ZcJt;`Vd=ujm6tsV zIVoT<|CQz^AmoyajK_C&7Ksue-nZ2>vae*nBg_#uuu|CC8z$C?I+Wo`A*-Ew&i^Vcz9TMbv zPWwHY&Qnj@8>ZDP?Uoq!U{l5MpqR*o-=WblqbPnRg_jD7F_51k6nu12Qt)2r^nu>a zJN?J_zLUaZixTbS0$ZzXXy$<~Ew`KeoufCtW7YI*Uhnj?GeA7{Jvutk!u#&q;2op* zHu-bm`ZQNY!U&<0w`=}4o+TD_&0@!>K94^|G}I%x^!&h%9FjwXNquiiJ0eyx(Di=8 z={rN^($fTfeKVor0`fX+KU19|!dHL@8o^F%2ZNzFfm3 zr&aLXR-WS!3vd8ViwTxAmJz;0uKmcw1Ef@J{~z|=E2yb2e*3k9f;6cD(tDHML7GUf z(jn4CdM8vBqzVW~Z_+zRuL;tnMnW@GDWQcVLg*pz=J(E=nR9n$&ei*$*Nfcj?98nF ztl2ATt?#ow&)DZ0DsytkOw(9jmEIcT&0CLOb7|IqNh=Ke#P@m&eZw7w5xQBT*Sfiq zRSY`0;F6pVpAC5?RCX~f6m)rdvUc9BSdi@+-1#>%qC@1UBjP>#>@IBnF(ugdyv3 zQqD(@>EooN&*LV383F|yHa+sn?LDiWHIi$EF79%90+k~7{ESIZz3rk1bohvKy*05X z%00^!JSP)gEx9&bw~-HTF1k5mqQ@@gOQKf84@L`PId3@&t-|=Qg*Us>=rv%I_jkMX zJ)xO6xr#?JyjYQ^r;Jd060{v;WGsx7_-1D^gg?zOMMUM4oD z+#r~Caoc^QHdxyMSYo$Af%Fbe7C213VeoVBCIL7+Mh*$!_J2J9Sp#@zBK2E zLtQcRT@dz?KvM7--at(vck%~EKlAsK!NWf#h`?TOaFC_YEz^9VyK7-b(ti?3!I+&8 z&%0Wl*XL9FRDTHYgT644x>v2^3DJp=4}WQz)r`6J2C8PsK=MEPuoOcySjPn`6SY0|T*O>L%tF z1G~z>eN%I)z5MKA@4GxMQY-1hz9bK$J%kB8B3I3(fnU;(B%hU}@QR5j z1^$Xu3=;E+vF4RJy(kvf!7uGg6g#9jTJS6SUlWrqQp}k}I}++}5r(^TM7KI6MifYr zQX~K)Z9*|eYuCLDW88;}e|pIDzHzhxDy6cH9tfI)o+u?W66>$Fh5xHX>s zW?@Py>rlnG72At&iiU9&;pslfR9a*aiFMdTB71nvtN7R%4W-#gl^il?u5eY(>9MjCA3|p0RZ{k#|;hn_$u~K`lmzj^Xmt8tcLV{ zFJ_K6)n)NjmdUrTNAAP^oB$?IE-o7sOx;R@%{_*GJyz)Q*@4d>(W^iSe!qg(nw)8X zV5gJ9YlqE>Ufk4b$sf(-&Y-jQGI;GlZlAj8f0gkpND42!MmEH=H1RYcSohYxglA|Q zW^!$Hyh)9QKYJn^-kGR)F~3A$N`UrUPL~D>tk{;h_1`h6tJVcE6+MPvep!fbg@S>Q)Y7IrARou(n>~ zarRN+%iVjoTIQP9u3Q`BPlbAAF8B5(Z#Ho@nG*a{o>n;zxmuM7klo>j5GW8?783DS zQQ;g>cym;EwWogLiavxX<<(y-@**!1)J^5A8QW#`SEP07J)P*fss}qmsZZ11&lkJ$D) zpHgjpPq#?8P}MsoK~NJWK;cQAz#VS-M1a>*fpU6Rt>(PdPv&&x1rNOW znLh10vUcSeem$8Lc*@T1U43~(RcZJ!C- ziS~sV?%~S{4d6!k2-o`%i0S)P+dk-yK_>$rzUX>Lv+1!Kx#8{fVLX{}Wj@$(8tZF%b%dgdz`WhYN?s!Y=WOehHtpcR z5zm{GD_2j5*~M6ScDhjLMRGy!jrxM0V17I9?yUOCtx^RyeNIanfdLCj7QpKf>zfOP zqu0cY_)|dPuAA$v7`-V?8QfKXs3(KFyxTz?K0uczYi- z;yROE2QzS~t@20)tk~d=%B(&!tOo#(b+GB@k(NSJ#BBg#!08-ZVdgF%v?=PaW@)p% zp=GX6?hF63w3_7G8H!o;yE~>=Cux8i*M`SS_&>~>&61d#Jt1^n0Uh?3S)n%6&waDX z69>kVd=xJyu@T{m$hAw!-s^@3$D|qj;9#-}SMbr{ud0d?=hkQSR^YdC?y9s$CjM4! z<-0z-(SqY?ysfLb$zg>8nUH+A+G7>4|9+^-FENH&{lAdh4_v!Xj)Hxl)4zYtgDLUP zDCZhv0NrJ}*%M@hSmIB1{No_1UMb`2!T1WWWGjH$j z^yX9tAwcG8C86kdnCsw6Dtkwev%YJZ|H9r z78QbyIy_B~Sp@~s6K?!T)GH6g(hDWeCr{>2Cf=?rtV=Xg{J5ylA$`S16Q(!e(wcl~zX}j-=osKUGIUQqfO8v2&NF!v~(NIbc^(YtOnA2O^ zo$)}qGf6Vmj^D=8W0A6+`5~bUEtf>5ZKe$TrJ6LkGb!Hmne74!+%9JyrhG812wsJV zihyuM;vFLD=e(8AO+dzGCbgpq%z+h%~EMBorjO*I!}O*Y|Cd)he9 z?Y&Y=&)E@-4mZ-&zF?FHTGT?|8R1((F@(z zBcRLSfeOKT=61W?MLh(p7?J|%#PDqQSU^;|F=HzP^<@TlmnR;j)~X^WN7skh$!v6j zZx%|9-0tyix~1J6!jx=XzwyBm9^d-X52lmSL4Y>_wKe&d3BJ+vp2q%J~yu22;CZ=y$sLEf-Nqbb*agmD0SCp#NTJKpvPk`kcC6O==% zQR93f(&r{AR{1gYC|ALkPs7RY{z|On{-0*R{@31r3j9;xp923B_@}@>1^y}UPl5ll z75E!*y)}<(zwtpta|~Uiqt1_pyj^Uvsk6-v_@Fv!j_{5yFR>PJCpC>3Z;ivDVC;q_ zmkuZ89@)&`M+FyY@M5CQSYfN~YVK?yJ&7A1bfW|ehT_IwCa?+^MyvAb@aOLnA6{vm zfvYwFk7JxaBZgjvslzvFS}}fiYzb`%2_G7$MmgBQa!xnYTG^7@=XIwVo7Q*yYrG)eO6^M=>=d;NFwuS zb65qg_Eec2RhQSA`G`=N?9ra876m=jCTur=9sYa1WDKgNC84is95~-43~wvljTNRg zy5&u$ETX|2J@))zHnX;!i}5oR4Z_!x2eWV-Yq5d?rD+D11!uQNcZ)D?h)kL15&5Tk zpH>?U<;QrN+ZRzfCi zUR;9a>w?b-cf+84e&V0A-VWZwi*mM>Zq=oqZFKiO0? zmnT1CTkPD$6*ZICW#3GWA!(Yl@CIg;DtWVR7P%noJ@q-59|mWigEVg`R3 z%fK@H$+l6hF{ka^Kqq%k61n^dx!y&@b#k!346@=RFZz?r4u6e1W1w|IblhfRdV@#a z%=P&NL0uw!bIh}!RvH2HHj}p@g;pPfz656W8l+G0WRa{TegK3L=7iQxs@OI?1r0RoU z=>sMqfTY`kgs!YBD0zthzu`Q)x6G(lQpDbVWu=SOm$%9GY@ zCCylPsb`7UeDBpor~{inY4Bt6w4`J3HgoI5OmDf#1KPVa9PFk2?&@s|GM%W;Eqq6jsFDetndFJV6QGZXi};HPvf ze??siF9VjzW%(eEw_b(;dSaX4Znf&ii`YeH_s9?7D?_iycX$EVH;~0vSsTvWh~xZr z(t2?m+J-4bJOYCwC1;mBS7ssrk)Lt zZ{E3_2BNg?H%h#1Ion^Um>1$Guv*1Vq6tp$RP}LZ?X^N+FX;45d9x;nn~zcMHvGByn09w;VGr6hdR7)vP3ZTG{YG0;vqHd_pUKfJ0f z=1`Zs1^i*Bc9J?``oVb5Vd~&TNPB_NU?=~>i+ESka_x-qjT*6Rb%zE@oU#wTD5A6nAa|O8Sg}X?#weL;%B&vaR zx!);PWeD~v>c;x9uf?3z9VVPQ4yM;(>9MUdpd(vfEdj6LV z#xj#Q#)+XfV5Rx8+4Go8V8d$EK_o%M&NIv~sOQ9@w^h<`es|kK(X=)>#0PZ6pC5WN zLAdBzm)Yyx;cZWy73_sSg_TmF38cLvqCo*68jKf-6^4G-mlg|~THrCXGqp{GrX)%~ zUB0-4aAr2G>E!DrvHg5xJi&8eCFVBt0MV+pYl`Uo>K7up@w@qhG4JO){qM;#@1>f3 zRw5&d;?H0g5Pn$l#rg<1hXHr9NI(4vnKwrTIB1Z~EVE9vBKc!VGz$&N00s$}GEWp? zZeZMQdR51{-F^ZMfs(eb=~4PPWd6VkJE7F@u0DO)a@n^{ZoLwqFxT*VvBRlF9t+yB zlxSd79gV+ST&#U8m6IEK!=LiF(7W8UCiyaZfLmuE4dO33=pcpRC!+TeNHexInoGy~$~~_rgp;W>Sd)2x>N$Q<4s)f1uSJA)2FdPWKQ*x; z$z(VqtwX=zP<17Y1V++od3dxFX4{qR8R*hEbH%7$&fKqr8z1;_bBgl!O&-8xKZKa- zb#}HM3_+E?*KT!XYpo2LO3p{%aK?zzORu7%>7`Z-w#v|T6}mT%8MT_n*ZcY9yTY*p zW#9e4Zxt9ZE%E<*3+`xot8sX>vs*76&;<`n+--2a2=zQKkqm(pun@1j#w<0^EO^|! ztwh+(QqjFcJEj-5<^UAvmkW>E%Pu}%0&8P!Q%#0jc+sTsbF1d5+A;$0>pYE6``HE^ z*EyCJr=rqQ{>Pixy^+kiBn`HHDyr9k-?Myx@lxtwp9}K8DAdzu;D8`c(Sg33n_96> zo--yz|3;F7Lg3+e?uaKtz0=SyT&0D&>N!Jx)frk*qUTPJH^Rk)dj(H4WXOrM+xKCI z;dY(45UBO_mn7Ds4o6y$ORbxO%}=s?K9Ib%j(|(qvO44l3Jq!uq?%5Js12C-nf;n) z#p2kzTb5lP!k-4jEu1v3&0-4~D{7J#yRWbIcgtXw*U4v_(|^A#>uadTyvsg7YRHux zbg)+ywuHYPG_GZO$b#aR+_9!(ID_#GC`%fQ2DaxkS1e+aUnV@GsV`t8URCt{uE?`( zY>DL?x@xfk?AJaTy3!0fL!@oiH!J|?s-@p1Y1ui)#?=;ZSF_O;ypE32H(-Ap*{SF9 zzT4_*>=Way&ZCs5y zV5MpP8s5x3AF!x^4V@lm!iVAw+@~3JmCU)tyIgbDGc==fX+d^3$~B#Us*A1b_aWmU z>Rh0vV40{XGZG!;km?@pC#jj2Y`&Z-YQv#eG)c@m=c$*I+qT@pYi}Ri7tRYkfBRPm zEDO;MdG%4Qywnk>_GS9U9Y2(NgLZ?~W==6FA6&fsn|(cal1JE6hJpyai+acsc*g8x z-iSPB(<&%%u(Y#?>hbgoEU?Qux-tZ6a=&DETr=d3b2K8C7YTd;&%J6j&qFJPXHdR3XM zK)o}x^&{jVHJ45xm$%^y;`??Y>@2uQj0z#%99hJwN~|`V8WJWH=PmrC|H!9{c|3fc z>8JJ`C63b?nu<)!Op}jctjl_c8n;452&#a+JTj35=xgY*)X69SLA%xT@Fq$IaO)Q z8R#2WB+(5z(V${|KqH`Hb&#x^rCpHe9c?gEt7QV_GNyG@d{I z&e@RXX=2m;yc#r@xKML?f+MUBnbO|gpXH}r4Uj={blvT()b-Fv6QYa(08Jt;mO>`I zrKrcq(W|iIH|VRi32|bmr-Y#HH`h*o{7y-8|Bg*>2XJ-%g~=Oh?9kt=k~mS`I#uxo zvtFN_i}~nr&T>1fP!UnMfF}xE*Y#i!Pq6^~|jJb$q`bI!Q>z9G%_|CRNW| z0iI3;^#JvS?wFUS{<3;h<=uv=nN#HbYv}t^`NpnCzvg4B;TR6x1E;0cR{K-w2fo5y)tIg zYit}L{h8qYtlHUU#FPrq@z|GM^aVc0c%hx|Z3E+kr%8fhm%>xBucl+r<;oJVj@k;l z9G=vm(UJU(qKozN?3QxEd{CAZOmkMoZep->uXg%u+G>bfOn?exxBNs!a%-NEOd%8S z#+sTfzdV~&$@${>&v#62fTw`{k$><9#$EbnD$GQP-Prifz&6VC zOfU0Ev^osD8~3tPw#CROKgpoAPIgv| zVRuG>E}Y_2d&n0Geq%#yJzkeki%Q5yi2c!Ja-5Cr!GU}&_e^V3C7GOqPkf7Nzgx~;;-B^50m?R#SnP`NWKbCY;$H)Vuz+k0P? zBPpM@-h<*MU|Bl%juQDPY4irmscRlCyqrpi2A)3?wvvKVw}&UL#+fpqSFNm?7o`;@ zb;riuFO<~7=aGfLuT%={?kJ6WHAhf&8|=d8hw$b#thnSazNMxL&4AfY8}8N7=RF#3u(3Q!Ev{o>{s|jD#Rq z&-xf8){dSUOmEc+!8zyZ(6mB3)5=K;ZstlZUgS}z2p@LcsHnXXjS@Puzww@*J&0yu zS2ll*c8dQcQi+siL@dbEb^6R!ct3lRmNxdwXQwMNWxY|Gh?HvG=&NTnt=NSP`L!v9 z5CYqsxagT7OTU!=oFY8~!-64z*O|D~E!04{T0ZYbcAp>=tve6iJg3GPkt6^7KKUy_9#A{;p>?mk z4GZr451qmMYxX|{{weTJf&WVt2)Nm)Tf!B#dwks(+qx>%^nKN2ZNo}kr{-@e(alO9 zCW{Be3h3s5O_orA@HR0WuFNU+Mm65yF@5e-qtrCd*6l=T z(KuIP_n4k3rAp@4K-qZJ*fNbg=>lZMP2}j+=-f<1E^bkDr!YjANl;?HTXTfV`eL_m zgvce5=Jjct7DSLKPMF%hJTLa}U~D>xRLdubJ=-8rKk85E00gV*iAqm6s8FJ7Ucdspk%{Y;Z`peksvi=37A zESriq?!*(f`}b&Ye`@)9B7G7`8dD-^+?G4LL+sQ(s6@OV(*O=pxftq(&F-Bvw_jp* zOaXE};H!31R3hCD{bm+; zTAjc*No5`|eRY>jivU9-@eCB`?Xu~GN1~zU_m(UZ;&?!a;_(VRo_YB!xd!R6pPcLt z>ImAuuI<~dIzeh}qZ)oTCGa`-GA1*Awao0D-s<9oE&nM=d^>51kC@u0u`ff1q0>W8 z-ekjl_L)&r$nZk^DP65G2`USZ#+`VT{qy0zjOkw^k)M=WH?Py3v~t45%%mOPA(Bo> z3(%m;KA4la#h2=2CY}Fkc-DF*l-j2i6f5I&Q;9iF2}!EQ(Xgj#R=GBc;>_;?<9-D*-eHq^0=Nh(gw3V-T2 zzVy~dH(GEpFE+NgM&9bPSdJCm=Ru^q`DaRlwI&~==p6v>(JNW8X^Vgdt-a~+z`Sgk zgdYhATE6IN(+rt98ofekpR7N45iq~K&tb#__POyJ*j`l2ePVDsz@W=ZKQ6wLub%L} z01rHy9r@bN$i*<#?@arF>dxevjaOqwgjD$ShPr4HfZRF&zL_1yvyrUy~AYUL$6a0f$!gkT`wsHcR09j)|#4* zcqX2Lpkpq+ouQ8Dhz08&ku~e<>w&ZGar?FmNQ6Yd6?7FWq(JmQcgdpDVLz?vOmF^R zYKQRqc#&)Hk*4v4b#*S)Yh(4;I$&3c*i*`)h&sAQw$Efg`K2Btt6+)0euYpKDTPHa zXvUO^itxm=6>vC)D68;`szF({;p0N_^*!NF-NiZ;b`URc6Z6HRM;ysq4GF_jtHGWy zaLIa}MCLew)Rmu2n0YYgU5$OJ zadZG#X@X{m!mq@aY2)N->eMb`owFsmFB}_$n=a3LQU2|9dVeA@Jj~E-AM1PE6-U}5 z1q}5b{GUU_BSM`$XHJ8S-&=c(Pxxn$0y?^B$bA>W4TP#IjRVb1Jmf5CVW9;Y13!lY zSb5r#IE6ZnuE%E5sa9GO2Mki{aB*qQsARNUj(?D-4R2E6s>iN4McVac-{r3=oN=IZ zCLghg65%DuORhZe2SRI>vsL~!$0k$dcO|LISKK-6{QHNkCP8R0l}G5dM&pHRa%N#- z;_uM1tv985om3C9nb}QZ$Gx1yL~lm>Yoc|pap$?)>qWv5 znX!wdKvEjd<=;I(BoxzA>nnCJ%}2htFpgn+w)Po%0y4Xt_&a?SGCAo?lqqMu=PPry z1lJ_r8hOF|z<_q^wi6RBiPYXYy8L0RA$J z)bloB66k|6dL{vvLB@Psdxc%onvGFG$Dir4u{{$q2#r7?o(shhZ9uCsbBx-*ZWW3U zev(0!gW&SCalhDvINviMBNtVsRS{ztBv!hOEiddf$(P^U%p8-+f1an)4Q%mu?mB~6 zyo4F2KB}0>Y#QS-%?#U2^Or1~k_~ZzZot3#fwSflM&- zYd|CKoS!%l+TQk4=(r#YvJ)ez?YX2pH)wwJr_T~TX3DoIA^5oW5J$7EzNGtr0=An( zLq8Bd-!*6HgMYO^F(&c;^a<17-7I==zm~~fEVrC*^|=aMl`6dd{_A(})vrlAcn#Go zMDBV>69HPG3iZOU{9QV{_Z_uZM^M4_;W9gfv_1nNL4AmwbN|!l$Ndz=HVO`|_&W)m znn_$w%%7wo5^qgYnp{ceGDR$s>h3NBmaJENr5TM3%hNPncJqIM7b4!FOJ?}&ot{ZN zuGo($8K`KJfk;S2^x-5#~;fm*LwGc9$tqudY&K8! zO@gZ5VLlHZy1MpKby~&zQNqkl%My(!eYXAkq9kM7-w*T6|NaxDuWR8oekYeZ1cDJa zILrx4A`QKu>7}^tS%Ge*xZs@!v!q}6_bo$wkpn4UqshmaTCIu5=}P?gG>2569O460~cuLPlu#{4x_-v{@+g zQF>I5x0rY+D%?JzwyjGxeE;qEsk5^)o08xA8ZJ8ZuUZb{p1Q=5SEtR*DHcPWlXb_( z%{oOQ-%g7~bp}4ljcvSNOfW0Z%?hXNwJPj9#K_uFM1O(rD;^7X&VXx^UUY_{?F#;^+84OBVS`(*dxXjkYq``k#)9<3EAB{KOp1 z{bV0$2fh)sCUet$pnV`;g{_|-u zq;Bff6J&oWRer3*|B37#l%1RF#d~}Q@tgMdXm914@%wZpT|b+h+~1!{(5@_1p?*QE zLJ>t<>Qc;ZqbXYaib6A$J&lGjdqPSt#zOAYu2IS>SzS*=kk&O*otMsBrb_jhBL#c# zYpBv#hT$TA6bl1QLWUe_?4AZ;8uM__bn<9I+=cGYmxnI3+|8zer5gMhhu^v)SfW;D zA`{D69A*)pHI<1e4$U0w2s!Lxhc5|$!nk$p+4JQ(Sh^Y8^_B$_hepvZVST=p_Vzd4{{ z`ey0*&tS4gO8f(4gRh7QDH>JzAIGtCe)Ri{g*rsET1fAL>xam`+uaLlY8C0R(|5XQ-3FgEaCkCXNPa^-$qEm%`F7fxnR ztBn7Q4Qs-rtBkdqps5|K>BA&I*UGP?u2gWF44zm=d(bXzS!bR)n83Kv$%q~1O{Ox) zNT|dfh4(X3d^C)$Z~;>AVMJ0g^UGS~bu4CD*dB63+Gv$f50VKLe;Kr+8^-g$qS>i2 z4E1IPP9NJ08^jwZ9zGTx-!d0;wjuq zk5+u!yVVp^&8AW}!q@gZkpS{ljWn1)oxr3^K-nlz-pH8Xb|vAst=h%4H67 z7}ezoRajMDC_mlMsMI$}3XZG2C8F`F!i9I(j67{lQ@xYop7O*0ob32n0aQafhFO)I ze2|&bQTS^VZB&ye&(f#wgoms=$-)I+|7#A+e3tj9r_RJr*AR+#aM#TgEI{M@2tV?h z%Ovi7iBigVPAL4EHu407?$%tbaXCAr#4IvZ~D0b{K7RV<~PirGQu1?M8*zu z)*k%wkdh_VfY_EymCRn>IC$s7D>s3W33d$*N6rRg2Mr7DPgzMyl&Y#w?bt^IK$2+W z?wg3`Yc2s&mC04*w!c1w5vI^mH;U`XrhSQ5X5o~`gf?lA4-yt@tY$dHi*S$W{~S@v zHfgI+b55anNEm4h^-R_viz?C{p?%3x^&*4aj*e5!l49^Txm{Mp#~_1%a!1~ByVN-0 z3}=wt$LExzWc^vCkyF3)##CQ9fB2#spTRyHM=k_?aqw!~FNK*{__L!7XOY+Qk)02J z8O0Bp+eR#=j$qDcB8qzkU!q7p(i*7|vk4cM#E!BLVkmxYIfn2&+5A8duTy#JTb5zEIf+{*k9J`iRzvpEa|eEqIy;!CZ1F|15n{2V`+4t9`@A2Dl|u{`;7FGW>m zn0c7_Q6#fwg6i0di4iqAUQq|>NZP0@4LfHFJ7Da6TgvD!+;(QX1FZLyttj=&DZE8o z=&1XdV;woa(m|ET%F@VGZOP*I*j0btVG%4$XnQi`mp z7(Dwsvf8(&mGlvFN_a4rfszuoQ8@EnUJXX zP7l-tj>G@=C5g_Q_IpDhVD|!1^DunxvUFmPTOX@@Z&e_(keqPk=d-eUH0RYIR9wP*g% z#b#;kmnGmM!lJ~Gm>`!Ty~}&^3u&g+gw`C+J#>iAgA+?a-o0) z2HkX0&W4qS*~z@YniYW=0;44u9=DrEL$JUk6B2P;3kB+I`i&ot&bD3Uo<+~E<1^-A zDAgOEX8eXp+eS8ZZzy6hU-RDgn~UR%3pK|YEo%3yuU=Mky`Ug-_&2?hrC)C$1+MjL zAvjk{q#J$Z#P9R|xe%AO1)>A_X97Nz)8;=_zSx>_`*w5%BjM}j6`f%z40yhZG&M5m zPGIOFt;Abh^Rb0I?Z<1N;CI+nEzd$^NU#fTHF)iJ%6vmmPw)m}EyTZVv8#DwFL7%D zoc?y3t#J7>m?CIr>x&qT&TR`>ps+t`^zZNO)nQG~v$v%SUhn*tErBON zzJU&&xFA~@VF%BvxP^Dp4fnv9v1aFT0$0(pvKH~G6VT61_y)6k!KwBhzvNnXa1-J! ztL33~=X-)R9=Qrz!=?u6>%La?egt~-04Z7A&DbeKvWgD`Ya0l_f{-h?IB+@< z$3<-*L?L3q4N=+-GJZI6`zPQ~8~BtH-g1)JJ&m|;;>J%2FkaoPigt2!&l$UoY&B_t z3<$^tiEA0QHwWxPA*=4rlOwL}VA@LLPCjz`K9hUW65TVYz~#2^i_fF$(Yiex=M(5uFAyA~|z3pb@_S_VrZ-Bwq4PellwQH{pIZhWrfZ1SA-5=4)Mw?|wW80CD>LEERp zw@HsqfE{jlepU^WF1Yh99+X00SeC}q&U9Drc>BmNM@lZxm56lY(cgX4Yq@|3`kB*( z5@fc|7YV~dZRz)*=#vkZC6YE;rgpq(GtYSlGX1{wHo)6HXyV^3O7&BwZR2roV6ZZE zs#2-?R-IOzc7mS4_oIau3#JS7aTk$+9))5B67jM$4LKjrw1z6=%$8_SH|N7cVTncd z3&7+kk1Dg{37;;}I2xT)e+?M9@_c0KTh(U)=^I__)Le6UKHk9vt5Q=?yVLFW9p6O~ zPbhiRu}?(Dhi)L)yY1H@qXZs|xf-?tw+2J0$$VUh$a!io!WiC2EW`ey?h*2_$}P5T_qoI@tKWN+Hew_MwWI4_a1E$f3-VO7RKoLF{cxfl(A7 z`QKRo|1N<%2)#Wix;O9)#%r}*ngL-oP0BkX}UzXuYoJ_U5< zKTV0DD7vK)*T13^M$!FHwBuD$$FDO){VFs>3YkklFX`0%&{s?&?qgVv-u_Ku38j!} zf?NzMfrJsz!Jr^aEXb!$Qp!bKyPamTjry~?5vW%7Cr8>Srkj8W-IkjiqnQ26sjS83 zyD)VGnm`oGXZ;iUw!V2GoO=&1hZb9R2%38_-z?o6OFm5FY|QJjgPXoqq9k8NdFUs7nywTW^w`|mEAuRI zEz}0R+k}%zwAjdX%>ueW{7l`48vAu7y&lUz_+E=c_*PBpICMo-eh|7%poPVR5OHLx z>@dx~Y?T37*LGcQDw=NIzTLe}uX&1YAKE)wJnUY81V;q> zD%%SgjA~(gZdx&?1|SC!$`5)c`_kWEXZp4mC=^~!rr9v{`UCyB3?S2iS#{mG^{u|L z>dj8ADZvMNG-W3xoe3jM1s+ycp`msmXUF;JVQOKRbs0^#)7*He1SvKC29B1nTr!uSg3`14M5& z{=3(JF1W|b)_bvGUQe8)Tp&P#NypTBYQ?9g9liMX$Fv&%R|E|y)KyZq{(z$f)JlXE zZHiO2OIYn=*)%?TF!}6&p7`!tA)qBH5H; zr4a`Jh3Yo>eycfllze-4xbDm*q6hmX6g8Dgljo0ziWh-i$6IJ(+#WwHsAL2ew;gXXHorI}M61N>&qPuGzgSfaRTuYV_Gnngg%>`rL#ubo^B zIHJNqBzQk)rw%Lhl%yiFp%)n5p7s_F3dk56QF*+e5RUfiJaP!17@^PY#r4rge){9q zcEcnjbOo!p-_IRv%Na;YN}GG1g~2cIjh;}vQ6`tP)uH~@o#>+GCdMVKKk@%Z;#&gh zZo%_iz2C5V&x2CgHGu&tDqvtaXvdBi>k8ac6HnPT=vZxSK515)oOgIFpR_Bf7=+vy z)PuULc|1m!cQ-Ie0EnFrUv72RIJvGfe`#<}kF?P-Zd=@XoH&55vP7}4HwE_=GqJbk z7zowBpj4odposmzRg)QVg^HZX$EIbqceRHg$IN$B>|NE%iWd1MeN`-Bxu~V^7KNmr zWtw&3t&P*_j>Q=}e@c zGisa@9{(~Zbw3YC$b+)f_$ zlyw`4H>}65LPpmMpu1>H3 z^u)rm_jjPV`OFxeZAm>J&`vVk#E&=iSQg`WM}V>A3A@R8h_MI z{hE}<92b{$wl8p7U=k-^fhg(EHntO!3l42@(tVe8@|=hK!zjDPDEqLM8tV*n*;C4b zX-~D*zjQ{Q9t>vk#qrBYOB%eNpdf!5^j69onC`g-vjkm_ zjkYFyn8UOM`<|w6V>2;6De+U-FgSEap>T8}1F7MPf|_I0!uIR}|LjQeIYDH3cLXAb z5*EsBtoGd3asalQT7?2_{0DmG6M>!rT;TKNtWR7>c14hz1^2ik>rPE5aa%KPF8I&!nHdV28=`7ya3H~k%I z*v-9G2=Y)ADwIXEw#}obciuSz_{|q{!Y)a^@U}Yfo9bQ6@x3#-+t4C+mc61P2xxk@66F=)B0;5HIBK*!DRV{Ji((>5mgpt@c=_uKjK z0d=MWJ^7A2m7C%GiXXm_z&DsV0zKm$JhqV5TgoUL3%apFi7}CyJ}$ouYwbEBU_$0E zWhC_P{7~phy>n(AcdOh+(0205{*MdYGsej~dRA*#1GdB;VtPn9e*{?&)_5>%elI3wR;OSwLxq#xF zY3I^@%nXg*2Le9Wv=qmqGsLyH+AgQIZ7Rimtb;yzSDMaS{r}>p*f}1ZhgSTr0DA(2 z{nat_FGAPZEc+LsI)*|dB?|g{`~70*YCCoCE{Z}WB?@`th&&v)3oUv861@wpfJuo0 z-ofA^8Igy4ckvb~DN*mBa_%Z)c z!>;(TxFa{fAw>|kN2~=Qs*54>AHjw0EutI^9&m63+hu1hM8tg-6!T`6dPE1CaI7@P zk-cUf*}uxHWk6BG6cUJp1OMzfj*id;2VzJnD2d`Rrb8%NnBp-a6&$oXaz6%y5D_P&uC4sW&z@%6)EsM~SIA}V;bEX7h zD6}VGKvwoHRL0m6LOO)vgszMcO^Nm-1Oy`CfcwZdN9f1wWKSs>GKu9e_AX*F=m;Sl zLh->=8e>_6wh;D|lI%45`ABvUKnTPg zR)M&ul=LcMWuZLAL=;eL@HG{*h0u~{Pr`xOvUUdy+0RF0zNv^+AcjH!At0Fu1tLP> zKn12mcnXf}R0AjF?sC%8X7kKFTn8Z#0|)_8o&9_yF8$zea6#FD7zzOap>XgYBw{K! zyou3+rbDjV>>ql>bl{Y0xk$Yhk@B2(B literal 0 HcmV?d00001 diff --git a/docs/img/teams/team-selector.webp b/docs/img/teams/team-selector.webp deleted file mode 100644 index e7af5e15230ed97876c661985cf071419ef783a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1256 zcmVEqoN{}m`Jb^ z32AQOb^#2AkDLCE|3K}Jz8@!_@6>npo<}~-{*TztV=|>{W|SXY^_nONqA zs!{5$Lv3q*<*+|HjRC{LQ&E6%8!zE^+7#*B(qjQr20$d9BQv|D26o`rg;hBqjj%fn z;S4c$YiXz`7r5>qAs!{S}9N0cK4~>K4VEEWeZ~*?( zO{JMomL!B&MM!ChJ%5yR36myFnKER_lO{}=GGxhapv*Koh7+Yfg0w<+|jr zr4ZBv3Br_*C*P(@m)$zz>VVr!bdi1+Nu?4f^1|%T|8y&Om8Yx;8~w=ZXRf^vg^r&t z*^ElIWUax>vHJ-@$;*NnhpvI`_P+Abc3cME%%%&>?5cRr z&mwq-t)5aB&)TC#dHqysI%NyeFsz~!fn0zE>YR5YvThjOvH+KN4SPGDdaT8DQG~=U zgoO|}FNkQ)Z_+1eRb{B#h;wHdEhJD!S5#aJjT0f<1~vHNivA6Fl7DH~ua=w(ec!`h z>*@95utmUwYSVP^8Y;b%H4rDz)I-|pTWcidh#{9Wk)08(k?qf$_qu7&*wfFj>1{cf z+yCJu=1y7tU2>iow&sv?M?52D{T37rd9XsI);do6t-r_QmN^c?AIBm zb_X}GF-aj*Bs9OQ(NxoSvs{O#*etkL%`UdN&$}lsy*ALRS+JK>T#}k3S+eq{Rqebwz}qe+?BmR`6+eX?c@QyaiZn{#{C2eaXgUL(;^uD{68>P;aU?^ zphnG1iE2Ae#&=GF2<1%w#;2O8u;)>^Jb$k1P#A6F`)ilIuTF{Izmth7&jTY--v9T( zzZow2dbCSiI|_dCX!{IpcdaMWobz;#C)1?Bm<8HI@4jdXN%_X5gUd<S#La%cgWve$b=oM)|Rk7EA>5y{cm6r*u!>xkgCK_ SbkqQuaS(t20000000016eRK5y diff --git a/docs/user-guide/account-teams/teams.njk b/docs/user-guide/account-teams/teams.njk index 64c39376d3..ea8b721bb4 100644 --- a/docs/user-guide/account-teams/teams.njk +++ b/docs/user-guide/account-teams/teams.njk @@ -14,18 +14,24 @@ member is allowed to do depends on their permissions.

At Penpot you can create as many teams as you need and be invited to teams owned by others. Learn how to manage them.

Select team

-

At the top left of the dashboard you can find the team selector.

+

At the top left of the dashboard you can find the team selector. It shows the current team and, if the team belongs to an organization, the organization name.

+

If you belong to an organization, the menu has two columns:

+
    +
  • Organizations: the organizations you belong to, plus Other teams for teams outside any organization. A check mark shows where you are.
  • +
  • Teams: the teams of the organization you point at, with Personal Projects at the end. Click a team to go to it. Ctrl/⌘ + click opens it in a new tab.
  • +
+

Clicking an organization only shows its teams. You stay where you are until you pick a team. If you do not belong to any organization, the menu shows a single list of teams.

"Personal Projects" is the name of your personal space at Penpot. It is like any other team but in which no members can be invited so that you will always have your own private dashboard. Create or join other teams to collaborate with other Penpot users.

-
Teams selector
+
Team selector with the Organizations and Teams columns

Create teams

-

To create a new team go to the bottom of the team selector and press "+ Create new team". Then you will be asked to enter a team name and that's it. Once a new team is created you are able to invite new team members.

+

To create a new team go to the bottom of the team selector and press "Create new team". If the menu shows organizations, the team is created in the organization you are looking at, or outside any organization if Other teams is selected. Then you will be asked to enter a team name and that's it. Once a new team is created you are able to invite new team members.

Teams selector

Delete and leave teams

All members can leave the team anytime from the same menu.

-

Only the team owner can delete a team. The option can be found at the team menu (the three dots at the right side). When deleting a team all projects and files belonging to it will be permanently deleted.

+

Only the team owner can delete a team. The option can be found at the team menu (the three dots next to the team selector), together with members, invitations, settings and leaving the team. When deleting a team all projects and files belonging to it will be permanently deleted.

Teams selector

Team settings

diff --git a/docs/user-guide/account-teams/your-account.njk b/docs/user-guide/account-teams/your-account.njk index db8c5161e4..3267ac5886 100644 --- a/docs/user-guide/account-teams/your-account.njk +++ b/docs/user-guide/account-teams/your-account.njk @@ -18,11 +18,18 @@ desc: Learn how to import and export files in Penpot, the free, open-source desi

Password #

-

If you want to change your password to a new one, this can be done in the Password section.

+

If you want to change your password to a new one, this can be done in the Password section. Penpot sends you an email each time your password is changed, set or reset.

Penpot's password
+

Sessions and sign-in protection + # +

+

Your session ends after a period without activity, and also after a maximum time since you logged in, even if you keep working. When that happens, Penpot asks you to log in again.

+

After several failed login attempts in a short time, your account can be locked for some minutes. Penpot shows how long to wait. Resetting your password unlocks it.

+

On self-hosted installations, the administrator sets these limits. See Session expiration and Account lockout in the Technical Guide.

+

Notifications #

diff --git a/docs/user-guide/design-systems/design-tokens.njk b/docs/user-guide/design-systems/design-tokens.njk index 53f58cdb99..1ceb140067 100644 --- a/docs/user-guide/design-systems/design-tokens.njk +++ b/docs/user-guide/design-systems/design-tokens.njk @@ -19,6 +19,8 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG

Penpot Design Tokens adhere to the Design Tokens Format Module and its definitions, a draft by the W3C DTCG. Penpot ensures compatibility across various disciplines, tools, and technologies by following the most standardized approach available for design tokens.

Tokens can be exported from Penpot or integrated into other tools directly, without conversion. Additionally, the knowledge gained from using Design Tokens in Penpot remains valuable, regardless of whether you continue using Penpot or a different tool or technology.

+

Tokens live in a file. To share them between files, keep them in a shared library and set that library as the tokens source of the other files. See Use tokens from a shared library.

+

Creating a token

You can create reusable and semantic tokens to be referenced in your designs at the Tokens panel. In this panel, you’ll find all the available types of tokens in Penpot arranged alphabetically, with existing tokens being shown at the top of the list.

@@ -194,6 +196,7 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
  • Clicking on a color token will apply it to the element as a fill by default.
  • Right-clicking on a color token, allows you to select whether it should be applied as a fill or stroke color.
  • +

    You can also use a color token for the canvas background. With nothing selected, click the swatch in the Canvas background section of the Design tab, switch the color picker to Color tokens and pick a token. The background then follows the token, for example when you switch between a light and a dark theme. Use Detach token to keep the color as a plain value.

    Tokens color
    @@ -343,6 +346,8 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG

    Stroke Width

    The stroke width token, also known as the border width token, defines the thickness of a stroke around a design element. It can be applied to boards, groups, rectangles and text elements.

    +

    On rectangles and boards you can apply it to a single side. Right-click the token and choose All, Top, Right, Bottom or Left. Dimension tokens offer the same options under Stroke Width. You can also pick a token in each side field of the stroke per side controls.

    +

    Tokens apply only to the first stroke of a layer. Applying a token to one side requires WebGL rendering.

    Number token

    @@ -677,7 +682,7 @@ ExtraBold Italic

    Importing and Exporting Tokens

    You can export Tokens from Penpot and import them from your computer to a Penpot file. Tokens can be imported from the Tools option at the bottom of the Tokens tab.

    -

    You can also import tokens from a connected shared library using the Libraries modal. That import uses the library's full token definition (sets and themes included) and replaces the tokens already in your file.

    +

    To use the tokens of a shared library without copying them, set the library as the file's tokens source. See Use tokens from a shared library. Import is not available while a library is the tokens source.

    The Import functionality allows you to upload and replace the global token set using a single file or a folder with multiple files in it.

    These features support JSON files formatted according to specific guidelines and preserve the ability to undo changes if needed.

    diff --git a/docs/user-guide/design-systems/libraries.njk b/docs/user-guide/design-systems/libraries.njk index 9c60b52918..d4b66e692e 100644 --- a/docs/user-guide/design-systems/libraries.njk +++ b/docs/user-guide/design-systems/libraries.njk @@ -71,22 +71,30 @@ desc: Use Penpot's libraries for reusable design elements! Learn to create, mana Open libraries
    -

    Import tokens from a connected library

    -

    If a shared library you have connected includes design tokens, you can copy them into the current file from the Libraries modal.

    -

    The Import tokens button appears only next to connected libraries that define tokens. Libraries without tokens do not show this control.

    +

    Use tokens from a shared library

    +

    A file can use the design tokens of a connected shared library instead of its own. This keeps one source of truth: tokens are defined once in the library, and every file that uses it gets the same tokens, sets and themes.

    +

    A file uses one tokens source at a time: its own tokens or the tokens of one connected library.

      -
    1. Open the Libraries panel from the assets tab.
    2. -
    3. Under Libraries in this file, find the connected library that includes tokens and click Import tokens (beside the disconnect control).
    4. -
    5. Review the confirmation dialog. Importing brings in the library's full token data, including sets and themes when the library defines them.
    6. -
    7. Click Import tokens to apply, or Cancel to go back without changes.
    8. +
    9. Connect the library to the file (see Connect shared libraries).
    10. +
    11. Open the Libraries panel from the assets tab. The This file tab lists the file library and the connected libraries, with how many tokens, sets and themes each one has.
    12. +
    13. Click Set as tokens source next to the library. The current source is labelled Tokens source.
    -

    Importing tokens from a library replaces all tokens, sets and themes in the current file. Export your tokens first (from the Tokens tab → Tools → Export) if you need to keep a backup.

    -

    This is separate from connecting the library for components, colors and typographies. You can use shared assets without importing tokens, and import tokens only when you want the file's token setup to match the library.

    - Import tokens button in the Libraries modal -
    The Import tokens button on a connected library that includes tokens.
    -
    -
    - Import tokens from library confirmation dialog -
    Confirm before importing. All current tokens, sets and themes in this file will be replaced.
    + This file tab of the Libraries modal with a connected library set as tokens source
    +

    The button only appears for libraries that have tokens of their own. A library that already uses tokens from another library cannot be a tokens source.

    +

    You can also change the source from the top of the Tokens tab, which shows Source: followed by This file or the library name. In the Assets tab, a tokens icon marks the library that is the current source.

    + +
    What you can do with library tokens
    +
      +
    • Apply them to layers, as with local tokens.
    • +
    • Activate themes and sets. The active themes and sets are saved in each file, so two files can use the same library with different themes. When you set a library as source, the file starts with the themes and sets active in the library.
    • +
    • Edit them in the library. From a file that uses library tokens you cannot create, edit, rename or delete tokens, sets or themes. Use the open-in-new-tab button next to the source name, or Edit themes in the theme menu, to open the library on its Tokens tab.
    • +
    + +
    How changes reach your file
    +

    Token changes in the library reach the files that use it right away. You do not need to accept them in Updates, which only lists components, colors and typographies. When sets or themes are added, removed or renamed in the library, Penpot shows a notice in the file.

    +

    If a token applied in your file is renamed or deleted in the library, the layer keeps its value but the token appears as missing. Apply the new token again.

    + +
    Switching back to the file's own tokens
    +

    Click Set as tokens source on the File library row. The file's own tokens were kept while the library was the source, so they come back. Tokens applied from the library appear as missing unless the file has tokens with the same names. Disconnecting a library that is the tokens source also switches the file back to its own tokens.

    diff --git a/docs/user-guide/designing/color-stroke.njk b/docs/user-guide/designing/color-stroke.njk index bd1cab3000..de69f59b8f 100644 --- a/docs/user-guide/designing/color-stroke.njk +++ b/docs/user-guide/designing/color-stroke.njk @@ -122,6 +122,21 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien Multiple strokes
    +

    Stroke per side

    +

    Rectangles and boards can have a different stroke width on each side. Use it for dividers, tab underlines or accent borders without adding extra layers.

    +
      +
    1. Select a rectangle or a board with a stroke.
    2. +
    3. Click the Stroke per side button at the end of the stroke row.
    4. +
    5. Set the width of each side: Top, Right, Bottom and Left. Use 0 to hide a side.
    6. +
    +
    + Stroke section with Stroke per side enabled and a different width on each side +
    +

    When the sides have different widths, the main width field shows Mixed. Typing a value in it sets all four sides to that value. Color, position and style are shared by all sides.

    +

    Each stroke of a layer has its own per-side widths. You can also edit them on a multiple selection of rectangles and boards.

    +

    You can apply stroke width tokens to one side or to all of them. In the Inspect tab, per-side widths appear as CSS logical properties (border-block-start-width, border-inline-end-width, border-block-end-width and border-inline-start-width).

    +

    Stroke per side requires WebGL rendering. With the classic renderer the button is disabled and only the top width is drawn.

    +

    Stroke to path

    A path is the underlying geometry: a sequence of points and segments (straight lines or Bezier curves). A stroke is the visible line drawn along that path, with properties such as width, color, opacity, dashes, caps, and joins.

    Stroke to path turns the visible outline of a stroke into a separate, editable path. You can then reshape or style that outline independently of the original path. The new shape is usually closed. That matters for SVG, boolean operations, and editing the outline as geometry.

    diff --git a/docs/user-guide/designing/layers.njk b/docs/user-guide/designing/layers.njk index b46bc11f90..9aca80ced0 100644 --- a/docs/user-guide/designing/layers.njk +++ b/docs/user-guide/designing/layers.njk @@ -22,7 +22,7 @@ are shown by default at the Curves (freehand)

    The curve tool allows a path to be created directly in a freehand mode. Select the curve tool from the free-draw flyout in the toolbar or press Shift/⇧ + C.

    -

    The path created will contain a lot of points, but it is edited the same way as any other curve.

    +

    When you release the mouse, Penpot turns your stroke into a few smooth curves. Sharp turns stay as corners, so a zigzag keeps its points. The result is a regular path that you can edit like any other.

    Paths (bezier)

    A path is composed of two or more nodes and the line segments between them, which may also be curved. To draw a new path, select the path tool from the free-draw flyout in the toolbar or press P. Then you have two ways to create the path:

    @@ -178,16 +178,37 @@ Select the curve tool from the free-draw flyout in the toolbar or press Shi
  • Close it clicking over the starting node.
  • Leave it open pressing Esc or Enter to stop editing. Then press Esc to exit the edit mode.
  • -

    Tip: If you hold Shift/⇧ while adding nodes the angle between the current and the next will change in 45 degree increments.

    +

    Tip: If you hold Shift/⇧ while adding nodes, the angle between the current node and the next one changes in 15 degree increments.

    -

    Edit nodes

    +

    Edit nodes

    To edit a node double click on a path or select and press Enter. You can choose to edit individual nodes or create new ones. Press Esc to exit node edition.

    +

    The path toolbar has two modes:

    +
      +
    • Move nodes (M): select and move nodes, segments and handles. Editing an existing path starts in this mode.
    • +
    • Draw nodes (P): add new nodes and segments to the path.
    • +
    +

    Snap nodes (Ctrl/⌘ + ') is on by default, so new and moved nodes snap to the existing ones.

    + +

    Continue or close a path

    +

    To keep drawing from an existing node, select it in Move mode and switch to Draw mode, or click it in Draw mode. If the node is the end of an open path, the new segments extend it. Click the start node to close the shape. Hold Shift/⇧ to draw in 15 degree steps.

    + +

    Reshape with the mouse

    +

    These gestures work on nodes and segments while you edit a path:

    +
      +
    • Ctrl/⌘ + drag a segment: bends it while both end nodes stay in place. Ctrl/⌘ + click turns a segment from straight to curved and back.
    • +
    • Ctrl/⌘ + drag a node: pulls out a pair of handles. Ctrl/⌘ + click turns a node from corner to curve and back.
    • +
    • Alt/⌥ + click a segment (Move mode): adds a node at that point. You can also click near the middle of a segment to add a node there.
    • +
    • Alt/⌥ + click a node: removes the node and keeps the path smooth around it.
    • +
    • Ctrl/⌘ + Alt/⌥ + click a node: removes the node and its segments, which leaves a gap.
    • +
    +

    Delete or Backspace removes the selected nodes and keeps the path joined. With selected handles, it removes them and the curve at that node becomes straight. Shift/⇧ + Delete removes the selected nodes together with their segments.

    +

    See all the path editor shortcuts.

    +

    Change the image frame

    +

    Hold Ctrl/⌘ while you drag a resize handle to change the layer bounds without scaling the image. Shrink the layer to crop the image, or make it bigger to add empty space around it. The image is not distorted.

    +

    This works with image layers and with any layer that has an image fill. A normal resize afterwards scales the frame and the image together.

    +

    Layer actions

    Create

    @@ -477,7 +502,7 @@ You can choose to edit individual nodes or create new ones. Press Esc
  • Layer blur blurs the layer content itself.
  • Background blur blurs whatever sits behind the layer. Use it for frosted-glass or depth effects. Background blur requires the WebGL renderer to be enabled.
  • -

    Background blur is not included in exported files yet (PNG, SVG, PDF, and others). The exporter still uses the legacy SVG renderer, so the effect only shows in the workspace and View mode. Export support is a current priority and will land in an upcoming release.

    +

    Background blur is included in exports made with WebGL rendering enabled. In PDF files it is embedded as an image.

    Colorful shapes behind a frosted panel with background blur applied
    Background blur affects the layers behind the shape, not the shape itself.
    diff --git a/docs/user-guide/designing/text-typo.njk b/docs/user-guide/designing/text-typo.njk index 3317821ff2..c01b634653 100644 --- a/docs/user-guide/designing/text-typo.njk +++ b/docs/user-guide/designing/text-typo.njk @@ -24,6 +24,7 @@ desc: Penpot's guide on custom fonts! Upload, manage, and use custom fonts in Pe

    Edit and style text content

    Press Enter with a text layer selected to start editing the text content. You can style parts of the text content as rich text.

    +

    With WebGL rendering enabled, right-click while editing to open a menu with Cut, Copy, Paste and Select all. Your text selection is kept.

    editing text
    diff --git a/docs/user-guide/designing/workspace-basics.njk b/docs/user-guide/designing/workspace-basics.njk index c0547d4cbc..bcd23eb546 100644 --- a/docs/user-guide/designing/workspace-basics.njk +++ b/docs/user-guide/designing/workspace-basics.njk @@ -185,6 +185,9 @@ press Shift/⇧ + left click over the right arrow of a group or a boa Marking comments as read
    +

    Filter comments

    +

    The comments menu lets you show all comments, only your comments or only your mentions. Hide resolved comments is a separate option. Penpot remembers it in your browser for all files, in the workspace and in View mode.

    +

    Edit and remove comments

    At the top right of the comment popup you can find options to edit or delete comments.

    diff --git a/docs/user-guide/export-import/exporting-layers.njk b/docs/user-guide/export-import/exporting-layers.njk index c8ad0988e9..6f1f5869c5 100644 --- a/docs/user-guide/export-import/exporting-layers.njk +++ b/docs/user-guide/export-import/exporting-layers.njk @@ -27,7 +27,7 @@ desc: Learn how to export layers in Penpot, the free, open-source design collabo

    Export options

    The options of an export:

      -
    • Size - Options for the most common sizing scales.
    • +
    • Size - The scale of the exported image: 0.5x, 0.75x, 1x, 1.5x, 2x, 3x, 4x or 6x. Use 1x, 2x and 3x to get the usual iOS asset sizes. Available for PNG, JPEG and WEBP.
    • Suffix - Especially useful if you are exporting at different scales.
    • File format - PNG, JPEG, WEBP, SVG, PDF.
    diff --git a/docs/user-guide/first-steps/cloud-selfhost.njk b/docs/user-guide/first-steps/cloud-selfhost.njk index bf3c128132..b5a22ee1a1 100644 --- a/docs/user-guide/first-steps/cloud-selfhost.njk +++ b/docs/user-guide/first-steps/cloud-selfhost.njk @@ -15,3 +15,7 @@ desc: Start instantly with Penpot, the open-source design and prototyping platfo

    On your own server

    Currently, private Penpot instances only require basic Docker knowledge.

    You can run your own Penpot server following the instructions at the Technical Guide

    + +

    Check for updates

    +

    To see if your installation is up to date, open your profile menu in the dashboard and go to About Penpot → Check for updates. If a newer version exists, Penpot shows your installed version, the latest one, and the highlights of each release you are missing, with links to the full changelog and the release notes.

    +

    The check runs from your browser and needs access to GitHub. It is hidden on installations configured as air-gapped.

    diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk index b06057a9b9..e4708c9800 100644 --- a/docs/user-guide/first-steps/shortcuts.njk +++ b/docs/user-guide/first-steps/shortcuts.njk @@ -10,7 +10,43 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface

    Here you can find a list of all keyboard shortcuts that you can use in Penpot. Bear in mind that most of them are at the workspace, where they are more needed.

    -

    You can also check the most updated list of shortcuts at the GitHub file.

    +

    The tables below list the default shortcuts. You can change most of them to fit your keyboard layout or your habits. See Customize shortcuts.

    + +

    Customize shortcuts

    +

    Go to Your account → Shortcuts to see every shortcut and change the ones you want. From the workspace, open the shortcuts panel (Help & info → Shortcuts in the main menu, or ?) and click Customize in Settings.

    +

    Your shortcuts are saved in your account, so they follow you to any computer and browser. After a change, reload any other open Penpot tabs to apply it.

    + +

    Find a shortcut

    +

    Shortcuts are grouped into Workspace, Dashboard and Viewer. Use the tabs to see All of them, only your Custom ones, or the ones you Disabled.

    +

    The search field matches both the action name and the keys. Type "group" to find the grouping actions, or "shift" to see every shortcut that uses Shift.

    + +

    Change a shortcut

    +
      +
    1. Click the shortcut you want to change.
    2. +
    3. Press the new keys. The last combination you press is the one recorded.
    4. +
    5. Click the check mark to save, or the cross to cancel. Clicking outside the editor also cancels. Esc does not cancel: it is recorded as a key.
    6. +
    +
    + Shortcuts settings with a shortcut being edited and a conflict warning +
    +

    If the keys are already used by another action in the same group, Penpot shows which one. When you save, the shortcut moves to the action you are editing, and the other action appears under Disabled.

    +

    Each action takes one combination. If an action had two defaults (for example, two ways to redo), saving a custom one replaces both.

    +

    From the same editor you can also disable a shortcut so that no keys trigger it, or reset it to its default value. Custom shortcuts are marked with a dot, and the main menu highlights the custom keys.

    +

    Paste cannot be customized.

    + +

    Keyboard layouts

    +

    Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so they work the same on AZERTY, QWERTZ and other layouts. Letters and numbers are recorded by the character they type. With Shift, the base key is recorded, not the shifted character.

    + +

    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.

    + +

    Export and import your shortcuts

    +

    Use the Import/Export button at the bottom of the page to move your setup to another account or share it with your team.

    +
      +
    • Export downloads a JSON file with your custom and disabled shortcuts. It is available once you have changed at least one shortcut.
    • +
    • Import reads a JSON file exported from Penpot and shows the shortcuts it will change. Click Import to confirm.
    • +
    +

    Importing replaces your custom shortcuts in each group included in the file (Workspace, Dashboard or Viewer). Custom shortcuts in those groups that are not in the file go back to their defaults. If an imported shortcut uses keys taken by a default one, the default one is disabled.

    Workspace

    The Workspace is where the designs are actually created. More about the Workspace.

    @@ -569,6 +605,7 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface

    Path editor

    +

    Copy, cut, paste, duplicate and select all also work on nodes while you edit a path. For mouse gestures, see Reshape with the mouse.

    @@ -584,12 +621,17 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface - - - + + + - + + + + + + @@ -614,7 +656,7 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface - + @@ -628,6 +670,16 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface + + + + + + + + + +
    ⇧+
    Delete nodeSupr or Delete⌫Delete node, segment or handleDelete or Backspace⌦ or ⌫
    Draw PathDelete node and segmentsShiftDelete⇧⌫
    Draw nodes P P
    ⌘J
    Move NodesMove nodes M M
    Ctrl' ⌘'
    Flip horizontally (selected nodes, or the whole path)ShiftH⇧H
    Flip vertically (selected nodes, or the whole path)ShiftV⇧V
    diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk index c3893f90e8..829e0e99ba 100644 --- a/docs/user-guide/first-steps/troubleshooting-webgl.njk +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -10,7 +10,8 @@ desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Be

    WebGL rendering (Beta)

    Since Penpot 2.16, optional WebGL rendering (Beta) is available on design.penpot.app and self-hosted instances. It is off by default.

    Turn it on from Your account → Settings or Workspace → Main menu → Preferences when you want a faster canvas for complex files.

    -

    In this beta, WebGL applies to the design workspace canvas only. View mode and exports still use the legacy renderer, so some visual differences are expected. If the beta gets in your way, you can switch back to the legacy renderer at any time.

    +

    With WebGL rendering enabled, the workspace canvas, View mode and exports (PNG, JPEG, WEBP, SVG and PDF) use the same engine, so exported files match what you see on the canvas. Text is edited with the new text editor, which also adds a right-click menu with Cut, Copy, Paste and Select all.

    +

    Some features need WebGL rendering, such as stroke per side, background blur and stroke to path.

    For what is in scope today, what we are prioritizing next, and known visual differences, read What's next for the Penpot WebGL renderer on the Penpot Community.

    From 28e735119b316e38bbefc6574acccea6ceb96246 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 14 Oct 2026 12:06:17 +0000 Subject: [PATCH 004/167] :wrench: Forbid Co-authored-by trailer in commits The Co-authored-by trailer is now forbidden. The commit checker fails any message holding it, whatever the case, and the docs record the ban. AI-assisted-by: muse-spark-1.3-contributor Signed-off-by: Andrey Antukh --- .agents/skills/create-commit/SKILL.md | 1 + .serena/memories/workflow/creating-commits.md | 3 +- CONTRIBUTING.md | 2 ++ scripts/check-commit | 24 ++++++++++++++- scripts/test_check_commit.py | 29 +++++++++++++++++++ 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/.agents/skills/create-commit/SKILL.md b/.agents/skills/create-commit/SKILL.md index 26b23b6a7e..44ba9b2d8b 100644 --- a/.agents/skills/create-commit/SKILL.md +++ b/.agents/skills/create-commit/SKILL.md @@ -61,5 +61,6 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly. - Do not pass `--author`. Author identity comes from the local git config. - Do not amend a commit you did not create in this session, unless explicitly asked. - Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked. +- Never add a `Co-authored-by:` trailer. It is forbidden and fails `./scripts/check-commit`. - Do not add untracked files that were not created in this session. - Do not skip the `scripts/check-commit` verification step (Iron Rule 4). diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index d632c9a471..d2d1fcaac2 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -37,8 +37,9 @@ that breaks them is wrong, even if the code is right. - For another commit: `./scripts/check-commit -c ` - **NEVER** hand-wave the body as "one long line". If a line exceeds 76, break it at a space. +- **NEVER add a `Co-authored-by:` trailer.** It is forbidden; `scripts/check-commit` fails the commit when any line matches it (case-insensitive). - Exceptions inside the body (do not wrap these): `Signed-off-by:`, - `Co-authored-by:`, `AI-assisted-by:` trailers, and lines carrying a URL. + `AI-assisted-by:` trailers, and lines carrying a URL. **AI-assisted-by trailer rules:** - Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9da08257f7..1b74634dc7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -223,6 +223,8 @@ Commit messages must follow this format: - Keep the subject to **70 characters** or fewer - **Wrap body lines at 76 characters or fewer** (trailers and URLs excepted) - Separate the subject from the body with a **blank line** +- Never add a `Co-authored-by` trailer — it is forbidden and fails + `./scripts/check-commit` You can check a commit against these rules with `./scripts/check-commit`. diff --git a/scripts/check-commit b/scripts/check-commit index 3f2792552e..d0e9f59899 100755 --- a/scripts/check-commit +++ b/scripts/check-commit @@ -47,10 +47,12 @@ MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$") MAX_BODY_LINE = 76 TRAILER_PATTERN = re.compile( - r"^(Signed-off-by|Co-authored-by|Co-developed-by|Reviewed-by|" + r"^(Signed-off-by|Co-developed-by|Reviewed-by|" r"Acked-by|Tested-by|Reported-by|Suggested-by|AI-assisted-by):" ) +FORBIDDEN_TRAILER_PATTERN = re.compile(r"^\s*Co-authored-by\s*:", re.IGNORECASE) + URL_PATTERN = re.compile(r"https?://\S+") # ═══════════════════════════════════════════════════════════════════════════════ @@ -209,6 +211,25 @@ def check_signed_off_by(message): return True, None +def check_no_co_authored_by(message): + """Check that the forbidden Co-authored-by trailer is absent.""" + offenders = [ + (line_number, line) + for line_number, line in enumerate(message.split("\n"), start=1) + if FORBIDDEN_TRAILER_PATTERN.match(line) + ] + if not offenders: + return True, None + details = "\n".join( + f" line {line_number}: {line!r}" + for line_number, line in offenders + ) + return False, ( + "The 'Co-authored-by:' trailer is forbidden and must not appear " + "in the commit message. Offending line(s):\n" + details + ) + + # ═══════════════════════════════════════════════════════════════════════════════ def main(): @@ -234,6 +255,7 @@ def main(): ("Subject capitalized", check_subject_capitalized), ("Blank line after subject", check_body_blank_line), ("Body wrapped at 76 chars", check_body_line_length), + ("No Co-authored-by trailer", check_no_co_authored_by), ] all_ok = True diff --git a/scripts/test_check_commit.py b/scripts/test_check_commit.py index 4e4940429e..a3a7a1c2b9 100644 --- a/scripts/test_check_commit.py +++ b/scripts/test_check_commit.py @@ -106,6 +106,35 @@ class BodyLineLengthTests(unittest.TestCase): self.assertIn("line 4", error) +class ForbiddenTrailerTests(unittest.TestCase): + def assert_ok(self, message): + ok, error = check_commit.check_no_co_authored_by(message) + self.assertTrue(ok, error) + self.assertIsNone(error) + + def assert_fail(self, message): + ok, error = check_commit.check_no_co_authored_by(message) + self.assertFalse(ok) + self.assertIsNotNone(error) + return error + + def test_body_without_trailer_passes(self): + self.assert_ok(":bug: Fix crash\n\nBody.\n\nSigned-off-by: Someone \n") + + def test_co_authored_by_fails(self): + error = self.assert_fail(":bug: Fix crash\n\nBody.\n\nCo-authored-by: Someone \n") + self.assertIn("Co-authored-by", error) + + def test_co_authored_by_case_insensitive_fails(self): + self.assert_fail(":bug: Fix crash\n\nBody.\n\nco-authored-by: Someone \n") + + def test_co_authored_by_in_body_fails(self): + self.assert_fail(":bug: Fix crash\n\nCo-authored-by: Someone \nBody.\n") + + def test_similar_trailer_passes(self): + self.assert_ok(":bug: Fix crash\n\nBody.\n\nCo-developed-by: Someone \n") + + class SubjectRulesRegressionTests(unittest.TestCase): """Guard the pre-existing validators against accidental breakage.""" From 3458b2f2e5173b1598c0b59f056c7c7d502c7edb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 14 Oct 2026 12:16:30 +0000 Subject: [PATCH 005/167] :wrench: Run check-commit on every new commit in CI Add a check-commit-guidelines job to commit-checker.yml that runs scripts/check-commit on every new commit, so the Co-authored-by ban holds in CI too. PR events check out the base ref, keeping the executed script trusted. AI-assisted-by: muse-spark-1.3-contributor Signed-off-by: Andrey Antukh --- .github/workflows/commit-checker.yml | 41 +++++++++++++++++++ .serena/memories/workflow/creating-commits.md | 1 + CONTRIBUTING.md | 2 + 3 files changed, 44 insertions(+) diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index a80e6e4cc0..14c48a5850 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -50,4 +50,45 @@ jobs: # pattern: '^.+(Resolves|Fixes): \#[0-9]+$' # error: 'You need at least one "Resolves|Fixes: #" line.' + check-commit-guidelines: + if: ${{ !github.event.pull_request.draft }} + name: Check Commit Guidelines + runs-on: ubuntu-latest + steps: + # On PR events check out the base ref, so the script below is + # always the trusted version: PR commits are only read, never + # executed. On push events this resolves to the pushed sha. + - name: Checkout trusted code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.base.sha || github.sha }} + + - name: Fetch PR commits + if: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }} + run: git fetch --no-tags origin "pull/${{ github.event.pull_request.number }}/head:pr-head" + + - name: Run scripts/check-commit on every new commit + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "push" ]; then + if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then + RANGE="${{ github.sha }}" + else + RANGE="${{ github.event.before }}..${{ github.sha }}" + fi + else + RANGE="${{ github.event.pull_request.base.sha }}..pr-head" + fi + COMMITS="$(git rev-list --reverse "$RANGE")" + if [ -z "$COMMITS" ]; then + echo "No new commits to check." + exit 0 + fi + FAILED=0 + for COMMIT in $COMMITS; do + ./scripts/check-commit -c "$COMMIT" || FAILED=1 + done + exit "$FAILED" + diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index d2d1fcaac2..a60234819f 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -38,6 +38,7 @@ that breaks them is wrong, even if the code is right. - **NEVER** hand-wave the body as "one long line". If a line exceeds 76, break it at a space. - **NEVER add a `Co-authored-by:` trailer.** It is forbidden; `scripts/check-commit` fails the commit when any line matches it (case-insensitive). +- CI runs `scripts/check-commit` on every new commit (`mem:workflow/creating-commits` is enforced by `.github/workflows/commit-checker.yml`), so a forbidden trailer fails the build. - Exceptions inside the body (do not wrap these): `Signed-off-by:`, `AI-assisted-by:` trailers, and lines carrying a URL. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b74634dc7..2d665b0715 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -227,6 +227,8 @@ Commit messages must follow this format: `./scripts/check-commit` You can check a commit against these rules with `./scripts/check-commit`. +CI runs the same script on every new commit in +`.github/workflows/commit-checker.yml`, so violations fail the build. ### Examples From ba81f2f8bb518ff06c39562460386c3e59022f82 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 14 Oct 2026 14:21:18 +0200 Subject: [PATCH 006/167] :bug: Fix audit-log-archive cron submit and nitrate wiring (#12321) After the unified jobs migration, cron submitted empty params that failed validation because the job schema still required Integrant system keys. Narrow the schema to optional REPL overrides, wire :app.nitrate/client onto the archive job-def, and drop the unused ::jobs/defs ref from nitrate that created an Integrant cycle. AI-assisted-by: composer --- backend/src/app/loggers/audit/archive_task.clj | 8 +++----- backend/src/app/main.clj | 10 ++++++---- .../backend_tests/loggers_audit_archive_task_test.clj | 11 +++++++++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 1264882eaa..6c29c29d6b 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -256,12 +256,10 @@ (declare execute-audit-log-archive) (def schema:audit-log-archive-params - "Optional overrides for the repl invocation defaults." + "Optional overrides for the repl invocation defaults. + Cron submits {}; system deps (pool, shared-keys, http) live on the + job-def cfg closed over by the handler, not in job params." [:map - ::db/pool - ::setup/shared-keys - ::http/client - [:app.nitrate/client {:optional true} [:maybe :map]] [:enabled {:optional true} :boolean] [:uri {:optional true} ::sm/uri]]) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index d5aa7538c0..2c34fc8c94 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -543,7 +543,8 @@ :app.nitrate/client {::http.client/client (ig/ref ::http.client/client) - ::jobs/defs (ig/ref ::jobs/defs) + ;; No ::jobs/defs: nitrate never submits jobs, and archive-task's + ;; job-def needs this client — that would cycle through jobs/defs. ::setup/shared-keys (ig/ref ::setup/shared-keys)} :app.rpc/management-methods @@ -679,10 +680,11 @@ ::mtx/metrics (ig/ref ::mtx/metrics)} :app.loggers.audit.archive-task/job-def - {::db/pool (ig/ref ::db/pool) + {::db/pool (ig/ref ::db/pool) ::http.client/client (ig/ref ::http.client/client) - ::setup/shared-keys (ig/ref ::setup/shared-keys) - ::mtx/metrics (ig/ref ::mtx/metrics)} + ::setup/shared-keys (ig/ref ::setup/shared-keys) + :app.nitrate/client (ig/ref :app.nitrate/client) + ::mtx/metrics (ig/ref ::mtx/metrics)} :app.loggers.audit.gc-task/job-def {::db/pool (ig/ref ::db/pool) diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_archive_task_test.clj index 863885a085..a04c2c2fd1 100644 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_archive_task_test.clj @@ -7,12 +7,15 @@ (ns backend-tests.loggers-audit-archive-task-test (:require [app.common.json :as json] + [app.common.schema :as sm] [app.common.time :as ct] + [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.http.client :as-alias http] [app.jobs :as jobs] + [app.loggers.audit.archive-task :as archive] [app.setup :as-alias setup] [backend-tests.helpers :as th] [clojure.test :as t] @@ -348,3 +351,11 @@ (t/is (= (:name team) (:team-name props)))) (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] (t/is (= 2 (count rows)))))))) + +(t/deftest archive-params-accept-empty-cron-props + ;; Cron always submits (or props {}) — system deps live on the + ;; job-def cfg, not in params. Requiring them here blocks every tick. + (let [valid? (sm/validator archive/schema:audit-log-archive-params)] + (t/is (true? (valid? {}))) + (t/is (true? (valid? {:enabled true}))) + (t/is (true? (valid? {:uri (u/uri archive-uri)}))))) From 24d3e62c6228fa1c904fd3e5519afc1af2cdb9c4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 14 Oct 2026 12:25:34 +0000 Subject: [PATCH 007/167] :arrow_up: Update opencode on devenv dockerfile Bump opencode to 2.0.29 in the devenv image and refresh the published checksums for both supported architectures. AI-assisted-by: muse-spark-1.3-contributor --- docker/devenv/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 1b452d6482..11dcf79391 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -109,18 +109,18 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=2.0.26 +ENV OPENCODE_VERSION=2.0.29 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-arm64/-/cli-linux-arm64-${OPENCODE_VERSION}.tgz"; \ - ESUM='1d7117dbb31968456f89537b562fb5e20e48d9e9ef0325fc8845abdda6ef4842'; \ + ESUM='0af39e75dbdfacc20f8f531ef35fb4dcc0b44c08084203518614272f232283ae'; \ ;; \ amd64|x86_64) \ BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-${OPENCODE_VERSION}.tgz"; \ - ESUM='0a116e033a02804741d4644337d012bdf5a24aac33a34c0c96534d2d53961119'; \ + ESUM='14b5a6638a1a093ac9d484599c844b10c29b445d04844b682ccc668ce26530a2'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; exit 1; \ From 286a6d5c9a75821681b6e9697521a09468692678 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Wed, 14 Oct 2026 15:24:53 +0200 Subject: [PATCH 008/167] :sparkles: Add new dashboard methods to the API (#12169) * :sparkles: Add new dashboard methods to the API * :bug: Fix some issues with the new methods * :bug: Fix problem with mcp test --- common/src/app/common/types/plugins.cljc | 7 +- docs/plugins/getting-started.md | 6 + .../scripts/check-translations/words.es.txt | 1 + frontend/src/app/main/data/dashboard.cljs | 8 + frontend/src/app/main/data/workspace/mcp.cljs | 6 +- .../src/app/main/ui/workspace/plugins.cljs | 21 + frontend/src/app/plugins/api.cljs | 2 +- frontend/src/app/plugins/library.cljs | 30 +- frontend/src/app/plugins/management.cljs | 599 +++++++++++++--- frontend/src/app/plugins/register.cljs | 14 +- .../frontend_tests/data/dashboard_test.cljs | 18 + .../frontend_tests/plugins/library_test.cljs | 35 +- .../plugins/management_test.cljs | 674 +++++++++++++----- .../frontend_tests/plugins/register_test.cljs | 6 + frontend/translations/en.po | 12 + frontend/translations/es.po | 12 + .../plugin/src/ExecuteCodeTaskConsole.test.ts | 2 +- .../plugin/src/ExecuteCodeTaskConsole.ts | 171 +++++ .../task-handlers/ExecuteCodeTaskHandler.ts | 173 +---- mcp/packages/server/data/api_types.yml | 448 ++++++++++-- .../server/data/initial_instructions.md | 16 +- plugins/CHANGELOG.md | 4 +- plugins/apps/plugin-api-test-suite/README.md | 69 +- .../apps/plugin-api-test-suite/ci/run-ci.ts | 135 +++- .../public/manifest.json | 7 +- .../src/framework/runner.ts | 83 ++- .../src/framework/temporary.ts | 14 + .../src/framework/types.ts | 17 +- .../src/generated/api-surface.json | 319 ++++++++- .../src/tests/management.test.ts | 555 ++++++++++++++ .../src/tests/pages.test.ts | 5 +- .../plugin-api-test-suite/src/tests/wait.ts | 17 + plugins/libs/plugin-types/index.d.ts | 256 +++++-- .../libs/plugins-runtime/src/lib/api/index.ts | 24 +- .../src/lib/api/plugin-api.spec.ts | 166 ++--- .../src/lib/models/manifest.schema.ts | 3 + 36 files changed, 3157 insertions(+), 778 deletions(-) create mode 100644 mcp/packages/plugin/src/ExecuteCodeTaskConsole.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/temporary.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/management.test.ts diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index 8db72497fd..3b32ccbaed 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -36,11 +36,14 @@ "user:read" "allow:downloads" "allow:localstorage" - "allow:global"}) + "allow:global" + "manage:projects" + "manage:teams" + "manage:delete"}) (def schema:permissions "Schema for plugin permissions - a set of valid permission strings." - [:set {:gen/max 12} (into [:enum] (sort valid-permissions))]) + [:set {:gen/max 15} (into [:enum] (sort valid-permissions))]) (def max-plugins "Maximum number of plugins a profile can hold." diff --git a/docs/plugins/getting-started.md b/docs/plugins/getting-started.md index 5f72ac4ada..e74cbce541 100644 --- a/docs/plugins/getting-started.md +++ b/docs/plugins/getting-started.md @@ -189,6 +189,12 @@ Typical use cases: storing authentication tokens for a plugin login - allow:global: Allows the plugin to keep running while the user moves between the dashboard and files, and to find and open their projects and files. Added automatically when the manifest sets "scope": "global". Typical use cases: assistants or integrations that work across several files. +- manage:projects: Allows a global plugin to create, rename, pin and duplicate projects. + +- manage:teams: Allows a global plugin to list, create and rename the user's teams, and move projects between them. + +- manage:delete: Allows a global plugin to delete files, projects and teams. + _Note: Write permissions automatically includes its corresponding read permission (e.g., content:write includes content:read) because reading is required to perform write or modification actions._ ### What are plugin.ts and plugin.js files? diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt index 009f4c1c4e..89cfcb7dc0 100644 --- a/frontend/scripts/check-translations/words.es.txt +++ b/frontend/scripts/check-translations/words.es.txt @@ -262,6 +262,7 @@ eliminarla eliminarlo eliminarlos ella +ellos eso esos este diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index b0e47eafea..28f36671ff 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -282,6 +282,13 @@ ;; --- EVENT: duplicate-project +(defn project-with-file-count + "Loads the file count missing from a duplicated project's RPC response." + [{:keys [id] :as project}] + (->> (rp/cmd! :get-project-files {:project-id id}) + (rx/map (fn [files] + (assoc project :count (count files) :is-pinned false))))) + (defn project-duplicated [{:keys [id] :as project}] (ptk/reify ::project-duplicated @@ -312,6 +319,7 @@ (str " " copy-count)))) new-name (cfh/generate-unique-name name unames :suffix-fn suffix-fn)] (->> (rp/cmd! :duplicate-project {:project-id id :name new-name}) + (rx/mapcat project-with-file-count) (rx/tap on-success) (rx/map project-duplicated) (rx/catch on-error)))))) diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index e4b03fb956..58ce2f5060 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -39,11 +39,7 @@ :allow-background true :scope "global" :permissions - #{"user:read" - "library:read" "library:write" - "comment:read" "comment:write" - "content:write" "content:read" - "allow:global"}}) + preg/mcp-permissions}) (defonce interval-sub (atom nil)) diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index 12bd45e766..478566b45a 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -338,6 +338,27 @@ [:p {:class (stl/css :permissions-list-text)} (tr "workspace.plugins.permissions.allow-global")]]) + (cond + (contains? permissions "manage:projects") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.manage-projects")]]) + + (cond + (contains? permissions "manage:teams") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.manage-teams")]]) + + (cond + (contains? permissions "manage:delete") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.manage-delete")]]) + (cond (contains? permissions "clipboard:write") [:div {:class (stl/css :permissions-list-entry)} diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 2f0944f800..c2a745cf13 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -90,7 +90,7 @@ ;; Public properties :management - {:get (fn [] (management/create-context))} + {:get (fn [] (management/create-context plugin-id))} :version {:this true diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 56782eb1e2..661e3d9b0d 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -1206,6 +1206,25 @@ (let [file (u/locate-file file-id)] (apply array (keys (dm/get-in file [:data :plugin-data (keyword "shared" namespace)])))))))) +(defn- loaded-file-ids + "Ids of the files whose data is loaded: the open file and its libraries. + Files left behind by earlier workspaces stay in `:files` without data." + [state] + (into #{} (keep (fn [[id file]] (when (:data file) id))) (:files state))) + +(defn- on-library-loaded + "Calls `f` once the data of `library-id` is in the workspace state." + [library-id f] + (let [key (js/Symbol) + loaded? #(some? (dm/get-in % [:files library-id :data]))] + (if (loaded? @st/state) + (f) + (add-watch st/state key + (fn [_ _ _ state] + (when (loaded? state) + (remove-watch st/state key) + (f))))))) + (defn library-subcontext [plugin-id] (obj/reify {:name "PenpotLibrarySubcontext"} @@ -1217,15 +1236,14 @@ :connected {:get (fn [] - (let [libraries (get @st/state :files)] - (apply array (->> libraries keys (map (partial library-proxy plugin-id))))))} + (apply array (map (partial library-proxy plugin-id) (loaded-file-ids @st/state))))} :availableLibraries (fn [] (let [team-id (:current-team-id @st/state)] (js/Promise. (fn [resolve reject] - (let [current-libs (into #{} (map first) (get @st/state :files))] + (let [current-libs (loaded-file-ids @st/state)] (->> (rp/cmd! :get-team-shared-files {:team-id team-id}) (rx/map (fn [result] (->> result @@ -1267,7 +1285,11 @@ (->> st/stream (rx/filter (ptk/type? ::dwl/link-file-to-library-finished)) (rx/take 1) - (rx/subs! #(resolve (library-proxy plugin-id library-id)) reject)) + (rx/subs! (fn [_] + (on-library-loaded + library-id + #(resolve (library-proxy plugin-id library-id)))) + reject)) (st/emit! (-> (dwl/link-file-to-library file-id library-id) (se/add-event plugin-id)))))) reject))))))))) diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs index 0337da56ba..727939b8b7 100644 --- a/frontend/src/app/plugins/management.cljs +++ b/frontend/src/app/plugins/management.cljs @@ -6,34 +6,151 @@ (ns app.plugins.management (:require + [app.common.data :as d] [app.common.features :as cfeat] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.main.data.dashboard :as dd] + [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.team :as dtm] + [app.main.features :as features] [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] + [app.plugins.register :as r] + [app.plugins.system-events :as se] + [app.plugins.utils :as u] [app.util.object :as obj] + [app.util.sse :as sse] [beicon.v2.core :as rx] [clojure.set :as set] [cuerdas.core :as str] [potok.v2.core :as ptk])) -(defn- project-summary - [project] - #js {:id (str (:id project)) - :teamId (str (:team-id project)) - :name (:name project) - :isDefault (boolean (:is-default project)) - :fileCount (or (:count project) 0)}) +(declare project-proxy) +(declare file-proxy) -(defn- file-summary - [file] - #js {:id (str (:id file)) - :teamId (str (:team-id file)) - :projectId (str (:project-id file)) - :name (:name file) - :modifiedAt (.toISOString ^js (:modified-at file))}) +(defn- dashboard-of-team? + "True when the dashboard of `team-id` is open. Outside the dashboard `:files` + holds the workspace files and libraries, so dashboard updates do not apply." + [state team-id] + (and (str/starts-with? (name (or (rt/lookup-name state) "")) "dashboard") + (= team-id (:current-team-id state)))) + +(defn- update-dashboard + "Applies `update-fn` to the state when the dashboard of `team-id` is open." + [team-id update-fn] + (st/emit! (fn [state] + (if (dashboard-of-team? state team-id) + (update-fn state) + state)))) + +(defn- refresh-restored-project + [team-id project-id] + (if (dashboard-of-team? @st/state team-id) + (->> (rx/zip (rp/cmd! :get-projects {:team-id team-id}) + (rp/cmd! :get-project-files {:project-id project-id}) + (rp/cmd! :get-team-deleted-files {:team-id team-id})) + (rx/tap (fn [[projects files deleted-files]] + (update-dashboard + team-id + (fn [state] + (-> state + (update :projects merge (d/index-by :id projects)) + (update :files merge (d/index-by :id (concat files deleted-files))) + (assoc :deleted-files (d/index-by :id deleted-files))))))) + (rx/map (constantly nil))) + (rx/of nil))) + +(defn- restore-deleted-files + "Uses the dashboard's Trash operation and waits for its final result." + [team-id ids] + (->> (rp/cmd! ::sse/restore-deleted-team-files {:team-id team-id :ids ids}) + (rx/filter sse/end-of-stream?) + (rx/take 1) + (rx/map sse/get-payload) + (rx/reduce (fn [_ restored] restored) nil) + (rx/mapcat (fn [restored] + (cond + (nil? restored) + (rx/throw (js/Error. "Restoration ended without a result")) + + (not (set/subset? ids restored)) + (rx/throw (js/Error. "Some files could not be restored; they may no longer exist")) + + :else + (rx/of nil)))))) + +(defn- valid-name + "Returns `value` trimmed when it is a name of 1 to 250 characters." + [value] + (when (string? value) + (let [value (str/trim value)] + (when (<= 1 (count value) 250) + value)))) + +(defn- permission-error + [permission] + (js/Error. (str "Permission " permission " is not granted"))) + +(defn- backend-error + "Adds the backend type and code to plugin errors without exposing RPC data." + [error] + (if-let [{:keys [type code hint]} (ex-data error)] + (let [details (str/join "/" (keep #(some-> % name) [type code])) + message (or hint (ex-message error)) + result (js/Error. (str message (when (seq details) (str " (" details ")"))))] + (obj/set! result "type" (some-> type name)) + (obj/set! result "code" (some-> code name)) + result) + error)) + +(defn- request + "Resolves with the single value of the stream built by `make-stream` when + the plugin has `permission`." + [plugin-id permission make-stream] + (if (r/check-permission plugin-id permission) + (js/Promise. + (fn [resolve reject] + (rx/subs! resolve #(reject (backend-error %)) (make-stream)))) + (js/Promise.reject (permission-error permission)))) + +(defn- set-property + "Runs a setter's RPC before updating the proxy and app state. Async failures + are logged even when synchronous validation errors are configured to throw." + [plugin-id data attr value event] + (st/emit! + (se/add-event + (ptk/reify ::set-property + ev/Event + (-data [_] + (assoc (if (satisfies? ev/Event event) (ev/-data event) {}) + ::ev/name (name (ptk/type event)))) + + ptk/WatchEvent + (watch [_ state stream] + (->> (rx/concat + (ptk/watch event state stream) + (rx/of (fn [state] + (swap! data assoc attr value) + (ptk/update event state)))) + (rx/catch (fn [error] + (u/display-not-valid attr (.-message (backend-error error))) + (rx/empty)))))) + plugin-id))) + +(defn- set-project-pin + [{:keys [id is-pinned] :as params}] + (ptk/reify ::set-project-pin + ptk/UpdateEvent + (update [_ state] + (d/update-in-when state [:projects id] assoc :is-pinned is-pinned)) + + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! :update-project-pin params) + (rx/ignore))))) (defn workspace-context [state] @@ -93,98 +210,390 @@ (finish (js/Error. "File navigation was superseded")))))) (st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id})))))))) -(defn list-projects - [options] - (let [team-id (if-let [id (obj/get options "teamId")] - (uuid/parse* id) - (:current-team-id @st/state))] - (if (nil? team-id) - (js/Promise.reject (js/Error. "Expected a team UUID")) - (js/Promise. - (fn [resolve reject] - (->> (rp/cmd! :get-projects {:team-id team-id}) - (rx/map (fn [projects] - (into-array (map project-summary (remove :deleted-at projects))))) - (rx/subs! resolve reject))))))) +(defn- list-projects + [plugin-id team-id] + (request plugin-id "content:read" + #(->> (rp/cmd! :get-projects {:team-id team-id}) + (rx/map (fn [projects] + (->> projects + (remove :deleted-at) + (map (partial project-proxy plugin-id)) + (into-array))))))) -(defn list-files - [options] - (let [project-id (uuid/parse* (obj/get options "projectId"))] - (if (nil? project-id) - (js/Promise.reject (js/Error. "Expected a project UUID")) - (js/Promise. - (fn [resolve reject] - (->> (rp/cmd! :get-project-files {:project-id project-id}) - (rx/map (fn [files] - (into-array (map file-summary files)))) - (rx/subs! resolve reject))))))) +(defn- list-deleted-projects + [plugin-id team-id] + (request plugin-id "content:read" + #(->> (rp/cmd! :get-projects {:team-id team-id}) + (rx/map (fn [projects] + (into-array (map (partial project-proxy plugin-id) + (filter (fn [project] + (some-> (:deleted-at project) (ct/is-after? (ct/now)))) + projects)))))))) -(defn- creation-name - [options] - (let [name (obj/get options "name")] - (when (string? name) - (let [name (str/trim name)] - (when (<= 1 (count name) 250) - name))))) +(defn- list-deleted-files + [plugin-id team-id project-id] + (request plugin-id "content:read" + #(->> (rp/cmd! :get-team-deleted-files {:team-id team-id}) + (rx/map (fn [files] + (into-array (map (partial file-proxy plugin-id) + (cond->> (filter (fn [file] + (some-> (:will-be-deleted-at file) (ct/is-after? (ct/now)))) + files) + project-id (filter (fn [file] (= project-id (:project-id file)))))))))))) -(defn create-project - [options] - (let [team-id (if-let [id (obj/get options "teamId")] - (uuid/parse* id) - (:current-team-id @st/state)) - name (creation-name options)] - (cond - (nil? team-id) - (js/Promise.reject (js/Error. "Expected a team UUID")) +(defn- create-project + [plugin-id team-id options] + (if-let [name (valid-name (obj/get options "name"))] + (request plugin-id "manage:projects" + #(->> (rp/cmd! :create-project {:team-id team-id :name name}) + (rx/map (fn [project] (assoc project :count 0))) + (rx/tap (fn [project] + (update-dashboard team-id + (fn [state] + (assoc-in state [:projects (:id project)] project))))) + (rx/map (partial project-proxy plugin-id)))) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")))) - (nil? name) - (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) +(defn- file-metadata + "Fetches the metadata of a file and its team. A file loaded in the workspace + is read from the state instead of downloading it again." + [file-id] + (let [file (dsh/lookup-file @st/state file-id)] + (->> (if (:project-id file) + (rx/of file) + (rp/cmd! :get-file {:id file-id :features (:features @st/state)})) + (rx/mapcat (fn [file] + (->> (rp/cmd! :get-project {:id (:project-id file)}) + (rx/map (fn [project] + (-> (dissoc file :data) + (assoc :team-id (:team-id project))))))))))) - :else - (js/Promise. - (fn [resolve reject] - (->> (rp/cmd! :create-project {:team-id team-id :name name}) - (rx/tap (fn [project] - (st/emit! - (fn [state] - (if (= (:team-id project) (:current-team-id state)) - (assoc-in state [:projects (:id project)] (assoc project :count 0)) - state))))) - (rx/map project-summary) - (rx/subs! resolve reject))))))) +(defn- get-file + [plugin-id file-id] + (if-let [file-id (uuid/parse* file-id)] + (request plugin-id "content:read" + #(->> (file-metadata file-id) + (rx/map (partial file-proxy plugin-id)))) + (js/Promise.reject (js/Error. "Expected a file UUID")))) -(defn create-file - [options] - (let [project-id (uuid/parse* (obj/get options "projectId")) - name (creation-name options)] - (cond - (nil? project-id) - (js/Promise.reject (js/Error. "Expected a project UUID")) +(defn team-proxy + [plugin-id team] + (let [data (atom team)] + (obj/reify {:name "TeamProxy"} + :$plugin {:enumerable false :get (fn [] plugin-id)} - (nil? name) - (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + :id + {:get #(str (:id @data))} - :else - (js/Promise. - (fn [resolve reject] - (let [features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)] - (->> (rp/cmd! :create-file {:project-id project-id :name name :features features}) - (rx/tap (fn [file] - (st/emit! - (fn [state] - (if (= (:team-id file) (:current-team-id state)) - (ptk/update (dd/file-created file) state) - state))))) - (rx/map file-summary) - (rx/subs! resolve reject)))))))) + :isDefault + {:get #(boolean (:is-default @data))} + + :name + {:get #(:name @data) + :set + (fn [value] + (let [name (valid-name value)] + (cond + (not (r/check-permission plugin-id "manage:teams")) + (u/not-valid plugin-id :name "Plugin doesn't have 'manage:teams' permission") + + (nil? name) + (u/not-valid plugin-id :name value) + + :else + (set-property plugin-id data :name name + (dtm/update-team {:id (:id @data) :name name})))))} + + :listProjects + (fn [] (list-projects plugin-id (:id @data))) + + :listDeletedProjects + (fn [] (list-deleted-projects plugin-id (:id @data))) + + :listDeletedFiles + (fn [] (list-deleted-files plugin-id (:id @data) nil)) + + :createProject + (fn [options] (create-project plugin-id (:id @data) options))))) + +(defn project-proxy + [plugin-id project] + (let [data (atom project)] + (obj/reify {:name "ProjectProxy"} + :$plugin {:enumerable false :get (fn [] plugin-id)} + + :id + {:get #(str (:id @data))} + + :teamId + {:get #(str (:team-id @data))} + + :isDefault + {:get #(boolean (:is-default @data))} + + :fileCount + {:get #(or (:count @data) 0)} + + :name + {:get #(:name @data) + :set + (fn [value] + (let [name (valid-name value)] + (cond + (not (r/check-permission plugin-id "manage:projects")) + (u/not-valid plugin-id :name "Plugin doesn't have 'manage:projects' permission") + + (nil? name) + (u/not-valid plugin-id :name value) + + :else + (set-property plugin-id data :name name + (dd/rename-project {:id (:id @data) :name name})))))} + + :pinned + {:get #(boolean (:is-pinned @data)) + :set + (fn [value] + (cond + (not (r/check-permission plugin-id "manage:projects")) + (u/not-valid plugin-id :pinned "Plugin doesn't have 'manage:projects' permission") + + (not (boolean? value)) + (u/not-valid plugin-id :pinned value) + + :else + (set-property plugin-id data :is-pinned value + (set-project-pin {:id (:id @data) + :team-id (:team-id @data) + :is-pinned value}))))} + + :listFiles + (fn [] + (request plugin-id "content:read" + #(->> (rp/cmd! :get-project-files {:project-id (:id @data)}) + (rx/map (fn [files] + (into-array (map (fn [file] + (file-proxy plugin-id (assoc file :team-id (:team-id @data)))) + files))))))) + + :listDeletedFiles + (fn [] (list-deleted-files plugin-id (:team-id @data) (:id @data))) + + :restore + (fn [] + (request plugin-id "manage:projects" + #(let [{:keys [id team-id]} @data] + (->> (rp/cmd! :get-team-deleted-files {:team-id team-id}) + (rx/mapcat (fn [files] + (let [ids (into #{} (comp (filter (fn [file] (= id (:project-id file)))) + (map :id)) files)] + (if (seq ids) + (restore-deleted-files team-id ids) + (rx/throw (js/Error. "Cannot restore a project without recoverable files")))))) + (rx/tap (fn [_] (swap! data dissoc :deleted-at))) + (rx/mapcat (fn [_] (refresh-restored-project team-id id))))))) + + :createFile + (fn [options] + (if-let [name (valid-name (obj/get options "name"))] + (request plugin-id "content:write" + #(let [{:keys [id team-id]} @data + features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)] + (->> (rp/cmd! :create-file {:project-id id :name name :features features}) + (rx/map (fn [file] (-> (dissoc file :data) (assoc :team-id team-id)))) + (rx/tap (fn [file] + (update-dashboard team-id (fn [state] (ptk/update (dd/file-created file) state))))) + (rx/map (partial file-proxy plugin-id))))) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")))) + + :duplicate + (fn [options] + (let [name (obj/get options "name")] + (if (and (some? name) (nil? (valid-name name))) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + (request plugin-id "manage:projects" + #(let [{:keys [id team-id]} @data] + (->> (rp/cmd! :duplicate-project (cond-> {:project-id id} + (some? name) (assoc :name (valid-name name)))) + (rx/mapcat dd/project-with-file-count) + (rx/tap (fn [project] + (update-dashboard team-id (fn [state] (ptk/update (dd/project-duplicated project) state))))) + (rx/map (partial project-proxy plugin-id)))))))) + + :moveTo + (fn [team] + (if-let [team-id (uuid/parse* (obj/get team "id"))] + (request plugin-id "manage:teams" + #(let [{:keys [id] :as project} @data] + (->> (rp/cmd! :move-project {:project-id id :team-id team-id}) + (rx/tap (fn [_] + (swap! data assoc :team-id team-id) + (update-dashboard (:team-id project) (fn [state] (update state :projects dissoc id))))) + (rx/map (constantly nil))))) + (js/Promise.reject (js/Error. "Expected a team")))) + + :remove + (fn [] + (request plugin-id "manage:delete" + #(let [{:keys [id team-id]} @data] + (->> (rp/cmd! :delete-project {:id id}) + (rx/tap (fn [_] + (update-dashboard team-id (fn [state] (update state :projects dissoc id))))) + (rx/map (constantly nil))))))))) + +(defn file-proxy + [plugin-id file] + (let [data (atom file)] + (obj/reify {:name "ProjectFileProxy"} + :$plugin {:enumerable false :get (fn [] plugin-id)} + + :id + {:get #(str (:id @data))} + + :teamId + {:get #(str (:team-id @data))} + + :projectId + {:get #(str (:project-id @data))} + + :modifiedAt + {:get #(.toISOString ^js (:modified-at @data))} + + :name + {:get #(:name @data) + :set + (fn [value] + (let [name (valid-name value)] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") + + (nil? name) + (u/not-valid plugin-id :name value) + + :else + (set-property plugin-id data :name name + (dd/rename-file {:id (:id @data) :name name})))))} + + :shared + {:get #(boolean (:is-shared @data)) + :set + (fn [value] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :shared "Plugin doesn't have 'library:write' permission") + + (not (boolean? value)) + (u/not-valid plugin-id :shared value) + + :else + (set-property plugin-id data :is-shared value + (dd/set-file-shared {:id (:id @data) :is-shared value}))))} + + :open + (fn [] + (if (r/check-permission plugin-id "content:read") + (open-file (str (:id @data)) #js {:teamId (str (:team-id @data))}) + (js/Promise.reject (permission-error "content:read")))) + + :restore + (fn [] + (request plugin-id "content:write" + #(->> (restore-deleted-files (:team-id @data) #{(:id @data)}) + (rx/tap (fn [_] + (swap! data dissoc :deleted-at :will-be-deleted-at))) + (rx/mapcat (fn [_] (refresh-restored-project (:team-id @data) (:project-id @data))))))) + + :moveTo + (fn [project] + (if-let [project-id (uuid/parse* (obj/get project "id"))] + (request plugin-id "content:write" + #(let [{:keys [id team-id]} @data] + (->> (rp/cmd! :get-project {:id project-id}) + (rx/mapcat (fn [target] + (if (= team-id (:team-id target)) + (rp/cmd! :move-files {:ids #{id} :project-id project-id}) + (rx/throw (js/Error. "Cannot move a file to a project in another team"))))) + (rx/tap (fn [_] + (update-dashboard team-id (fn [state] (ptk/update (dd/move-files {:ids #{id} :project-id project-id}) state))) + (swap! data assoc :project-id project-id))) + (rx/map (constantly nil))))) + (js/Promise.reject (js/Error. "Expected a project")))) + + :duplicate + (fn [options] + (let [name (obj/get options "name")] + (if (and (some? name) (nil? (valid-name name))) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + (request plugin-id "content:write" + #(let [{:keys [id team-id]} @data] + (->> (rp/cmd! :duplicate-file (cond-> {:file-id id} + (some? name) (assoc :name (valid-name name)))) + (rx/map (fn [file] (-> (dissoc file :data) (assoc :team-id team-id)))) + (rx/tap (fn [file] + (update-dashboard team-id (fn [state] (ptk/update (dd/file-created file) state))))) + (rx/map (partial file-proxy plugin-id)))))))) + + :remove + (fn [] + (request plugin-id "manage:delete" + #(let [{:keys [id project-id team-id]} @data] + (->> (rp/cmd! :delete-file {:id id}) + (rx/tap (fn [_] + (update-dashboard team-id + (fn [state] + (->> state + (ptk/update (dd/delete-file {:id id :project-id project-id})) + (ptk/update (dd/file-deleted project-id))))))) + (rx/map (constantly nil))))))))) (defn create-context - [] + [plugin-id] (obj/reify {:name "PenpotManagementContext"} :workspace {:get (fn [] (clj->js (workspace-context @st/state)))} + :openFile open-file - :listProjects list-projects - :listFiles list-files - :createProject create-project - :createFile create-file)) + + :getFile + (fn [file-id] (get-file plugin-id file-id)) + + :listTeams + (fn [] + (request plugin-id "manage:teams" + #(->> (rp/cmd! :get-teams) + (rx/map (fn [teams] + (into-array (map (partial team-proxy plugin-id) teams))))))) + + :createTeam + (fn [options] + (if-let [name (valid-name (obj/get options "name"))] + (request plugin-id "manage:teams" + #(->> (rp/cmd! :create-team {:name name :features features/global-enabled-features}) + (rx/tap (fn [_] (st/emit! (dtm/fetch-teams)))) + (rx/map (partial team-proxy plugin-id)))) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")))) + + :listProjects + (fn [options] + (if-let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (list-projects plugin-id team-id) + (js/Promise.reject (js/Error. "Expected a team UUID")))) + + :listDeletedProjects + (fn [options] + (if-let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (list-deleted-projects plugin-id team-id) + (js/Promise.reject (js/Error. "Expected a team UUID")))) + + :listDeletedFiles + (fn [options] + (if-let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (list-deleted-files plugin-id team-id nil) + (js/Promise.reject (js/Error. "Expected a team UUID")))))) diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index 8b5bcc3bde..0c591eeb28 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -22,6 +22,11 @@ ;; cause a circular dependency (def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae") +(def mcp-permissions + #{"user:read" "library:read" "library:write" + "comment:read" "comment:write" "content:read" "content:write" + "allow:global" "manage:projects" "manage:teams" "manage:delete"}) + ;; Promise that resolves when plugins runtime is initialized. ;; Lives here to avoid circular dependency: workspace.mcp -> app.plugins -> app.plugins.api -> workspace (defonce ^:private runtime-ready-promise (p/deferred)) @@ -267,10 +272,11 @@ (defn check-permission [plugin-id permission] - (or (= plugin-id "00000000-0000-0000-0000-000000000000") - (= plugin-id mcp-plugin-id) - (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])] - (contains? permissions permission)))) + (cond + (= plugin-id "00000000-0000-0000-0000-000000000000") true + (= plugin-id mcp-plugin-id) (contains? mcp-permissions permission) + :else (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])] + (contains? permissions permission)))) (defn get-plugin-data [state plugin-id] diff --git a/frontend/test/frontend_tests/data/dashboard_test.cljs b/frontend/test/frontend_tests/data/dashboard_test.cljs index 12fefdb476..9d5fbd1ce3 100644 --- a/frontend/test/frontend_tests/data/dashboard_test.cljs +++ b/frontend/test/frontend_tests/data/dashboard_test.cljs @@ -19,6 +19,24 @@ [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) +(t/deftest ^:async duplicated-project-has-a-file-count-and-is-unpinned + (let [id (uuid/next) + copy-id (uuid/next) + team-id (uuid/next) + copied {:id copy-id :team-id team-id :name "Copy"} + state {:projects {id {:id id :name "Source" :count 9 :is-pinned true}}} + result (atom state)] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [cmd _] + (->> (rx/of (case cmd + :duplicate-project copied + :get-project-files [{:id (uuid/next)} {:id (uuid/next)}])) + (rx/observe-on :async))))} + (await (async/observe (ptk/watch (dd/duplicate-project {:id id :name "Source"}) state (rx/empty)) + :on-next #(swap! result (partial ptk/update %)))))) + (t/is (= (assoc copied :count 2 :is-pinned false) (get-in @result [:projects copy-id]))))) + (t/deftest moving-current-team-into-sso-organization-redirects (t/async done (let [team-id (uuid/next) diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index 70d3e3fb7d..25122a9104 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -239,19 +239,26 @@ team-id (uuid/next) library-id (uuid/next) stream (rx/subject) - events (atom [])] + events (atom []) + state (atom {:current-file-id file-id :current-team-id team-id})] (await (mock/with-mocks* - {st/state (atom {:current-file-id file-id :current-team-id team-id}) + {st/state state st/stream stream st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) rp/cmd! (shared-files-cmd [{:id library-id :name "Published"}]) r/check-permission (mock/stub (constantly true))} (let [context (library/library-subcontext plugin-id) - result (settled (.connectLibrary context (str library-id))) + promise (.connectLibrary context (str library-id)) + result (settled promise) + done? (atom false) link? #(= ::dwl/link-file-to-library (ptk/type %))] + (.then promise #(reset! done? true)) (await (async/wait-for #(some link? @events) "link event emitted")) (rx/push! stream (ptk/data-event ::dwl/link-file-to-library-finished)) + (await (async/settle)) + (t/is (false? @done?) "waits until the library data is loaded") + (swap! state assoc-in [:files library-id :data] {:id library-id}) (let [{:keys [resolved]} (await result) link-data (ev/-data (first (filter link? @events)))] (t/is (= library-id (obj/get resolved "$id"))) @@ -312,3 +319,25 @@ {:keys [rejected]} (await (settled (.connectLibrary context (str (uuid/next)))))] (t/is (str/includes? (str rejected) "Plugin doesn't have 'library:write' permission")) (t/is (empty? @mock/rpc-calls)))))) + +(t/deftest ^:async library-context-ignores-files-left-by-earlier-workspaces + (let [file-id (uuid/next) + linked-id (uuid/next) + visited-id (uuid/next) + other-id (uuid/next)] + (await + (mock/with-mocks* + {st/state (atom {:current-file-id file-id + :current-team-id (uuid/next) + :files {file-id {:id file-id :data {}} + linked-id {:id linked-id :data {}} + visited-id {:id visited-id}}}) + rp/cmd! (shared-files-cmd [{:id linked-id :name "Linked"} + {:id visited-id :name "Visited"} + {:id other-id :name "Other"}]) + r/check-permission (mock/stub (constantly true))} + (let [context (library/library-subcontext plugin-id)] + (t/is (= #{file-id linked-id} + (into #{} (map #(obj/get % "$id")) (.-connected context)))) + (t/is (= #{(str visited-id) (str other-id)} + (into #{} (map #(.-id %)) (await (.availableLibraries context)))))))))) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs index eb74e0b09d..75889e3ec5 100644 --- a/frontend/test/frontend_tests/plugins/management_test.cljs +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -7,18 +7,29 @@ (ns frontend-tests.plugins.management-test (:require [app.common.uuid :as uuid] + [app.main.data.dashboard :as dd] + [app.main.data.team :as dtm] [app.main.repo :as rp] [app.main.store :as st] [app.plugins.events :as events] [app.plugins.management :as management] + [app.plugins.register :as r] + [app.plugins.utils :as u] + [app.util.object :as obj] + [app.util.sse :as sse] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as a] - [frontend-tests.helpers.mock :as mock])) + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) (def file-id (uuid/next)) (def page-id (uuid/next)) (def team-id (uuid/next)) +(def project-id (uuid/next)) + +;; The zero plugin id always holds every permission. +(def plugin-id (str uuid/zero)) (def dashboard {:route {:data {:name :dashboard-recent}} @@ -34,9 +45,57 @@ (def ready (assoc loading :current-page-id page-id - :files {file-id {:id file-id :name "Design" + :files {file-id {:id file-id :name "Design" :project-id project-id :data {:pages-index {page-id {:id page-id :objects {}}}}}})) +(def team + {:id team-id :name "Team" :is-default true}) + +(def project + {:id project-id :team-id team-id :name "Drafts" :is-default true :is-pinned false :count 2}) + +(def file + {:id file-id :team-id team-id :project-id project-id :name "Design" + :is-shared false :modified-at (js/Date. "2026-01-02T03:04:05Z")}) + +(t/deftest teams-do-not-expose-deletion + (doseq [id [plugin-id r/mcp-plugin-id]] + (t/is (nil? (obj/get (management/team-proxy id team) "remove"))))) + +(defn- props + "Reads `keys` from a proxy into a map." + [proxy keys] + (into {} (map (fn [k] [k (obj/get proxy k)])) keys)) + +(defn- message + "Resolves with the rejection message of `promise`, or nil when it resolves." + [promise] + (.then promise (fn [] nil) #(.-message %))) + +(defn- cmd-stub + "Mock `rp/cmd!` recording calls in `requests` and answering with `respond`." + [requests respond] + (mock/stub + (fn + ([command] (swap! requests conj [command]) (respond command nil)) + ([command params] (swap! requests conj [command params]) (respond command params))))) + +(defn- emit-stub + "Mock `st/emit!` applying update functions to `state` and recording events." + [state events] + (mock/stub + (fn [event] + (if (fn? event) + (swap! state event) + (do (swap! events conj event) + (when (satisfies? ptk/UpdateEvent event) + (swap! state (partial ptk/update event)))))))) + +(defn- throwing + "Makes validation errors of the test plugin throw." + [state] + (assoc-in state [:plugins :flags plugin-id :throw-validation-errors] true)) + (t/deftest workspace-readiness-requires-an-initialized-page (t/is (= "none" (:status (management/workspace-context dashboard)))) (t/is (= "loading" (:status (management/workspace-context loading)))) @@ -57,7 +116,7 @@ result (-> (management/open-file (str file-id) nil) (.then #(reset! done? true)))] (try - (await (a/wait-for #(= ["loading"] @seen))) + (await (a/wait-for #(= ["loading"] @seen) "workspace loading")) (t/is (false? @done?)) (reset! state ready) (await result) @@ -73,208 +132,435 @@ {st/state state st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0)) ([_ & _] nil))} - (let [result (-> (management/open-file (str file-id) nil) - (.then (fn [] nil) (fn [error] (.-message error))))] - (await (a/wait-for #(= loading @state))) + (let [result (message (management/open-file (str file-id) nil))] + (await (a/wait-for #(= loading @state) "navigation started")) (reset! state {:route {:data {:name :settings-profile}}}) (t/is (= "File navigation was superseded" (await result)))))))) (t/deftest ^:async open-file-rejects-invalid-identifiers - (let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)}) - (.then (fn [] nil) (fn [error] (.-message error))))] - (t/is (= "Expected a file UUID and a team UUID" (await result))))) + (t/is (= "Expected a file UUID and a team UUID" + (await (message (management/open-file "invalid" #js {:teamId (str team-id)})))))) -(t/deftest ^:async list-projects-returns-active-metadata - (let [project-id (uuid/next) - other-team-id (uuid/next)] - (doseq [[state options expected-team-id] - [[dashboard nil team-id] - [ready #js {:teamId (str other-team-id)} other-team-id]]] - (let [state* (atom state)] - (await - (mock/with-mocks* - {st/state state* - rp/cmd! (mock/stub - (fn [command params] - (t/is (= [:get-projects {:team-id expected-team-id}] - [command params])) - (rx/of [{:id project-id :team-id expected-team-id - :name "Drafts" :is-default true :count 2 :total-count 3} - {:id (uuid/next) :deleted-at (js/Date.)}])))} - (let [projects (await (management/list-projects options))] - (t/is (= [{"id" (str project-id) "teamId" (str expected-team-id) - "name" "Drafts" "isDefault" true "fileCount" 2}] - (js->clj projects))) - (t/is (= state @state*))))))))) - -(t/deftest ^:async list-files-returns-metadata-without-opening-a-file - (let [project-id (uuid/next)] - (doseq [state [dashboard ready]] - (let [state* (atom state)] - (await - (mock/with-mocks* - {st/state state* - rp/cmd! (mock/stub - (fn [command params] - (t/is (= [:get-project-files {:project-id project-id}] - [command params])) - (rx/of [{:id file-id :team-id team-id :project-id project-id - :name "Design" :modified-at (js/Date. "2026-01-02T03:04:05Z") - :revn 10}])))} - (let [files (await (management/list-files #js {:projectId (str project-id)}))] - (t/is (= [{"id" (str file-id) "teamId" (str team-id) - "projectId" (str project-id) "name" "Design" - "modifiedAt" "2026-01-02T03:04:05.000Z"}] - (js->clj files))) - (t/is (= state @state*))))))))) - -(t/deftest ^:async listing-rejects-invalid-identifiers-without-querying - (await - (mock/with-mocks* - {st/state (atom {}) - rp/cmd! (mock/stub (fn [_ _] (t/is false "Invalid IDs must not query the backend")))} - (doseq [[result message] - [[(management/list-projects nil) "Expected a team UUID"] - [(management/list-projects #js {:teamId "invalid"}) "Expected a team UUID"] - [(management/list-files nil) "Expected a project UUID"] - [(management/list-files #js {:projectId "invalid"}) "Expected a project UUID"]]] - (t/is (= message (await (.then result (fn [] nil) #(.-message %))))))))) - -(t/deftest ^:async listing-preserves-empty-results-and-backend-errors - (doseq [list-items [(fn [] (management/list-projects #js {:teamId (str team-id)})) - (fn [] (management/list-files #js {:projectId (str (uuid/next))}))]] - (await - (mock/with-mocks* - {rp/cmd! (mock/stub (fn [_ _] (rx/of [])))} - (t/is (= [] (js->clj (await (list-items))))))) - (let [error (js/Error. "Access denied")] - (await - (mock/with-mocks* - {rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))} - (t/is (identical? error (await (.then (list-items) (fn [] nil) identity))))))))) - -(t/deftest ^:async create-project-returns-metadata-and-updates-only-the-current-team - (let [project-id (uuid/next) - other-team-id (uuid/next)] - (doseq [[initial options target-team-id] - [[dashboard #js {:name " Buttons "} team-id] - [ready #js {:name " Buttons " :teamId (str other-team-id)} other-team-id]]] - (let [state (atom initial) - requests (atom []) - project {:id project-id :team-id target-team-id :name "Buttons" - :is-default false :is-pinned false}] - (await - (mock/with-mocks* - {st/state state - st/emit! (mock/stub (fn [event] (swap! state event))) - rp/cmd! (mock/stub - (fn [command params] - (swap! requests conj [command params]) - (->> (rx/of project) (rx/observe-on :async))))} - (let [context (management/create-context) - result (await (.createProject context options))] - (t/is (= {"id" (str project-id) "teamId" (str target-team-id) - "name" "Buttons" "isDefault" false "fileCount" 0} - (js->clj result))) - (t/is (= [[:create-project {:team-id target-team-id :name "Buttons"}]] @requests)) - (t/is (= (if (= team-id target-team-id) - (assoc-in initial [:projects project-id] (assoc project :count 0)) - initial) - @state))))))))) - -(t/deftest ^:async create-file-returns-metadata-and-updates-the-dashboard-without-navigation - (let [project-id (uuid/next) - created-id (uuid/next) - modified-at (js/Date. "2026-01-02T03:04:05Z")] - (doseq [initial [dashboard ready]] - (let [initial (assoc initial - :features #{"components/v2" "styles/v2" "plugins/runtime"} - :projects {project-id {:id project-id :team-id team-id :count 2}}) - state (atom initial) - requests (atom []) - file {:id created-id :team-id team-id :project-id project-id - :name "Login" :modified-at modified-at :data {:pages []}}] - (await - (mock/with-mocks* - {st/state state - st/emit! (mock/stub (fn [event] (swap! state event))) - rp/cmd! (mock/stub - (fn [command params] - (swap! requests conj [command params]) - (->> (rx/of file) (rx/observe-on :async))))} - (let [context (management/create-context) - result (await (.createFile context #js {:projectId (str project-id) :name " Login " - :id "ignored" :isShared true}))] - (t/is (= {"id" (str created-id) "teamId" (str team-id) - "projectId" (str project-id) "name" "Login" - "modifiedAt" "2026-01-02T03:04:05.000Z"} - (js->clj result))) - (t/is (= [[:create-file {:project-id project-id :name "Login" :features #{"components/v2"}}]] - @requests)) - (t/is (= (-> initial - (assoc-in [:files created-id] (dissoc file :data)) - (assoc-in [:recent-files created-id] (dissoc file :data)) - (update-in [:projects project-id :count] inc)) - @state))))))))) - -(t/deftest ^:async creation-rejects-invalid-input-before-querying - (let [project-id (uuid/next) - requests (atom [])] +(t/deftest ^:async listing-returns-team-project-and-file-objects + (let [requests (atom []) + other-id (uuid/next)] (await (mock/with-mocks* {st/state (atom dashboard) + rp/cmd! (cmd-stub requests + (fn [command _] + (rx/of (case command + :get-teams [team] + :get-projects [project {:id other-id :deleted-at (js/Date.)}] + :get-project-files [(dissoc file :team-id)]))))} + (let [context (management/create-context plugin-id) + [team*] (await (.listTeams context)) + [project*] (await (.listProjects context)) + team-project (first (await (.listProjects team*))) + [file*] (await (.listFiles project*))] + (t/is (= {"id" (str team-id) "name" "Team" "isDefault" true} + (props team* ["id" "name" "isDefault"]))) + (t/is (= {"id" (str project-id) "teamId" (str team-id) "name" "Drafts" + "isDefault" true "pinned" false "fileCount" 2} + (props project* ["id" "teamId" "name" "isDefault" "pinned" "fileCount"]))) + (t/is (= (str project-id) (obj/get team-project "id"))) + (t/is (= {"id" (str file-id) "teamId" (str team-id) "projectId" (str project-id) + "name" "Design" "shared" false "modifiedAt" "2026-01-02T03:04:05.000Z"} + (props file* ["id" "teamId" "projectId" "name" "shared" "modifiedAt"]))) + (t/is (= [[:get-teams] + [:get-projects {:team-id team-id}] + [:get-projects {:team-id team-id}] + [:get-project-files {:project-id project-id}]] + @requests))))))) + +(t/deftest ^:async get-file-reads-loaded-files-and-fetches-the-rest + (let [other-id (uuid/next)] + (doseq [[state id expected] + [[ready file-id + [[:get-project {:id project-id}]]] + [dashboard other-id + [[:get-file {:id other-id :features nil}] + [:get-project {:id project-id}]]]]] + (let [requests (atom [])] + (await + (mock/with-mocks* + {st/state (atom state) + rp/cmd! (cmd-stub requests + (fn [command _] + (rx/of (case command + :get-file (assoc file :id other-id :data {}) + :get-project project))))} + (let [file* (await (.getFile (management/create-context plugin-id) (str id)))] + (t/is (= {"id" (str id) "teamId" (str team-id) "projectId" (str project-id)} + (props file* ["id" "teamId" "projectId"]))) + (t/is (= expected @requests))))))))) + +(t/deftest ^:async listing-rejects-invalid-identifiers-and-preserves-backend-errors + (let [requests (atom [])] + (await + (mock/with-mocks* + {st/state (atom {}) + rp/cmd! (cmd-stub requests (fn [_ _] (rx/of nil)))} + (let [context (management/create-context plugin-id)] + (t/is (= "Expected a team UUID" (await (message (.listProjects context nil))))) + (t/is (= "Expected a team UUID" (await (message (.listProjects context #js {:teamId "invalid"}))))) + (t/is (= "Expected a file UUID" (await (message (.getFile context "invalid"))))) + (await (a/settle)) + (t/is (empty? @requests)))))) + (let [error (js/Error. "Access denied")] + (await + (mock/with-mocks* + {st/state (atom dashboard) + rp/cmd! (mock/stub (fn [& _] (rx/throw error)))} + (let [context (management/create-context plugin-id)] + (t/is (identical? error (await (.then (.listTeams context) (fn [] nil) identity)))) + (t/is (identical? error (await (.then (.listProjects context) (fn [] nil) identity))))))))) + +(t/deftest ^:async teams-are-created + (let [requests (atom []) + events (atom []) + state (atom dashboard) + created {:id (uuid/next) :name "Brand" :is-default false}] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state events) + rp/cmd! (cmd-stub requests (fn [& _] (->> (rx/of created) (rx/observe-on :async))))} + (let [team* (await (.createTeam (management/create-context plugin-id) #js {:name " Brand "}))] + (t/is (= {"id" (str (:id created)) "name" "Brand" "isDefault" false} + (props team* ["id" "name" "isDefault"]))) + (t/is (= [[:create-team "Brand"]] + (mapv (fn [[command params]] [command (:name params)]) @requests))) + (t/is (= 1 (count (filter #(= ::dtm/fetch-teams (ptk/type %)) @events)))) + (t/is (= "Expected a name with 1 to 250 characters" + (await (message (.createTeam (management/create-context plugin-id) #js {:name ""})))))))))) + +(t/deftest ^:async setters-update-values-after-the-backend-succeeds + (let [state (atom (throwing dashboard)) + events (atom [])] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state events) + rp/cmd! (mock/stub (fn [& _] (->> (rx/of {}) (rx/observe-on :async))))} + (let [team* (management/team-proxy plugin-id team) + project* (management/project-proxy plugin-id project) + file* (management/file-proxy plugin-id file)] + (obj/set! team* "name" " Brand ") + (obj/set! project* "name" "Icons") + (obj/set! project* "pinned" true) + (obj/set! file* "name" "Login") + (obj/set! file* "shared" true) + (t/is (= "Team" (obj/get team* "name"))) + (doseq [event @events] + (await (a/observe (ptk/watch event @state (rx/empty)) + :on-next #(when (fn? %) (swap! state %))))) + (t/is (= "Brand" (obj/get team* "name"))) + (t/is (= "Icons" (obj/get project* "name"))) + (t/is (true? (obj/get project* "pinned"))) + (t/is (= "Login" (obj/get file* "name"))) + (t/is (true? (obj/get file* "shared"))) + (t/is (= 5 (count @events)))))))) + +(t/deftest ^:async stale-object-setter-errors-stay-out-of-the-global-handler + (doseq [[constructor property value original] + [[#(management/team-proxy plugin-id team) "name" "New team" "Team"] + [#(management/project-proxy plugin-id project) "name" "New project" "Drafts"] + [#(management/project-proxy plugin-id project) "pinned" true false] + [#(management/file-proxy plugin-id file) "name" "New file" "Design"] + [#(management/file-proxy plugin-id file) "shared" true false]]] + (let [state (atom (throwing ready)) + events (atom []) + reports (atom []) + escaped (atom [])] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state events) + rp/cmd! (mock/stub (fn [& _] + (->> (rx/throw (ex-info "http error" {:type :not-found :code :object-not-found})) + (rx/observe-on :async)))) + u/display-not-valid (mock/stub (fn [code value] (swap! reports conj [code value])))} + (let [proxy (constructor)] + (obj/set! proxy property value) + (doseq [event @events] + (await (a/observe (ptk/watch event @state (rx/empty)) + :on-next #(when (fn? %) (swap! state %)) + :on-error #(swap! escaped conj %)))) + (t/is (empty? @escaped) property) + (t/is (= original (obj/get proxy property)) property) + (t/is (= (throwing ready) @state)) + (t/is (= 1 (count @reports)) property))))))) + +(t/deftest setters-reject-invalid-values-and-missing-permissions + (let [state (atom (throwing dashboard)) + events (atom [])] + (with-redefs [st/state state + st/emit! (emit-stub state events)] + (let [team* (management/team-proxy plugin-id team) + project* (management/project-proxy plugin-id project) + file* (management/file-proxy plugin-id file)] + (doseq [[proxy key value] [[team* "name" " "] + [project* "name" (apply str (repeat 251 "a"))] + [project* "pinned" "yes"] + [file* "name" nil] + [file* "shared" 1]]] + (t/is (thrown? js/Error (obj/set! proxy key value)) key)) + (with-redefs [r/check-permission (mock/stub (constantly false))] + (doseq [[proxy key value permission] [[team* "name" "Brand" "manage:teams"] + [project* "name" "Icons" "manage:projects"] + [project* "pinned" true "manage:projects"] + [file* "name" "Login" "content:write"] + [file* "shared" true "library:write"]]] + (t/is (thrown-with-msg? js/Error (re-pattern permission) (obj/set! proxy key value))))) + (t/is (= "Team" (obj/get team* "name"))) + (t/is (= "Design" (obj/get file* "name"))) + (t/is (empty? @events)))))) + +(t/deftest ^:async creation-returns-objects-and-updates-only-the-dashboard + (let [created-project {:id (uuid/next) :team-id team-id :name "Icons" :is-default false} + created-file (assoc file :id (uuid/next) :name "Login" :data {})] + (doseq [initial [dashboard ready]] + (let [state (atom (assoc initial :projects {project-id project})) + requests (atom [])] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (cmd-stub requests + (fn [command _] + (->> (rx/of (case command + :create-project created-project + :create-file created-file)) + (rx/observe-on :async))))} + (let [project* (await (.createProject (management/team-proxy plugin-id team) #js {:name " Icons "})) + parent (management/project-proxy plugin-id project) + file* (await (.createFile parent #js {:name "Login"}))] + (t/is (= {"name" "Icons" "fileCount" 0} (props project* ["name" "fileCount"]))) + (t/is (= {"name" "Login" "teamId" (str team-id)} (props file* ["name" "teamId"]))) + (t/is (= 2 (obj/get parent "fileCount"))) + (t/is (= [:create-project {:team-id team-id :name "Icons"}] (first @requests))) + (if (= initial dashboard) + (do (t/is (contains? (:projects @state) (:id created-project))) + (t/is (contains? (:files @state) (:id created-file)))) + (t/is (= (assoc ready :projects {project-id project}) @state)))))))))) + +(t/deftest ^:async file-and-project-methods-call-the-backend + (let [requests (atom []) + other-id (uuid/next) + copy (assoc file :id (uuid/next) :name "Design copy") + project-2 (assoc project :id other-id :is-default false)] + (await + (mock/with-mocks* + {st/state (atom dashboard) + st/emit! (mock/stub (fn [_] nil)) + rp/cmd! (cmd-stub requests + (fn [command _] + (rx/of (case command + :duplicate-file copy + :duplicate-project (dissoc project-2 :count :is-pinned) + :get-project-files [file file] + :get-project project-2 + nil))))} + (let [file* (management/file-proxy plugin-id file) + project* (management/project-proxy plugin-id project-2) + team* (management/team-proxy plugin-id (assoc team :id other-id)) + duplicate (await (.duplicate file* #js {:name "Design copy"}))] + (t/is (= "Design copy" (obj/get duplicate "name"))) + (t/is (= {"name" "Drafts" "fileCount" 2 "pinned" false} + (props (await (.duplicate project*)) ["name" "fileCount" "pinned"]))) + (await (.moveTo file* project*)) + (t/is (= (str other-id) (obj/get file* "projectId"))) + (await (.moveTo project* team*)) + (t/is (= (str other-id) (obj/get project* "teamId"))) + (await (.remove file*)) + (await (.remove project*)) + (t/is (= [[:duplicate-file {:file-id file-id :name "Design copy"}] + [:duplicate-project {:project-id other-id}] + [:get-project-files {:project-id other-id}] + [:get-project {:id other-id}] + [:move-files {:ids #{file-id} :project-id other-id}] + [:move-project {:project-id other-id :team-id other-id}] + [:delete-file {:id file-id}] + [:delete-project {:id other-id}]] + @requests))))))) + +(t/deftest ^:async methods-reject-invalid-input-and-missing-permissions + (let [requests (atom [])] + (await + (mock/with-mocks* + {st/state (atom dashboard) + rp/cmd! (cmd-stub requests (fn [_ _] (rx/of nil)))} + (let [team* (management/team-proxy plugin-id team) + project* (management/project-proxy plugin-id project) + file* (management/file-proxy plugin-id file)] + (t/is (= "Expected a name with 1 to 250 characters" (await (message (.createProject team* #js {:name ""}))))) + (t/is (= "Expected a name with 1 to 250 characters" (await (message (.createFile project* nil))))) + (t/is (= "Expected a name with 1 to 250 characters" (await (message (.duplicate file* #js {:name " "}))))) + (t/is (= "Expected a project" (await (message (.moveTo file* #js {:id "invalid"}))))) + (t/is (= "Expected a team" (await (message (.moveTo project* nil))))) + (with-redefs [r/check-permission (mock/stub (constantly false))] + (doseq [[promise permission] [[(.createTeam (management/create-context plugin-id) #js {:name "Brand"}) "manage:teams"] + [(.listProjects team*) "content:read"] + [(.createProject team* #js {:name "Icons"}) "manage:projects"] + [(.createFile project* #js {:name "Login"}) "content:write"] + [(.duplicate project*) "manage:projects"] + [(.moveTo project* team*) "manage:teams"] + [(.remove project*) "manage:delete"] + [(.open file*) "content:read"] + [(.duplicate file*) "content:write"] + [(.moveTo file* project*) "content:write"] + [(.remove file*) "manage:delete"]]] + (t/is (= (str "Permission " permission " is not granted") (await (message promise)))))) + (await (a/settle)) + (t/is (empty? @requests))))))) + +(t/deftest ^:async file-move-rejects-a-project-that-now-belongs-to-another-team + (let [state (atom ready) + requests (atom []) + target (assoc project :id (uuid/next))] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (cmd-stub requests + (fn [_ _] + (->> (rx/of (assoc target :team-id (uuid/next))) + (rx/observe-on :async))))} + (let [file* (management/file-proxy plugin-id file)] + (t/is (= "Cannot move a file to a project in another team" + (await (message (.moveTo file* (management/project-proxy plugin-id target)))))) + (t/is (= (str project-id) (obj/get file* "projectId"))) + (t/is (= (str team-id) (obj/get file* "teamId"))) + (t/is (= ready @state)) + (t/is (= [[:get-project {:id (:id target)}]] @requests))))))) + +(t/deftest ^:async backend-rejections-include-the-type-and-code + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [& _] + (->> (rx/throw (ex-info "http error" {:type :validation + :code :project-already-in-team + :hint "Project is already in this team"})) + (rx/observe-on :async))))} + (t/is (= "Project is already in this team (validation/project-already-in-team)" + (await (message (.moveTo (management/project-proxy plugin-id project) + (management/team-proxy plugin-id team))))))))) + +(t/deftest ^:async file-count-stays-at-its-listing-snapshot-after-creation-and-deletion + (let [state (atom (assoc dashboard :projects {project-id project}))] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (mock/stub (fn [command _] + (->> (rx/of (when (= command :create-file) file)) + (rx/observe-on :async))))} + (let [project* (management/project-proxy plugin-id project) + file* (await (.createFile project* #js {:name "Design"}))] + (t/is (= 2 (obj/get project* "fileCount"))) + (t/is (= 3 (get-in @state [:projects project-id :count]))) + (await (.remove file*)) + (t/is (= 2 (obj/get project* "fileCount"))) + (t/is (= 2 (get-in @state [:projects project-id :count])))))))) + +(t/deftest ^:async mcp-can-delete-projects-and-files-without-an-extra-grant + (let [state (atom ready) + requests (atom [])] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (cmd-stub requests (fn [& _] (->> (rx/of nil) (rx/observe-on :async))))} + (doseq [proxy [(management/project-proxy r/mcp-plugin-id project) + (management/file-proxy r/mcp-plugin-id file)]] + (t/is (nil? (await (.remove proxy))))) + (t/is (= [[:delete-project {:id project-id}] [:delete-file {:id file-id}]] @requests)))))) + +(t/deftest ^:async deleted-objects-can-be-listed-and-restored + (let [state (atom ready) + deleted-project (assoc project :deleted-at (js/Date. "2099-01-01")) + deleted-file (assoc file :will-be-deleted-at (js/Date. "2099-01-01"))] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (mock/stub (fn [command _] + (->> (rx/of (case command + :get-projects [deleted-project] + :get-team-deleted-files [deleted-file] + ::sse/restore-deleted-team-files #js {:type "end" :data #{file-id}})) + (rx/observe-on :async))))} + (let [[project*] (await (.listDeletedProjects (management/create-context r/mcp-plugin-id))) + [file*] (await (.listDeletedFiles project*))] + (t/is (= (str project-id) (obj/get project* "id"))) + (t/is (= (str file-id) (obj/get file* "id"))) + (t/is (nil? (await (.restore project*)))) + (t/is (nil? (await (.restore file*))))))))) + +(t/deftest ^:async restoration-refreshes-dashboard-projects-and-trash + (doseq [constructor [management/project-proxy management/file-proxy]] + (let [restored? (atom false) + state (atom (assoc dashboard + :projects {project-id (assoc project :deleted-at (js/Date. "2099-01-01"))} + :deleted-files {file-id file}))] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (mock/stub (fn [command _] + (->> (rx/of (case command + :get-projects [(assoc project :count 1)] + :get-project-files [file] + :get-team-deleted-files (if @restored? [] [file]) + ::sse/restore-deleted-team-files + (do (reset! restored? true) #js {:type "end" :data #{file-id}}))) + (rx/observe-on :async))))} + (await (.restore (constructor plugin-id (if (= constructor management/project-proxy) project file)))) + (t/is (nil? (get-in @state [:projects project-id :deleted-at]))) + (t/is (= 1 (get-in @state [:projects project-id :count]))) + (t/is (= file (get-in @state [:files file-id]))) + (t/is (empty? (:deleted-files @state)))))))) + +(t/deftest ^:async restoration-waits-for-the-trash-operation-to-finish + (let [state (atom ready) + response (rx/subject) + completed? (atom false)] + (await + (mock/with-mocks* + {st/state state rp/cmd! (mock/stub (fn [command params] - (swap! requests conj [command params]) - (->> (rx/of nil) (rx/observe-on :async))))} - (let [context (management/create-context)] - (doseq [options [nil #js {} #js {:name nil} #js {:name 1} - #js {:name ""} #js {:name " \n "} - #js {:name (apply str (repeat 251 "a"))}]] - (t/is (= "Expected a name with 1 to 250 characters" - (await (.then (.createProject context options) (fn [] nil) #(.-message %))))) - (let [options (js/Object.assign #js {:projectId (str project-id)} options)] - (t/is (= "Expected a name with 1 to 250 characters" - (await (.then (.createFile context options) (fn [] nil) #(.-message %))))))) - (doseq [[create options message] - [[#(.createProject context %) #js {:name "Buttons" :teamId "invalid"} "Expected a team UUID"] - [#(.createFile context %) #js {:name "Login"} "Expected a project UUID"] - [#(.createFile context %) #js {:name "Login" :projectId "invalid"} "Expected a project UUID"]]] - (t/is (= message (await (.then (create options) (fn [] nil) #(.-message %))))))) - (await (a/settle)) - (t/is (empty? @requests)))))) + (t/is (= ::sse/restore-deleted-team-files command)) + (t/is (= {:team-id team-id :ids #{file-id}} params)) + (rx/observe-on :async response)))} + (let [promise (.then (.restore (management/file-proxy plugin-id file)) + #(reset! completed? true))] + (rx/push! response #js {:type "progress" :data {:file-id file-id}}) + (await (a/settle)) + (t/is (false? @completed?)) + (rx/push! response #js {:type "end" :data #{file-id}}) + (await promise) + (t/is (true? @completed?))))))) -(t/deftest ^:async creation-preserves-backend-errors-and-state - (doseq [method ["createProject" "createFile"]] - (let [state (atom dashboard) - error (js/Error. "Access denied")] +(t/deftest ^:async restoration-reports-trash-errors-without-changing-the-workspace + (doseq [response [(rx/throw (ex-info "stream exception" {:type :not-found :code :object-not-found})) + (rx/of #js {:type "end" :data #{}}) + (rx/of #js {:type "progress" :data {}})]] + (let [state (atom ready)] (await (mock/with-mocks* {st/state state - st/emit! (mock/stub (fn [event] (swap! state event))) - rp/cmd! (mock/stub (fn [_ _] (->> (rx/throw error) (rx/observe-on :async))))} - (let [context (management/create-context) - options #js {:name "Design" :projectId (str (uuid/next))} - result (.call (aget context method) context options)] - (t/is (identical? error (await (.then result (fn [] nil) identity)))) - (t/is (= dashboard @state)))))))) + rp/cmd! (mock/stub (fn [_ _] (rx/observe-on :async response)))} + (t/is (string? (await (message (.restore (management/file-proxy plugin-id file)))))) + (t/is (= ready @state))))))) -(t/deftest ^:async creation-does-not-add-results-to-a-different-team-after-navigation - (doseq [method ["createProject" "createFile"]] - (let [state (atom dashboard) - response (rx/subject) - other (assoc dashboard :current-team-id (uuid/next)) - item-id (uuid/next) - item {:id item-id :team-id team-id :project-id (uuid/next) - :name "Design" :modified-at (js/Date.)}] - (await - (mock/with-mocks* - {st/state state - st/emit! (mock/stub (fn [event] (swap! state event))) - rp/cmd! (mock/stub (fn [_ _] (->> response (rx/observe-on :async))))} - (let [context (management/create-context) - result (.call (aget context method) context - #js {:name "Design" :projectId (str (:project-id item))})] - (reset! state other) - (rx/push! response item) - (t/is (= (str item-id) (.-id (await result)))) - (t/is (= other @state)))))))) +(t/deftest ^:async projects-without-recoverable-files-cannot-be-restored + (let [state (atom ready)] + (await + (mock/with-mocks* + {st/state state + rp/cmd! (mock/stub (fn [command _] + (t/is (= :get-team-deleted-files command)) + (->> (rx/of []) (rx/observe-on :async))))} + (t/is (= "Cannot restore a project without recoverable files" + (await (message (.restore (management/project-proxy plugin-id project)))))))))) diff --git a/frontend/test/frontend_tests/plugins/register_test.cljs b/frontend/test/frontend_tests/plugins/register_test.cljs index 49c387d4bc..a4399ab6af 100644 --- a/frontend/test/frontend_tests/plugins/register_test.cljs +++ b/frontend/test/frontend_tests/plugins/register_test.cljs @@ -12,6 +12,12 @@ [cljs.test :as t :include-macros true] [frontend-tests.helpers.mock :as mock])) +(t/deftest mcp-has-default-management-permissions + (t/is (preg/check-permission preg/mcp-plugin-id "manage:delete")) + (t/is (preg/check-permission preg/mcp-plugin-id "manage:teams")) + (t/is (preg/check-permission preg/mcp-plugin-id "content:write")) + (t/is (not (preg/check-permission preg/mcp-plugin-id "unknown:permission")))) + (defn- record-cmd-mock "Mock rp/cmd! that records calls in mock/rpc-calls and answers with (response-fn cmd params)." diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 0be9173e33..68e9f34c95 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8261,6 +8261,18 @@ msgstr "Read your libraries and assets." msgid "workspace.plugins.permissions.library-write" msgstr "Read and modify your libraries and assets." +#: src/app/main/ui/workspace/plugins.cljs:360 +msgid "workspace.plugins.permissions.manage-delete" +msgstr "Delete your files and projects." + +#: src/app/main/ui/workspace/plugins.cljs:346 +msgid "workspace.plugins.permissions.manage-projects" +msgstr "Create, rename, pin and duplicate your projects." + +#: src/app/main/ui/workspace/plugins.cljs:353 +msgid "workspace.plugins.permissions.manage-teams" +msgstr "See, create and rename your teams, and move projects between them." + #: src/app/main/ui/workspace/plugins.cljs:387 msgid "workspace.plugins.permissions.title" msgstr "'%s' PLUGIN WANTS ACCESS TO:" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 5715b6bba1..103b2725b3 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8356,6 +8356,18 @@ msgstr "Leer la información de sus bibliotecas y recursos." msgid "workspace.plugins.permissions.library-write" msgstr "Leer y modificar la información de sus bibliotecas y recursos." +#: src/app/main/ui/workspace/plugins.cljs:360 +msgid "workspace.plugins.permissions.manage-delete" +msgstr "Eliminar tus archivos y proyectos." + +#: src/app/main/ui/workspace/plugins.cljs:346 +msgid "workspace.plugins.permissions.manage-projects" +msgstr "Crear, renombrar, fijar y duplicar tus proyectos." + +#: src/app/main/ui/workspace/plugins.cljs:353 +msgid "workspace.plugins.permissions.manage-teams" +msgstr "Ver, crear y renombrar tus equipos, y mover proyectos entre ellos." + #: src/app/main/ui/workspace/plugins.cljs:387 msgid "workspace.plugins.permissions.title" msgstr "LA EXTENSIÓN '%s' SOLICITA PERMISO PARA ACCEDER:" diff --git a/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts b/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts index 40295aee4a..41323fbdbc 100644 --- a/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts +++ b/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { ExecuteCodeTaskConsole } from "./task-handlers/ExecuteCodeTaskHandler.ts"; +import { ExecuteCodeTaskConsole } from "./ExecuteCodeTaskConsole.ts"; test("formats plain objects and primitives", () => { const console = new ExecuteCodeTaskConsole(); diff --git a/mcp/packages/plugin/src/ExecuteCodeTaskConsole.ts b/mcp/packages/plugin/src/ExecuteCodeTaskConsole.ts new file mode 100644 index 0000000000..b17e8c3b87 --- /dev/null +++ b/mcp/packages/plugin/src/ExecuteCodeTaskConsole.ts @@ -0,0 +1,171 @@ +/** + * Console implementation that captures all log output for code execution. + * + * Provides the same interface as the native console object but appends + * all output to an internal log string that can be retrieved. + */ +export class ExecuteCodeTaskConsole { + /** + * Accumulated log output from all console method calls. + */ + private logOutput: string = ""; + + /** + * Resets the accumulated log output to empty string. + * Should be called before each code execution to start with clean logs. + */ + resetLog(): void { + this.logOutput = ""; + } + + /** + * Gets the accumulated log output from all console method calls. + * @returns The complete log output as a string + */ + getLog(): string { + return this.logOutput; + } + + /** + * Formats one argument of a captured console call. + * Errors have no enumerable fields, and values such as circular objects + * cannot be serialized to JSON, so fall back to their string form. + */ + private formatLogArgument(arg: unknown): string { + if (arg instanceof Error) { + return String(arg); + } + if (typeof arg === "object") { + try { + return JSON.stringify(arg, null, 2); + } catch { + return String(arg); + } + } + return String(arg); + } + + /** + * Appends a formatted message to the log output. + * @param level - Log level prefix (e.g., "LOG", "WARN", "ERROR") + * @param args - Arguments to log, will be stringified and joined + */ + private appendToLog(level: string, ...args: any[]): void { + const message = args.map((arg) => this.formatLogArgument(arg)).join(" "); + this.logOutput += `[${level}] ${message}\n`; + } + + /** + * Logs a message to the captured output. + */ + log(...args: any[]): void { + this.appendToLog("LOG", ...args); + } + + /** + * Logs a warning message to the captured output. + */ + warn(...args: any[]): void { + this.appendToLog("WARN", ...args); + } + + /** + * Logs an error message to the captured output. + */ + error(...args: any[]): void { + this.appendToLog("ERROR", ...args); + } + + /** + * Logs an informational message to the captured output. + */ + info(...args: any[]): void { + this.appendToLog("INFO", ...args); + } + + /** + * Logs a debug message to the captured output. + */ + debug(...args: any[]): void { + this.appendToLog("DEBUG", ...args); + } + + /** + * Logs a message with trace information to the captured output. + */ + trace(...args: any[]): void { + this.appendToLog("TRACE", ...args); + } + + /** + * Logs a table to the captured output (simplified as JSON). + */ + table(data: any): void { + this.appendToLog("TABLE", data); + } + + /** + * Starts a timer (simplified implementation that just logs). + */ + time(label?: string): void { + this.appendToLog("TIME", `Timer started: ${label || "default"}`); + } + + /** + * Ends a timer (simplified implementation that just logs). + */ + timeEnd(label?: string): void { + this.appendToLog("TIME_END", `Timer ended: ${label || "default"}`); + } + + /** + * Logs messages in a group (simplified to just log the label). + */ + group(label?: string): void { + this.appendToLog("GROUP", label || ""); + } + + /** + * Logs messages in a collapsed group (simplified to just log the label). + */ + groupCollapsed(label?: string): void { + this.appendToLog("GROUP_COLLAPSED", label || ""); + } + + /** + * Ends the current group (simplified implementation). + */ + groupEnd(): void { + this.appendToLog("GROUP_END", ""); + } + + /** + * Clears the console (no-op in this implementation since we want to capture logs). + */ + clear(): void { + // intentionally empty - we don't want to clear captured logs + } + + /** + * Counts occurrences of calls with the same label (simplified implementation). + */ + count(label?: string): void { + this.appendToLog("COUNT", label || "default"); + } + + /** + * Resets the count for a label (simplified implementation). + */ + countReset(label?: string): void { + this.appendToLog("COUNT_RESET", label || "default"); + } + + /** + * Logs an assertion (simplified to just log if condition is false). + */ + assert(condition: boolean, ...args: any[]): void { + if (!condition) { + this.appendToLog("ASSERT", ...args); + } + } +} diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index b2fff14f05..43bfd37e35 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -1,178 +1,7 @@ import { Task, TaskHandler } from "../TaskHandler"; import { ExecuteCodeTaskParams, ExecuteCodeTaskResultData } from "../../../common/src"; import { PenpotUtils } from "../PenpotUtils.ts"; - -/** - * Console implementation that captures all log output for code execution. - * - * Provides the same interface as the native console object but appends - * all output to an internal log string that can be retrieved. - */ -export class ExecuteCodeTaskConsole { - /** - * Accumulated log output from all console method calls. - */ - private logOutput: string = ""; - - /** - * Resets the accumulated log output to empty string. - * Should be called before each code execution to start with clean logs. - */ - resetLog(): void { - this.logOutput = ""; - } - - /** - * Gets the accumulated log output from all console method calls. - * @returns The complete log output as a string - */ - getLog(): string { - return this.logOutput; - } - - /** - * Formats one argument of a captured console call. - * Errors have no enumerable fields, and values such as circular objects - * cannot be serialized to JSON, so fall back to their string form. - */ - private formatLogArgument(arg: unknown): string { - if (arg instanceof Error) { - return String(arg); - } - if (typeof arg === "object") { - try { - return JSON.stringify(arg, null, 2); - } catch { - return String(arg); - } - } - return String(arg); - } - - /** - * Appends a formatted message to the log output. - * @param level - Log level prefix (e.g., "LOG", "WARN", "ERROR") - * @param args - Arguments to log, will be stringified and joined - */ - private appendToLog(level: string, ...args: any[]): void { - const message = args.map((arg) => this.formatLogArgument(arg)).join(" "); - this.logOutput += `[${level}] ${message}\n`; - } - - /** - * Logs a message to the captured output. - */ - log(...args: any[]): void { - this.appendToLog("LOG", ...args); - } - - /** - * Logs a warning message to the captured output. - */ - warn(...args: any[]): void { - this.appendToLog("WARN", ...args); - } - - /** - * Logs an error message to the captured output. - */ - error(...args: any[]): void { - this.appendToLog("ERROR", ...args); - } - - /** - * Logs an informational message to the captured output. - */ - info(...args: any[]): void { - this.appendToLog("INFO", ...args); - } - - /** - * Logs a debug message to the captured output. - */ - debug(...args: any[]): void { - this.appendToLog("DEBUG", ...args); - } - - /** - * Logs a message with trace information to the captured output. - */ - trace(...args: any[]): void { - this.appendToLog("TRACE", ...args); - } - - /** - * Logs a table to the captured output (simplified as JSON). - */ - table(data: any): void { - this.appendToLog("TABLE", data); - } - - /** - * Starts a timer (simplified implementation that just logs). - */ - time(label?: string): void { - this.appendToLog("TIME", `Timer started: ${label || "default"}`); - } - - /** - * Ends a timer (simplified implementation that just logs). - */ - timeEnd(label?: string): void { - this.appendToLog("TIME_END", `Timer ended: ${label || "default"}`); - } - - /** - * Logs messages in a group (simplified to just log the label). - */ - group(label?: string): void { - this.appendToLog("GROUP", label || ""); - } - - /** - * Logs messages in a collapsed group (simplified to just log the label). - */ - groupCollapsed(label?: string): void { - this.appendToLog("GROUP_COLLAPSED", label || ""); - } - - /** - * Ends the current group (simplified implementation). - */ - groupEnd(): void { - this.appendToLog("GROUP_END", ""); - } - - /** - * Clears the console (no-op in this implementation since we want to capture logs). - */ - clear(): void { - // intentionally empty - we don't want to clear captured logs - } - - /** - * Counts occurrences of calls with the same label (simplified implementation). - */ - count(label?: string): void { - this.appendToLog("COUNT", label || "default"); - } - - /** - * Resets the count for a label (simplified implementation). - */ - countReset(label?: string): void { - this.appendToLog("COUNT_RESET", label || "default"); - } - - /** - * Logs an assertion (simplified to just log if condition is false). - */ - assert(condition: boolean, ...args: any[]): void { - if (!condition) { - this.appendToLog("ASSERT", ...args); - } - } -} +import { ExecuteCodeTaskConsole } from "../ExecuteCodeTaskConsole.ts"; /** * Task handler for executing JavaScript code in the plugin context. diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index 7fc050cec7..32460c4315 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -5037,24 +5037,131 @@ WorkspaceContext: ``` readonly teamId: string | null ``` -ProjectSummary: +Team: overview: |- - Interface ProjectSummary - ======================== + Interface Team + ============== - Metadata snapshot of an accessible, non-deleted project. + A team the user belongs to. Requires `manage:teams`. + + Values are a snapshot taken when the team was listed; changes made through + this object update it. + Property assignments run asynchronously and update values after success. + Backend failures are logged to the console; assignments cannot be awaited. ``` - interface ProjectSummary { + interface Team { id: string; - teamId: string; - name: string; isDefault: boolean; - fileCount: number; + name: string; + listProjects(): Promise; + listDeletedProjects(): Promise; + listDeletedFiles(): Promise; + createProject(options: { name: string }): Promise; } ``` - Referenced by: PenpotMgmt + Referenced by: PenpotMgmt, Project + members: + Properties: + id: |- + ``` + readonly id: string + ``` + isDefault: |- + ``` + readonly isDefault: boolean + ``` + + Whether this is the user's personal team. + name: |- + ``` + name: string + ``` + + The team name. Assigning renames the team; requires `manage:teams`. + Trims the name and requires 1 to 250 characters. + Methods: + listProjects: |- + ``` + listProjects(): Promise + ``` + + Lists the team's projects. Requires `content:read`. + + Returns Promise + + Example + ``` + const [team] = await penpotMgmt.listTeams();const projects = await team.listProjects(); + ``` + listDeletedProjects: |- + ``` + listDeletedProjects(): Promise + ``` + + Lists projects still recoverable from Trash. Requires `content:read`. + + Returns Promise + listDeletedFiles: |- + ``` + listDeletedFiles(): Promise + ``` + + Lists files still recoverable from Trash. Requires `content:read`. + + Returns Promise + createProject: |- + ``` + createProject(options: { name: string }): Promise + ``` + + Creates a project in the team. Requires `manage:projects`. + + * Trims `name` and requires 1 to 250 characters. + * Does not change navigation. + + Parameters + + * options: { name: string } + + Returns Promise + + Example + ``` + const project = await team.createProject({ name: 'Design system' }); + ``` +Project: + overview: |- + Interface Project + ================= + + A project in the dashboard. + + Values are a snapshot taken when the project was listed; changes made through + this object update it. + Property assignments run asynchronously and update values after success. + Backend failures are logged to the console; assignments cannot be awaited. + + ``` + interface Project { + id: string; + teamId: string; + isDefault: boolean; + fileCount: number; + name: string; + pinned: boolean; + listFiles(): Promise; + listDeletedFiles(): Promise; + restore(): Promise; + createFile(options: { name: string }): Promise; + duplicate(options?: { name?: string }): Promise; + moveTo(team: Team): Promise; + remove(): Promise; + } + ``` + + Referenced by: PenpotMgmt, Project, ProjectFile, Team members: Properties: id: |- @@ -5065,10 +5172,6 @@ ProjectSummary: ``` readonly teamId: string ``` - name: |- - ``` - readonly name: string - ``` isDefault: |- ``` readonly isDefault: boolean @@ -5079,24 +5182,138 @@ ProjectSummary: ``` readonly fileCount: number ``` -FileSummary: + + Number of files when the project was listed; file operations do not update it. + name: |- + ``` + name: string + ``` + + The project name. Assigning renames the project; requires `manage:projects`. + Trims the name and requires 1 to 250 characters. + pinned: |- + ``` + pinned: boolean + ``` + + Whether the project is pinned in the sidebar. Assigning requires `manage:projects`. + Methods: + listFiles: |- + ``` + listFiles(): Promise + ``` + + Lists the files in the project. Requires `content:read`. + + Returns Promise + + Example + ``` + const files = await project.listFiles(); + ``` + listDeletedFiles: |- + ``` + listDeletedFiles(): Promise + ``` + + Lists the project's files still recoverable from Trash. Requires `content:read`. + + Returns Promise + restore: |- + ``` + restore(): Promise + ``` + + Restores the project through its files using the dashboard's Trash operation. + Requires `manage:projects` and team edit access. Does not require `manage:delete`. + Rejects if the project has no recoverable files, including empty projects. + + Returns Promise + createFile: |- + ``` + createFile(options: { name: string }): Promise + ``` + + Creates a file with an initial page in the project. Requires `content:write`. + + * Trims `name` and requires 1 to 250 characters. + * Does not open the file. Use `ProjectFile.open` to edit it. + + Parameters + + * options: { name: string } + + Returns Promise + + Example + ``` + const file = await project.createFile({ name: 'Login' });await file.open(); + ``` + duplicate: |- + ``` + duplicate(options?: { name?: string }): Promise + ``` + + Duplicates the project with all its files. Requires `manage:projects`. + + `name` defaults to the name the backend chooses. + + Parameters + + * options: { name?: string } + + Returns Promise + moveTo: |- + ``` + moveTo(team: Team): Promise + ``` + + Moves the project to another team. Requires `manage:teams`. + + Parameters + + * team: Team + + Returns Promise + remove: |- + ``` + remove(): Promise + ``` + + Moves the project and its files to Trash. Requires `manage:delete`. + They can be restored during the retention period. Cannot permanently delete. + + Returns Promise +ProjectFile: overview: |- - Interface FileSummary + Interface ProjectFile ===================== - Metadata snapshot of an accessible, non-deleted file. + A file in the dashboard, which may or may not be open. + + Values are a snapshot taken when the file was listed; changes made through + this object update it. To read or edit its content, `open` it and use + `penpot.currentFile`. + Property assignments run asynchronously and update values after success. + Backend failures are logged to the console; assignments cannot be awaited. ``` - interface FileSummary { + interface ProjectFile { id: string; teamId: string; projectId: string; - name: string; modifiedAt: string; + name: string; + shared: boolean; + open(): Promise; + restore(): Promise; + moveTo(project: Project): Promise; + duplicate(options?: { name?: string }): Promise; + remove(): Promise; } ``` - Referenced by: PenpotMgmt + Referenced by: PenpotMgmt, Project, ProjectFile members: Properties: id: |- @@ -5111,16 +5328,84 @@ FileSummary: ``` readonly projectId: string ``` - name: |- - ``` - readonly name: string - ``` modifiedAt: |- ``` readonly modifiedAt: string ``` Last modification time as an ISO 8601 string in UTC. + name: |- + ``` + name: string + ``` + + The file name. Assigning renames the file; requires `content:write`. + Trims the name and requires 1 to 250 characters. + shared: |- + ``` + shared: boolean + ``` + + Whether the file is shared as a library with its team. Assigning requires + `library:write`. A shared file is listed by + `penpot.library.availableLibraries()`; unsharing copies its assets into + every file that uses it and disconnects them. + Methods: + open: |- + ``` + open(): Promise + ``` + + Opens the file in this tab and resolves once its workspace and page are + ready. Requires `content:read`. + + Returns Promise + restore: |- + ``` + restore(): Promise + ``` + + Restores the file using the dashboard's Trash operation, including its parent + project if deleted. Requires `content:write` and team edit access. + Rejects if the file no longer exists. Does not require `manage:delete`. + + Returns Promise + moveTo: |- + ``` + moveTo(project: Project): Promise + ``` + + Moves the file to another project of the same team. Requires `content:write`. + Rejects a destination that currently belongs to another team. + + Parameters + + * project: Project + + Returns Promise + duplicate: |- + ``` + duplicate(options?: { name?: string }): Promise + ``` + + Duplicates the file in its project. Requires `content:write`. + + `name` defaults to the name the backend chooses. + + Parameters + + * options: { name?: string } + + Returns Promise + remove: |- + ``` + remove(): Promise + ``` + + Moves the file to Trash. Requires `manage:delete`. + It can be restored during the retention period. Cannot permanently delete. + + Returns Promise PenpotMgmt: overview: |- Interface PenpotMgmt @@ -5128,18 +5413,22 @@ PenpotMgmt: Application management API exposed as `penpotMgmt` to global plugins. - * Discovery and navigation require `content:read`; creation requires `content:write`. - * Available in the dashboard and file workspaces. - * Use this API to discover, create and open files; file operations on `penpot` require a ready workspace. + * Lists teams, projects and files, and opens files, from the dashboard or a + file workspace. Teams, projects and files are objects with their own + properties and methods. + * Listing and opening require `content:read`; teams require `manage:teams`. + * File operations on `penpot` require a ready workspace. * Reacquire file, page and shape objects after changing files. ``` interface PenpotMgmt { workspace: WorkspaceContext; - listProjects(options?: { teamId?: string }): Promise; - listFiles(options: { projectId: string }): Promise; - createProject(options: { name: string; teamId?: string }): Promise; - createFile(options: { name: string; projectId: string }): Promise; + listTeams(): Promise; + createTeam(options: { name: string }): Promise; + listProjects(options?: { teamId?: string }): Promise; + listDeletedProjects(options?: { teamId?: string }): Promise; + listDeletedFiles(options?: { teamId?: string }): Promise; + getFile(fileId: string): Promise; openFile(fileId: string, options?: { teamId?: string }): Promise; on( type: "workspacechange", @@ -5159,92 +5448,101 @@ PenpotMgmt: Current workspace availability and metadata; usable for file operations when status is `ready`. Methods: + listTeams: |- + ``` + listTeams(): Promise + ``` + + Lists the teams of the user. Requires `manage:teams`. + + Returns Promise + + Example + ``` + const teams = await penpotMgmt.listTeams(); + ``` + createTeam: |- + ``` + createTeam(options: { name: string }): Promise + ``` + + Creates a team with the user as owner. Requires `manage:teams`. + + * Trims `name` and requires 1 to 250 characters. + * Does not change navigation. + + Parameters + + * options: { name: string } + + Returns Promise + + Example + ``` + const team = await penpotMgmt.createTeam({ name: 'Marketing' }); + ``` listProjects: |- ``` - listProjects(options?: { teamId?: string }): Promise + listProjects(options?: { teamId?: string }): Promise ``` - Lists accessible, non-deleted projects as metadata snapshots. + Lists the projects of a team. Requires `content:read`. * `teamId` defaults to the current team. - * Queries the backend without opening a file or changing navigation. * Rejects invalid identifiers and backend access errors. Parameters * options: { teamId?: string } - Returns Promise + Returns Promise Example ``` const projects = await penpotMgmt.listProjects(); ``` - listFiles: |- + listDeletedProjects: |- ``` - listFiles(options: { projectId: string }): Promise + listDeletedProjects(options?: { teamId?: string }): Promise ``` - Lists accessible, non-deleted files in the specified project as metadata snapshots. - - * Requires an explicit `projectId`. - * Queries the backend without opening a file or changing navigation. - * Rejects invalid identifiers and backend access errors. + Lists projects still recoverable from Trash. Requires `content:read`. + `teamId` defaults to the current team. Use `Project.restore` to recover them. Parameters - * options: { projectId: string } + * options: { teamId?: string } - Returns Promise - - Example + Returns Promise + listDeletedFiles: |- ``` - const files = await penpotMgmt.listFiles({ projectId: project.id }); - ``` - createProject: |- - ``` - createProject(options: { name: string; teamId?: string }): Promise + listDeletedFiles(options?: { teamId?: string }): Promise ``` - Creates a project and returns its metadata snapshot. Requires `content:write`. - - * `teamId` defaults to the current team. - * Trims `name` and requires 1 to 250 characters. - * Does not change navigation or open a file. - * Rejects invalid input, backend access errors and quota errors. + Lists files still recoverable from Trash. Requires `content:read`. + `teamId` defaults to the current team. Use `ProjectFile.restore` to recover them. Parameters - * options: { name: string; teamId?: string } + * options: { teamId?: string } - Returns Promise - - Example + Returns Promise + getFile: |- ``` - const project = await penpotMgmt.createProject({ name: 'Design system' }); - ``` - createFile: |- - ``` - createFile(options: { name: string; projectId: string }): Promise + getFile(fileId: string): Promise ``` - Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`. - - * Requires an explicit `projectId`. - * Trims `name` and requires 1 to 250 characters. - * Does not open the file or change navigation. Use `openFile` to edit it. - * Rejects invalid input, backend access errors and quota errors. + Loads a file by id, open or not. Requires `content:read`. Parameters - * options: { name: string; projectId: string } + * fileId: string - Returns Promise + Returns Promise Example ``` - const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' }); - await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + const file = await penpotMgmt.getFile(penpotMgmt.workspace.fileId!);file.name = 'Checkout'; ``` openFile: |- ``` diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 437c58c7ab..66c2c642da 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -435,12 +435,18 @@ For many tasks, it can be critical to visually inspect the design. Remember to u # The `penpotMgmt` Object This object (of type `PenpotMgmt`) serves to manage Penpot design projects and files. -It allows you to discover, create and navigate to design projects and files the user has access to. -Create a project with `await penpotMgmt.createProject({ name: "Design system" })` (optionally specify `teamId`). -Create a file with `await penpotMgmt.createFile({ projectId: project.id, name: "Login" })`. -Both creation methods require `content:write` and return metadata without changing the current view. -To edit the new file, call `await penpotMgmt.openFile(file.id, { teamId: file.teamId })` and reacquire file, page and shape objects. +Teams (`Team`), projects (`Project`) and files (`ProjectFile`) are objects with their own properties and methods: + * `await penpotMgmt.listProjects()` lists the projects of the current team; `await penpotMgmt.listTeams()` lists all teams. + * `await project.listFiles()` lists a project's files; `await penpotMgmt.getFile(id)` loads any file by id, e.g. `penpotMgmt.workspace.fileId`. + * Create with `await team.createProject({ name: "Design system" })` and `await project.createFile({ name: "Login" })`. + * Rename by assigning `name`; pin a project with `project.pinned = true`; share a file as a team library with `file.shared = true`. + * `moveTo`, `duplicate` and `remove` move, copy and delete projects and files; `penpotMgmt.createTeam({ name })` creates teams. Team deletion is not exposed. + * Project and file `remove()` moves them to Trash. There is no API for their permanent deletion. Discover recoverable objects with `await penpotMgmt.listDeletedProjects()` and `await penpotMgmt.listDeletedFiles()` (optionally pass `{ teamId }`), or `team.listDeletedProjects()`, `team.listDeletedFiles()` and `project.listDeletedFiles()`. + * Restore with `await project.restore()` or `await file.restore()` before the retention period expires. Restoration uses the dashboard's Trash operation and requires team edit access. Project restoration restores its recoverable files and rejects if none remain, including empty projects. File restoration also restores its deleted parent project. + * None of these change the current view. To edit a file, call `await file.open()` (or `await penpotMgmt.openFile(fileId)`) and reacquire file, page and shape objects. + * Connect a shared library to the open file with `penpot.library.connectLibrary(file.id)`. You navigate to different design files only when your task explicitly requires it/you are instructed to do so. +Only rename teams or rename, move, delete or restore projects and files when the user asks for it. If `penpotMgmt` is `null`, file management is unavailable and only the file that is open in Penpot can be used; ask the user to open another file if needed. diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index ac3ecf08a0..a6bbcde71f 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,7 +2,7 @@ ### 🚀 Features -- **plugin-types, plugins-runtime:** Added `penpotMgmt.createProject` and `penpotMgmt.createFile` to create projects and files from the dashboard or workspace with `content:write` permission. +- **plugin-types, plugins-runtime:** `penpotMgmt` returns `Team`, `Project` and `ProjectFile` objects. Assigning `name` renames teams, projects and files, `project.pinned` pins a project and `file.shared` shares a file as a team library; `createProject`, `createFile`, `moveTo`, `duplicate`, `remove` and `restore` live on the objects. Added `penpotMgmt.listTeams`, `penpotMgmt.createTeam`, `penpotMgmt.getFile`, `listDeletedProjects` and `listDeletedFiles`, and the `manage:projects`, `manage:teams` and `manage:delete` permissions. Restoration uses the dashboard's Trash operation; projects need recoverable files and empty projects cannot be restored; the API exposes no permanent deletion method. Team deletion is not exposed. - **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. Global scope adds the `allow:global` permission, which users must accept when installing the plugin. - **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. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. @@ -11,7 +11,9 @@ ### 🩹 Fixes +- **plugin-types, plugin-runtime:** Dashboard property setters now update values after backend success and log failures without interrupting the workspace. File moves reject projects in other teams, backend rejections include their type and code, and `project.fileCount` stays at its listing snapshot. Duplicated projects include their file count and pinned state. - **plugin-runtime:** Cancel plugin loads before they execute after unloading or logout, and allow reopening plugins that close during startup. +- **plugins-runtime**: `library.connectLibrary()` now resolves once the library's assets are loaded; it used to resolve with an empty library the first time. `library.connected` and `library.availableLibraries()` ignore files opened earlier in the same tab, which used to show up as connected and be left out of the available libraries. - **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction. - **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape. - **plugins-runtime**: Writing `trigger`, `delay` or `action` on an interaction the shape no longer has now raises a validation error. The write used to be sent to the workspace with no position to apply it at, where it failed out of the plugin's reach: nothing was written and nothing was reported. diff --git a/plugins/apps/plugin-api-test-suite/README.md b/plugins/apps/plugin-api-test-suite/README.md index 5ea524b484..a04657cfff 100644 --- a/plugins/apps/plugin-api-test-suite/README.md +++ b/plugins/apps/plugin-api-test-suite/README.md @@ -17,9 +17,10 @@ fully before writing any test. ## The one rule that matters most -> **Always call the API through `ctx.penpot`, never the global `penpot`.** +> **Always call the API through `ctx.penpot` and `ctx.penpotMgmt`, never the +> globals `penpot` and `penpotMgmt`.** -`ctx.penpot` is a recording proxy. Calls made through it are what count towards +`ctx.penpot` and `ctx.penpotMgmt` are recording proxies. Calls made through it are what count towards coverage and are correctly attributed to the right interface. Calls on the global `penpot` still work but are invisible to coverage. Same for shapes: operate on the objects returned by `ctx.penpot.*` (and on `ctx.board`), not on objects obtained @@ -31,7 +32,9 @@ From `plugins/`: - Dev server: `pnpm run start:plugin:api-test-suite` (serves on port 4202). - In Penpot: open the Plugin Manager (Ctrl+Alt+P) and install - `http://localhost:4202/manifest.json`. + `http://localhost:4202/manifest.json`. The suite is a global plugin + (`"scope": "global"`), so the install dialog asks for the `allow:global` + permission. It keeps running while tests move between files. - Alternatively, a one-shot `pnpm --filter plugin-api-test-suite run build` output is served by the devenv alongside the other bundled plugins, at `https://localhost:3449/plugins/plugin-api-test-suite/manifest.json` (no hot @@ -62,6 +65,8 @@ E2E_LOGIN_EMAIL=… E2E_LOGIN_PASSWORD=… \ - It builds `headless.js`, logs in, creates a scratch file, injects the test bundle, and prints per-test results + the coverage report. +- After the run it deletes the projects the tests created (see + [Working with other files](#working-with-other-files)). - Exit code is non-zero iff any test failed (coverage does not affect it). - Optional env: `PENPOT_BASE_URL` (default `https://localhost:3449`). Against a local devenv with a self-signed certificate, prefix the command with @@ -214,15 +219,50 @@ committing. - `ctx.penpot` — the recording proxy over the real `penpot` global. Use it for every API call. +- `ctx.penpotMgmt` — the recording proxy over the real `penpotMgmt` global, for + teams, projects and files (see [Working with other files](#working-with-other-files)). - `ctx.board` — a **fresh scratch `Board`** created for this test and **removed automatically afterwards**. Append shapes you create to it (`ctx.board.appendChild(shape)`) so the user's canvas is left clean. Do not rely on it persisting between tests. +- `ctx.fixture(key, create)` — returns the value `create` resolves to, created + the first time `key` is used in the run and reused by later tests of the same + run. Keep shared fixtures here rather than in module variables: the plugin UI + keeps modules between runs, so a module-level cache would leak state from one + run into the next. -The runner also resets shared state between tests: the selection is cleared and the -active page is restored to whatever was active when the run started (both through -the raw `penpot`, so they aren't credited toward coverage). A test that changes the -active page therefore won't leak into later tests. +The runner also resets shared state between tests: it reopens the file the run +started in if a test left another one open, restores the page that was active +when the run started and clears the selection (all through the raw globals, so +they aren't credited toward coverage). A test that changes the active page or +file therefore won't leak into later tests. + +### Working with other files + +The suite runs with global scope, so tests can reach teams, projects and files +through `ctx.penpotMgmt`. They are objects like the rest of the API: `Team` +(`listTeams()`), `Project` (`listProjects()`, `team.createProject()`) and +`ProjectFile` (`project.listFiles()`, `project.createFile()`, `getFile(id)`), +with `name`/`pinned`/`shared` properties and `open`, `moveTo`, `duplicate` and +`remove` methods. `management.test.ts` shows the patterns: + +- Put every team and project a test creates under a name from + `temporaryName()` (`src/framework/temporary.ts`). Tests delete the teams they + create; the live CI driver deletes the teams and projects with that prefix + after the run. Runs from the plugin UI leave the projects behind. The tests + need nothing set up beforehand. +- Assigning `name`, `pinned` or `shared` updates the object at once and saves + in the background. To check the saved result, list again inside + `waitForAsync()` (`src/tests/wait.ts`). +- Shape, page, file and library objects taken before `file.open()` (or + `openFile`) are stale afterwards; look them up again in the new file. + `ctx.board` belongs to the file the test started in, so only use it there. +- While a file opens, `penpot.currentFile`, `currentPage` and `root` are `null` + and file operations throw. `open` resolves once the file is ready. +- These tests need the real backend, so tag them `skipIfMocked`. +- The per-test timeout is 15s; opening a file takes about half a second + locally. Build expensive fixtures (such as a shared library) once per run with + `ctx.fixture`, as `sharedLibrary()` does. ### Sync or async @@ -318,8 +358,8 @@ pnpm --filter plugin-api-test-suite run gen:api - `createText(str)` returns `Text | null` — guard the result (`if (text) { … }`). - `width`/`height` are read-only; use `resize(w, h)`. `x`/`y` are writable. - The plugin manifest already requests broad permissions (`content:*`, - `library:*`, `user:read`, `comment:*`, `allow:downloads`, `allow:localstorage`), - so most of the API is callable from tests without changes. + `library:*`, `user:read`, `comment:*`, `allow:downloads`, `allow:localstorage`, + `allow:global`), so most of the API is callable from tests without changes. - The runner sets `throwValidationErrors = true` and `naturalChildOrdering = true`, so invalid API usage throws (surfacing as a red test) and `children` is always in z-index order. @@ -405,8 +445,6 @@ valid"); `fills-strokes.test.ts` pins this with a `toThrow`. ### External state / not reachable headless - **`ActiveUser.position/zoom`** — needs a second collaborator in the file. -- **`LibrarySummary.*`, `LibraryContext.connectLibrary`** — need a published shared - library. - **`FileVersion.restore`, `Penpot.closePlugin`, `Penpot.ui`, `Context.openViewer`** — tear down or navigate away from the running plugin/workspace. - **`FileVersion.pin`** — only converts a _system_ autosave to a permanent version; @@ -414,14 +452,16 @@ valid"); `fills-strokes.test.ts` pins this with a `toThrow`. rejects. - **`Context.addListener/removeListener`** — omitted from the `penpot` global (`Omit`), so unreachable via `penpot`. -- **`EventsMap` events `pagechange/filechange/themechange/contentsave/finish`** — - can't be triggered deterministically in the headless runner. +- **`EventsMap` events `pagechange/themechange/contentsave`** — can't be + triggered deterministically in the headless runner. `filechange` and `finish` + are exercised in `management.test.ts` by switching files. ## Checklist before finishing - [ ] Test file is `src/tests/.test.ts` and uses `test(...)` + `expect`, ideally wrapped in a `describe('', …)`. -- [ ] All API calls go through `ctx.penpot`; shapes are appended to `ctx.board`. +- [ ] All API calls go through `ctx.penpot` / `ctx.penpotMgmt`; shapes are + appended to `ctx.board`. - [ ] Created shapes don't leak (rely on the scratch board cleanup; don't touch the user's existing content). - [ ] Lint/format/typecheck pass: @@ -438,6 +478,7 @@ valid"); `fills-strokes.test.ts` pins this with a `toThrow`. - `src/framework/static-coverage.ts` — the statically-covered allowlist. - `src/framework/expect.ts` — the assertion library. - `src/framework/types.ts` — `TestContext`, `TestResult`, `CoverageReport`, etc. +- `src/framework/temporary.ts` — name prefix for temporary projects, shared with the CI driver. - `tools/gen-api-surface.ts` — generates `src/generated/api-surface.json`. - `src/plugin.ts` (sandbox), `src/ui.ts` (iframe), `src/model.ts` (messages). - `src/ci/headless.ts` + `ci/run-ci.ts` — CI path. diff --git a/plugins/apps/plugin-api-test-suite/ci/run-ci.ts b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts index 559e6f0aea..acf6904299 100644 --- a/plugins/apps/plugin-api-test-suite/ci/run-ci.ts +++ b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium, type Page } from 'playwright'; +import { TEMPORARY_PREFIX } from '../src/framework/temporary'; import type { CoverageReport, TestResult } from '../src/framework/types'; import { startStaticServer, type StaticServer } from './static-server.ts'; @@ -42,6 +43,9 @@ const APP_ERROR_RE = /^(Internal Error|Unexpected Error|Assertion Error|Uncaught Exception|Uncaught Rejection):/; const MOCKED = !!process.env['MOCK_BACKEND']; + +// Limit for the whole run; each test also has its own 15s limit in the runner. +const RUN_TIMEOUT_MS = 300000; const MOCK_BASE_URL = 'http://localhost:3000'; const apiUrl = MOCKED ? MOCK_BASE_URL @@ -52,12 +56,13 @@ const headlessBundlePath = resolve( '../../../dist/apps/plugin-api-test-suite/headless.js', ); -// Source the permissions from the same manifest the real plugin ships with, so -// the CI sandbox never drifts from what users actually grant. +// Source the permissions and scope from the same manifest the real plugin ships +// with, so the CI sandbox never drifts from what users actually grant. const manifestPath = resolve(here, '../public/manifest.json'); -const PERMISSIONS: string[] = ( - JSON.parse(readFileSync(manifestPath, 'utf-8')) as { permissions: string[] } -).permissions; +const MANIFEST = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + permissions: string[]; + scope?: string; +}; function cleanId(id: string): string { return id.replace('~u', ''); @@ -94,7 +99,70 @@ async function login() { if (!authToken) throw new Error('Login failed: no auth-token cookie returned'); - return { authToken, defaultProjectId: loginData['~:default-project-id'] }; + return { + authToken, + defaultProjectId: loginData['~:default-project-id'], + defaultTeamId: cleanId(loginData['~:default-team-id']), + }; +} + +// Calls a backend RPC with plain JSON in and out. Commands without a result +// answer with an empty body. +async function rpc( + authToken: string, + method: string, + params: Record, +): Promise { + const response = await fetch(`${apiUrl}/api/main/methods/${method}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + cookie: authToken, + }, + body: JSON.stringify(params), + }); + if (!response.ok) { + throw new Error(`${method} failed with status ${response.status}`); + } + const body = await response.text(); + return (body ? JSON.parse(body) : undefined) as T; +} + +// Deletes the teams the tests created, with their projects and files. +async function deleteTemporaryTeams(authToken: string): Promise { + const teams = await rpc<{ id: string; name: string }[]>( + authToken, + 'get-teams', + {}, + ); + const temporary = teams.filter((team) => + team.name.startsWith(TEMPORARY_PREFIX), + ); + for (const team of temporary) { + await rpc(authToken, 'delete-team', { id: team.id }); + } + return temporary.length; +} + +// Deletes the projects the tests created (see `src/framework/temporary.ts`), +// with the files in them. +async function deleteTemporaryProjects( + authToken: string, + teamId: string, +): Promise { + const projects = await rpc< + { id: string; name: string; deletedAt?: string | null }[] + >(authToken, 'get-projects', { 'team-id': teamId }); + // Projects already in the trash are listed too; skip them. + const temporary = projects.filter( + (project) => + !project.deletedAt && project.name.startsWith(TEMPORARY_PREFIX), + ); + for (const project of temporary) { + await rpc(authToken, 'delete-project', { id: project.id }); + } + return temporary.length; } async function createFile( @@ -130,11 +198,10 @@ async function createFile( return (await response.json()) as FileRpc; } -function getFileUrl(file: FileRpc): string { - const projectId = cleanId(file['~:project-id']); +function getFileUrl(file: FileRpc, teamId: string): string { const fileId = cleanId(file['~:id']); const pageId = cleanId(file['~:data']['~:pages'][0]); - return `${apiUrl}/#/workspace/${projectId}/${fileId}?page-id=${pageId}`; + return `${apiUrl}/?screen=workspace&team-id=${teamId}&file-id=${fileId}&page-id=${pageId}`; } // --- Mocked mode setup ------------------------------------------------------- @@ -348,6 +415,7 @@ async function main() { let server: StaticServer | undefined; let fileUrl: string; let authToken: string | undefined; + let teamId: string | undefined; if (MOCKED) { server = await startE2eServer(); @@ -356,8 +424,9 @@ async function main() { } else { const session = await login(); authToken = session.authToken; + teamId = session.defaultTeamId; const file = await createFile(authToken, session.defaultProjectId); - fileUrl = getFileUrl(file); + fileUrl = getFileUrl(file, session.defaultTeamId); } const renderWasm = process.env['RENDER_WASM']; @@ -468,7 +537,7 @@ async function main() { ); await page.evaluate( - ({ code, permissions }) => { + ({ code, permissions, scope }) => { ( globalThis as unknown as { ɵloadPlugin: (m: unknown) => void } ).ɵloadPlugin({ @@ -478,23 +547,45 @@ async function main() { icon: '', description: '', permissions, + scope, }); }, - { code: injectedCode, permissions: PERMISSIONS }, + { + code: injectedCode, + permissions: MANIFEST.permissions, + scope: MANIFEST.scope, + }, ); - await Promise.race([ - done, - new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timed out waiting for test results')), - 120000, + try { + await Promise.race([ + done, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timed out waiting for test results')), + RUN_TIMEOUT_MS, + ), ), - ), - ]); + ]); + } finally { + await browser.close(); + await server?.close(); - await browser.close(); - await server?.close(); + // Runs after a timeout too, so an interrupted run leaves nothing behind. + if (authToken && teamId) { + try { + const projects = await deleteTemporaryProjects(authToken, teamId); + const teams = await deleteTemporaryTeams(authToken); + if (projects > 0 || teams > 0) { + console.log( + `\nDeleted ${projects} temporary project(s) and ${teams} team(s).`, + ); + } + } catch (err) { + console.warn(`\nCould not delete temporary data: ${String(err)}`); + } + } + } printReport(results, coverage, skipped); diff --git a/plugins/apps/plugin-api-test-suite/public/manifest.json b/plugins/apps/plugin-api-test-suite/public/manifest.json index a56ee76498..99c2578a1d 100644 --- a/plugins/apps/plugin-api-test-suite/public/manifest.json +++ b/plugins/apps/plugin-api-test-suite/public/manifest.json @@ -3,6 +3,7 @@ "description": "Launcher for a battery of Penpot Plugin API tests", "code": "plugin.js", "version": 2, + "scope": "global", "icon": "assets/icon.png", "permissions": [ "content:read", @@ -13,6 +14,10 @@ "comment:read", "comment:write", "allow:downloads", - "allow:localstorage" + "allow:localstorage", + "allow:global", + "manage:projects", + "manage:teams", + "manage:delete" ] } diff --git a/plugins/apps/plugin-api-test-suite/src/framework/runner.ts b/plugins/apps/plugin-api-test-suite/src/framework/runner.ts index 9c20d38969..62ee3b471b 100644 --- a/plugins/apps/plugin-api-test-suite/src/framework/runner.ts +++ b/plugins/apps/plugin-api-test-suite/src/framework/runner.ts @@ -98,6 +98,28 @@ export interface RunOptions { export type ResultReporter = (result: TestResult) => void; +/** File and page that were active when the run started. */ +interface Home { + fileId: string | null; + teamId: string | null; + pageId: string | null; +} + +// Reopens the home file when a test navigated to another one. Shape, page and +// file references taken before navigating are stale afterwards, so callers +// look objects up again by id. Uses the *raw* globals so it isn't credited +// toward coverage. +async function returnToHomeFile(home: Home): Promise { + if (!home.fileId) return; + const workspace = penpotMgmt.workspace; + if (workspace.status === 'ready' && workspace.fileId === home.fileId) return; + await penpotMgmt.openFile(home.fileId, { teamId: home.teamId ?? undefined }); +} + +function homePage(home: Home) { + return penpot.currentFile?.pages.find((page) => page.id === home.pageId); +} + function withTimeout(promise: void | Promise, ms: number): Promise { return new Promise((resolve, reject) => { let settled = false; @@ -152,6 +174,14 @@ export async function runTests( : focused; const recorder = createRecorder(penpot, apiSurface as ApiSurface); + const management = recorder.wrap(penpotMgmt, 'PenpotMgmt'); + + // Fixtures shared by the tests of this run (see `TestContext.fixture`). + const fixtures = new Map>(); + const fixture = (key: string, create: () => Promise): Promise => { + if (!fixtures.has(key)) fixtures.set(key, create()); + return fixtures.get(key) as Promise; + }; // Run every test with strict, deterministic API behavior. Set through the // recording proxy so the flags also count towards coverage: @@ -162,13 +192,17 @@ export async function runTests( recorder.proxy.flags.throwValidationErrors = true; recorder.proxy.flags.naturalChildOrdering = true; - // Remember the page that was active when the run started. Tests share global - // state (selection, the active page) with no per-test reset, so a test that - // changes the active page — or fails before restoring it — would silently make - // every later test run on the wrong page. After each test we clear the - // selection and restore this page, all through the *raw* penpot so the cleanup - // isn't credited toward coverage. - const homePage = penpot.currentPage; + // Remember the file and page that were active when the run started. Tests + // share global state (selection, the active page, the open file) with no + // per-test reset, so a test that changes them — or fails before restoring + // them — would silently make every later test run in the wrong place. After + // each test we reopen this file and page and clear the selection, all through + // the *raw* globals so the cleanup isn't credited toward coverage. + const home: Home = { + fileId: penpotMgmt.workspace.fileId, + teamId: penpotMgmt.workspace.teamId, + pageId: penpot.currentPage?.id ?? null, + }; const results: TestResult[] = []; @@ -190,7 +224,7 @@ export async function runTests( // Create/name/remove the scratch board through the *raw* penpot so this // harness bookkeeping isn't credited toward coverage. The test still gets a // recording-wrapped board, so its own access to it is counted. - let rawBoard: ReturnType | undefined; + let boardId: string | undefined; let result: TestResult; // Baseline the file's integrity errors before the test so we can attribute @@ -198,13 +232,21 @@ export async function runTests( const integrityBaseline = integritySignatures(); try { - rawBoard = penpot.createBoard(); + const rawBoard = penpot.createBoard(); rawBoard.name = SCRATCH_NAME; + boardId = rawBoard.id; const board = recorder.wrap(rawBoard, 'Board'); await withTimeout( - testCase.fn({ penpot: recorder.proxy, board }), + testCase.fn({ + penpot: recorder.proxy, + penpotMgmt: management, + board, + fixture, + }), TEST_TIMEOUT_MS, ); + // The integrity baseline belongs to the home file, so check it there. + await returnToHomeFile(home); // Postcondition: the test must not have broken referential integrity. if (integrityBaseline) assertNoNewIntegrityErrors(integrityBaseline); result = { @@ -225,22 +267,27 @@ export async function runTests( // `test.nocleanup` keeps the scratch board and shared state as the test // left them so the result can be inspected in the workspace. if (!testCase.noCleanup) { - try { - rawBoard?.remove(); - } catch { - // best-effort cleanup; never fail a test because teardown failed - } // Reset shared state so the next test starts clean. All best-effort: a // teardown failure must never turn into a test failure. + try { + await returnToHomeFile(home); + } catch { + /* ignore */ + } + try { + if (boardId) homePage(home)?.getShapeById(boardId)?.remove(); + } catch { + /* ignore */ + } try { penpot.selection = []; } catch { /* ignore */ } try { - const active = penpot.currentPage; - if (homePage && active && active.id !== homePage.id) { - await penpot.openPage(homePage); + const page = homePage(home); + if (page && penpot.currentPage?.id !== page.id) { + await penpot.openPage(page); } } catch { /* ignore */ diff --git a/plugins/apps/plugin-api-test-suite/src/framework/temporary.ts b/plugins/apps/plugin-api-test-suite/src/framework/temporary.ts new file mode 100644 index 0000000000..80b86300af --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/temporary.ts @@ -0,0 +1,14 @@ +// Shared by the tests (in the sandbox) and the CI driver (in Node), so it must +// stay free of imports. + +/** + * Name prefix of every team and project the tests create. Tests delete the + * teams they create; the live CI driver deletes the teams and projects with + * this prefix after a run. Runs from the plugin UI leave the projects behind. + */ +export const TEMPORARY_PREFIX = '__api_test_tmp__'; + +/** Returns a unique name with {@link TEMPORARY_PREFIX} for a test project. */ +export function temporaryName(label: string): string { + return `${TEMPORARY_PREFIX} ${label} ${Date.now()}`; +} diff --git a/plugins/apps/plugin-api-test-suite/src/framework/types.ts b/plugins/apps/plugin-api-test-suite/src/framework/types.ts index 665597478b..d9ff30e1dc 100644 --- a/plugins/apps/plugin-api-test-suite/src/framework/types.ts +++ b/plugins/apps/plugin-api-test-suite/src/framework/types.ts @@ -1,15 +1,24 @@ -import type { Board, Penpot } from '@penpot/plugin-types'; +import type { Board, Penpot, PenpotMgmt } from '@penpot/plugin-types'; export type TestStatus = 'pending' | 'running' | 'pass' | 'fail'; /** - * The context handed to every test. Tests MUST use `ctx.penpot` (the recording - * proxy) rather than the global `penpot` so their API usage is counted towards - * coverage. A fresh scratch `board` is provided per test and removed afterwards. + * The context handed to every test. Tests MUST use `ctx.penpot` and + * `ctx.penpotMgmt` (the recording proxies) rather than the globals so their API + * usage is counted towards coverage. A fresh scratch `board` is provided per + * test and removed afterwards. */ export interface TestContext { penpot: Penpot; + /** Recording proxy over `penpotMgmt`, available because the suite runs with global scope. */ + penpotMgmt: PenpotMgmt; board: Board; + /** + * Returns the value `create` resolves to, creating it the first time `key` + * is used in the current run. Use it for expensive fixtures shared by + * several tests; a later run creates them again. + */ + fixture(key: string, create: () => Promise): Promise; } export type TestFn = (ctx: TestContext) => void | Promise; diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json index b32168ec1b..1025e9d764 100644 --- a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -138,10 +138,12 @@ "contentsave", "filechange", "finish", + "logout", "pagechange", "selectionchange", "shapechange", - "themechange" + "themechange", + "workspacechange" ], "Export": ["scale", "skipChildren", "suffix", "type"], "File": [ @@ -365,6 +367,16 @@ "Path": ["commands", "d", "fills", "type"], "PathCommand": ["command", "params"], "Penpot": ["closePlugin", "off", "on", "ui", "utils"], + "PenpotMgmt": [ + "createTeam", + "getFile", + "listProjects", + "listTeams", + "off", + "on", + "openFile", + "workspace" + ], "PluginData": [ "getPluginData", "getPluginDataKeys", @@ -375,6 +387,31 @@ ], "Point": ["x", "y"], "PreviousScreen": ["type"], + "Project": [ + "createFile", + "duplicate", + "fileCount", + "id", + "isDefault", + "listFiles", + "moveTo", + "name", + "pinned", + "remove", + "teamId" + ], + "ProjectFile": [ + "duplicate", + "id", + "modifiedAt", + "moveTo", + "name", + "open", + "projectId", + "remove", + "shared", + "teamId" + ], "Push": ["direction", "duration", "easing", "type"], "Rectangle": ["fills", "type"], "RulerGuide": ["board", "orientation", "position", "remove"], @@ -480,6 +517,14 @@ "strokeWidthType" ], "SvgRaw": ["type"], + "Team": [ + "createProject", + "id", + "isDefault", + "listProjects", + "name", + "remove" + ], "Text": [ "align", "applyTypography", @@ -538,6 +583,7 @@ "addTheme", "getSetById", "getThemeById", + "isEditableTokens", "sets", "themes" ], @@ -636,7 +682,8 @@ "zoomIntoView", "zoomReset", "zoomToFitAll" - ] + ], + "WorkspaceContext": ["fileId", "fileName", "status", "teamId"] }, "graph": { "ActiveUser": { @@ -2080,6 +2127,12 @@ } }, "Context": { + "management": { + "decl": "Context", + "kind": "get", + "type": null, + "array": false + }, "version": { "decl": "Context", "kind": "get", @@ -2914,6 +2967,18 @@ } }, "EventsMap": { + "workspacechange": { + "decl": "EventsMap", + "kind": "get", + "type": "WorkspaceContext", + "array": false + }, + "logout": { + "decl": "EventsMap", + "kind": "get", + "type": null, + "array": false + }, "pagechange": { "decl": "EventsMap", "kind": "get", @@ -6677,6 +6742,56 @@ "array": false } }, + "PenpotMgmt": { + "workspace": { + "decl": "PenpotMgmt", + "kind": "get", + "type": "WorkspaceContext", + "array": false + }, + "listTeams": { + "decl": "PenpotMgmt", + "kind": "method", + "type": "Team", + "array": true + }, + "createTeam": { + "decl": "PenpotMgmt", + "kind": "method", + "type": "Team", + "array": false + }, + "listProjects": { + "decl": "PenpotMgmt", + "kind": "method", + "type": "Project", + "array": true + }, + "getFile": { + "decl": "PenpotMgmt", + "kind": "method", + "type": "ProjectFile", + "array": false + }, + "openFile": { + "decl": "PenpotMgmt", + "kind": "method", + "type": null, + "array": false + }, + "on": { + "decl": "PenpotMgmt", + "kind": "method", + "type": null, + "array": false + }, + "off": { + "decl": "PenpotMgmt", + "kind": "method", + "type": null, + "array": false + } + }, "PluginData": { "getPluginData": { "decl": "PluginData", @@ -6737,6 +6852,136 @@ "array": false } }, + "Project": { + "id": { + "decl": "Project", + "kind": "get", + "type": null, + "array": false + }, + "teamId": { + "decl": "Project", + "kind": "get", + "type": null, + "array": false + }, + "isDefault": { + "decl": "Project", + "kind": "get", + "type": null, + "array": false + }, + "fileCount": { + "decl": "Project", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "Project", + "kind": "getset", + "type": null, + "array": false + }, + "pinned": { + "decl": "Project", + "kind": "getset", + "type": null, + "array": false + }, + "listFiles": { + "decl": "Project", + "kind": "method", + "type": "ProjectFile", + "array": true + }, + "createFile": { + "decl": "Project", + "kind": "method", + "type": "ProjectFile", + "array": false + }, + "duplicate": { + "decl": "Project", + "kind": "method", + "type": "Project", + "array": false + }, + "moveTo": { + "decl": "Project", + "kind": "method", + "type": null, + "array": false + }, + "remove": { + "decl": "Project", + "kind": "method", + "type": null, + "array": false + } + }, + "ProjectFile": { + "id": { + "decl": "ProjectFile", + "kind": "get", + "type": null, + "array": false + }, + "teamId": { + "decl": "ProjectFile", + "kind": "get", + "type": null, + "array": false + }, + "projectId": { + "decl": "ProjectFile", + "kind": "get", + "type": null, + "array": false + }, + "modifiedAt": { + "decl": "ProjectFile", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ProjectFile", + "kind": "getset", + "type": null, + "array": false + }, + "shared": { + "decl": "ProjectFile", + "kind": "getset", + "type": null, + "array": false + }, + "open": { + "decl": "ProjectFile", + "kind": "method", + "type": null, + "array": false + }, + "moveTo": { + "decl": "ProjectFile", + "kind": "method", + "type": null, + "array": false + }, + "duplicate": { + "decl": "ProjectFile", + "kind": "method", + "type": "ProjectFile", + "array": false + }, + "remove": { + "decl": "ProjectFile", + "kind": "method", + "type": null, + "array": false + } + }, "Push": { "type": { "decl": "Push", @@ -8361,6 +8606,44 @@ "array": true } }, + "Team": { + "id": { + "decl": "Team", + "kind": "get", + "type": null, + "array": false + }, + "isDefault": { + "decl": "Team", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "Team", + "kind": "getset", + "type": null, + "array": false + }, + "listProjects": { + "decl": "Team", + "kind": "method", + "type": "Project", + "array": true + }, + "createProject": { + "decl": "Team", + "kind": "method", + "type": "Project", + "array": false + }, + "remove": { + "decl": "Team", + "kind": "method", + "type": null, + "array": false + } + }, "Text": { "type": { "decl": "Text", @@ -9274,6 +9557,12 @@ } }, "TokenCatalog": { + "isEditableTokens": { + "decl": "TokenCatalog", + "kind": "get", + "type": null, + "array": false + }, "themes": { "decl": "TokenCatalog", "kind": "get", @@ -11330,6 +11619,32 @@ "type": null, "array": false } + }, + "WorkspaceContext": { + "status": { + "decl": "WorkspaceContext", + "kind": "get", + "type": null, + "array": false + }, + "fileId": { + "decl": "WorkspaceContext", + "kind": "get", + "type": null, + "array": false + }, + "fileName": { + "decl": "WorkspaceContext", + "kind": "get", + "type": null, + "array": false + }, + "teamId": { + "decl": "WorkspaceContext", + "kind": "get", + "type": null, + "array": false + } } }, "unions": { diff --git a/plugins/apps/plugin-api-test-suite/src/tests/management.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/management.test.ts new file mode 100644 index 0000000000..1c451f5ac1 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/management.test.ts @@ -0,0 +1,555 @@ +import type { Project, ProjectFile, Team } from '@penpot/plugin-types'; +import { expect, expectReject } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import { temporaryName } from '../framework/temporary'; +import type { TestContext } from '../framework/types'; +import { waitFor, waitForAsync } from './wait'; + +// Application management (`penpotMgmt`), available because the suite runs with +// global scope. These tests create projects and files and move between them, +// so they need the real backend. Every project they create has a temporary +// name, and the live CI driver deletes those projects afterwards. + +async function currentTeam(ctx: TestContext): Promise { + const teamId = ctx.penpotMgmt.workspace.teamId; + const team = (await ctx.penpotMgmt.listTeams()).find((t) => t.id === teamId); + if (!team) throw new Error('The current team is not listed'); + return team; +} + +// One temporary project per run holds the files that tests only read. +function temporaryProject(ctx: TestContext): Promise { + return ctx.fixture('management:project', async () => + (await currentTeam(ctx)).createProject({ name: temporaryName('files') }), + ); +} + +async function temporaryFile( + ctx: TestContext, + name: string, +): Promise { + return (await temporaryProject(ctx)).createFile({ name }); +} + +// Opens `file`, runs `fn` there and comes back to the file the test started in. +async function inFile( + ctx: TestContext, + file: ProjectFile, + fn: () => void | Promise, +): Promise { + const home = ctx.penpotMgmt.workspace; + await file.open(); + try { + await fn(); + } finally { + await ctx.penpotMgmt.openFile(home.fileId!, { + teamId: home.teamId ?? undefined, + }); + } +} + +async function listedFile( + project: Project, + fileId: string, +): Promise { + return (await project.listFiles()).find((f) => f.id === fileId); +} + +async function listedProject( + ctx: TestContext, + projectId: string, +): Promise { + return (await ctx.penpotMgmt.listProjects()).find((p) => p.id === projectId); +} + +interface BrandLibrary { + file: ProjectFile; + colorName: string; + typographyName: string; + componentName: string; +} + +// A shared library with one color, typography and component, built once per +// run in its own file. Tests connect it to the open file, and connected +// libraries are no longer listed as available, so each run needs a new one. +function sharedLibrary(ctx: TestContext): Promise { + return ctx.fixture('management:brand-library', async () => { + const file = await temporaryFile(ctx, 'Brand library'); + const library: BrandLibrary = { + file, + colorName: 'brand-primary', + typographyName: 'brand-heading', + componentName: 'brand-button', + }; + + await inFile(ctx, file, () => { + const local = ctx.penpot.library.local; + const color = local.createColor(); + color.name = library.colorName; + color.color = '#112233'; + + const typography = local.createTypography(); + typography.name = library.typographyName; + + const rect = ctx.penpot.createRectangle(); + const component = local.createComponent([rect]); + component.name = library.componentName; + }); + + file.shared = true; + const isListed = async () => + (await ctx.penpot.library.availableLibraries()).some( + (l) => l.id === file.id, + ); + await waitForAsync(isListed); + if (!(await isListed())) { + throw new Error('The shared library is not listed as available'); + } + return library; + }); +} + +describe('Management', () => { + test('workspace describes the open file', (ctx) => { + const workspace = ctx.penpotMgmt.workspace; + expect(workspace.status).toBe('ready'); + expect(workspace.fileId).toBe(ctx.penpot.currentFile?.id); + expect(workspace.fileName).toBe(ctx.penpot.currentFile?.name); + expect(typeof workspace.teamId).toBe('string'); + }); + + test('openFile resolves at once for the open file', async (ctx) => { + const workspace = ctx.penpotMgmt.workspace; + await ctx.penpotMgmt.openFile(workspace.fileId!, { + teamId: workspace.teamId ?? undefined, + }); + expect(ctx.penpotMgmt.workspace).toEqual(workspace); + }); + + test('management methods reject invalid identifiers', async (ctx) => { + const mgmt = ctx.penpotMgmt; + await expectReject(mgmt.openFile('not-a-uuid'), 'file UUID'); + await expectReject(mgmt.getFile('not-a-uuid'), 'file UUID'); + await expectReject( + mgmt.listProjects({ teamId: 'not-a-uuid' }), + 'team UUID', + ); + }); + + describe.skipIfMocked('Discovery', () => { + test('getFile loads the open file', async (ctx) => { + const workspace = ctx.penpotMgmt.workspace; + const file = await ctx.penpotMgmt.getFile(workspace.fileId!); + + expect(file.id).toBe(workspace.fileId); + expect(file.name).toBe(workspace.fileName); + expect(file.teamId).toBe(workspace.teamId); + expect(file.shared).toBe(false); + expect(Number.isNaN(Date.parse(file.modifiedAt))).toBe(false); + + const parent = await listedProject(ctx, file.projectId); + expect(parent).toBeDefined(); + expect((await listedFile(parent!, file.id))?.name).toBe(file.name); + }); + + test('getFile loads a file that is not open', async (ctx) => { + const created = await temporaryFile(ctx, 'Loaded by id'); + const file = await ctx.penpotMgmt.getFile(created.id); + + expect(file.name).toBe('Loaded by id'); + expect(file.projectId).toBe(created.projectId); + expect(file.teamId).toBe(created.teamId); + }); + }); + + describe.skipIfMocked('Teams', () => { + test('listTeams includes the current team', async (ctx) => { + const team = await currentTeam(ctx); + expect(typeof team.name).toBe('string'); + expect(typeof team.isDefault).toBe('boolean'); + }); + + test('team and root list the same projects', async (ctx) => { + const team = await currentTeam(ctx); + const fromTeam = (await team.listProjects()).map((p) => p.id).sort(); + const fromRoot = (await ctx.penpotMgmt.listProjects()) + .map((p) => p.id) + .sort(); + + expect(fromTeam).toEqual(fromRoot); + expect( + (await ctx.penpotMgmt.listProjects()).some((p) => p.isDefault), + ).toBe(true); + }); + + test('teams do not expose deletion', async (ctx) => { + expect('remove' in (await currentTeam(ctx))).toBe(false); + }); + + test('assigning name renames the team', async (ctx) => { + const team = await currentTeam(ctx); + const original = team.name; + const renamed = `${original} (renamed)`; + + try { + team.name = renamed; + expect(team.name).toBe(renamed); + await waitForAsync( + async () => (await currentTeam(ctx)).name === renamed, + ); + expect((await currentTeam(ctx)).name).toBe(renamed); + } finally { + team.name = original; + await waitForAsync( + async () => (await currentTeam(ctx)).name === original, + ); + } + }); + }); + + describe.skipIfMocked('Projects', () => { + test('createProject and createFile return objects', async (ctx) => { + const home = ctx.penpotMgmt.workspace; + const team = await currentTeam(ctx); + const name = temporaryName('created'); + const created = await team.createProject({ name: ` ${name} ` }); + + expect(created.name).toBe(name); + expect(created.teamId).toBe(team.id); + expect(created.isDefault).toBe(false); + expect(created.pinned).toBe(false); + expect(created.fileCount).toBe(0); + + const file = await created.createFile({ name: ' Login ' }); + expect(file.name).toBe('Login'); + expect(file.projectId).toBe(created.id); + expect(file.teamId).toBe(team.id); + expect(file.shared).toBe(false); + expect(created.fileCount).toBe(1); + expect((await created.listFiles()).map((f) => f.id)).toEqual([file.id]); + // Creating does not navigate. + expect(ctx.penpotMgmt.workspace).toEqual(home); + }); + + test('assigning name and pinned updates the project', async (ctx) => { + const team = await currentTeam(ctx); + const created = await team.createProject({ + name: temporaryName('to rename'), + }); + const renamed = temporaryName('renamed'); + + created.name = renamed; + created.pinned = true; + expect(created.name).toBe(renamed); + expect(created.pinned).toBe(true); + + await waitForAsync(async () => { + const listed = await listedProject(ctx, created.id); + return listed?.name === renamed && listed.pinned; + }); + const listed = await listedProject(ctx, created.id); + expect(listed?.name).toBe(renamed); + expect(listed?.pinned).toBe(true); + }); + + test('duplicate copies a project with its files', async (ctx) => { + const team = await currentTeam(ctx); + const original = await team.createProject({ + name: temporaryName('original'), + }); + await original.createFile({ name: 'Copied' }); + const name = temporaryName('copy'); + + const copy = await original.duplicate({ name }); + expect(copy.id).not.toBe(original.id); + expect(copy.name).toBe(name); + expect((await copy.listFiles()).map((f) => f.name)).toEqual(['Copied']); + }); + + test('remove deletes a project', async (ctx) => { + const team = await currentTeam(ctx); + const created = await team.createProject({ + name: temporaryName('removed'), + }); + + await created.remove(); + expect(await listedProject(ctx, created.id)).toBeUndefined(); + }); + + test('moveTo moves a project to another team', async (ctx) => { + const team = await currentTeam(ctx); + const target = await ctx.penpotMgmt.createTeam({ + name: temporaryName('team'), + }); + + try { + const moved = await team.createProject({ + name: temporaryName('moved'), + }); + await moved.moveTo(target); + + expect(moved.teamId).toBe(target.id); + expect((await target.listProjects()).map((p) => p.id)).toContain( + moved.id, + ); + expect(await listedProject(ctx, moved.id)).toBeUndefined(); + } finally { + await target.remove(); + } + }); + }); + + describe.skipIfMocked('Files', () => { + test('assigning name renames a file', async (ctx) => { + const file = await temporaryFile(ctx, 'To rename'); + const parent = await temporaryProject(ctx); + + file.name = ' Renamed '; + expect(file.name).toBe('Renamed'); + await waitForAsync( + async () => (await listedFile(parent, file.id))?.name === 'Renamed', + ); + expect((await listedFile(parent, file.id))?.name).toBe('Renamed'); + }); + + test('duplicate copies a file into its project', async (ctx) => { + const file = await temporaryFile(ctx, 'Original'); + const copy = await file.duplicate({ name: 'Duplicated' }); + + expect(copy.id).not.toBe(file.id); + expect(copy.name).toBe('Duplicated'); + expect(copy.projectId).toBe(file.projectId); + expect( + (await listedFile(await temporaryProject(ctx), copy.id))?.name, + ).toBe('Duplicated'); + }); + + test('moveTo moves a file to another project', async (ctx) => { + const file = await temporaryFile(ctx, 'Moved'); + const team = await currentTeam(ctx); + const target = await team.createProject({ + name: temporaryName('target'), + }); + + await file.moveTo(target); + expect(file.projectId).toBe(target.id); + expect(await listedFile(target, file.id)).toBeDefined(); + expect( + await listedFile(await temporaryProject(ctx), file.id), + ).toBeUndefined(); + }); + + test('remove deletes a file', async (ctx) => { + const file = await temporaryFile(ctx, 'Removed'); + + await file.remove(); + expect( + await listedFile(await temporaryProject(ctx), file.id), + ).toBeUndefined(); + }); + + test('renaming the open file updates the workspace', async (ctx) => { + const workspace = ctx.penpotMgmt.workspace; + const file = await ctx.penpotMgmt.getFile(workspace.fileId!); + const original = file.name; + + try { + file.name = `${original} (renamed)`; + await waitFor( + () => ctx.penpotMgmt.workspace.fileName === `${original} (renamed)`, + ); + expect(ctx.penpotMgmt.workspace.fileName).toBe(`${original} (renamed)`); + } finally { + file.name = original; + } + }); + }); + + describe.skipIfMocked('Navigation', () => { + test('open switches to another file and back', async (ctx) => { + const home = ctx.penpotMgmt.workspace; + const other = await temporaryFile(ctx, 'Navigation'); + + await other.open(); + expect(ctx.penpotMgmt.workspace).toEqual({ + status: 'ready', + fileId: other.id, + fileName: 'Navigation', + teamId: other.teamId, + }); + expect(ctx.penpot.currentFile?.id).toBe(other.id); + expect(ctx.penpot.currentPage).not.toBeNull(); + + await ctx.penpotMgmt.openFile(home.fileId!, { + teamId: home.teamId ?? undefined, + }); + expect(ctx.penpotMgmt.workspace).toEqual(home); + expect(ctx.penpot.currentFile?.id).toBe(home.fileId); + }); + + test('file events fire when switching files', async (ctx) => { + const home = ctx.penpotMgmt.workspace; + const other = await temporaryFile(ctx, 'Events'); + + const workspaces: string[] = []; + const files: string[] = []; + const finished: string[] = []; + const workspaceListener = ctx.penpotMgmt.on('workspacechange', (w) => { + workspaces.push(`${w.status}:${w.fileId}`); + }); + const fileListener = ctx.penpot.on('filechange', (file) => { + files.push(file.id); + }); + const finishListener = ctx.penpot.on('finish', (fileId) => { + finished.push(fileId); + }); + + try { + await inFile(ctx, other, () => undefined); + await waitFor(() => files.includes(home.fileId!)); + } finally { + ctx.penpotMgmt.off(workspaceListener); + ctx.penpot.off(fileListener); + ctx.penpot.off(finishListener); + } + + expect(workspaces).toContain(`loading:${other.id}`); + expect(workspaces).toContain(`ready:${other.id}`); + expect(workspaces).toContain(`ready:${home.fileId}`); + expect(files).toContain(other.id); + expect(files).toContain(home.fileId); + expect(finished).toContain(home.fileId); + expect(finished).toContain(other.id); + }); + + test('off stops workspacechange notifications', async (ctx) => { + const other = await temporaryFile(ctx, 'Unsubscribed'); + let count = 0; + const listener = ctx.penpotMgmt.on('workspacechange', () => { + count += 1; + }); + ctx.penpotMgmt.off(listener); + + await inFile(ctx, other, () => undefined); + expect(count).toBe(0); + }); + + test('changes made in another file persist', async (ctx) => { + const other = await temporaryFile(ctx, 'Persistence'); + let shapeId = ''; + + await inFile(ctx, other, () => { + const rect = ctx.penpot.createRectangle(); + rect.name = 'remote-shape'; + shapeId = rect.id; + }); + + await inFile(ctx, other, () => { + const shape = ctx.penpot.currentPage?.getShapeById(shapeId); + expect(shape?.name).toBe('remote-shape'); + }); + }); + }); + + describe.skipIfMocked('Shared libraries', () => { + test('assigning shared publishes and unpublishes a library', async (ctx) => { + const file = await temporaryFile(ctx, 'Publishing'); + const isListed = async () => + (await ctx.penpot.library.availableLibraries()).some( + (library) => library.id === file.id, + ); + + file.shared = true; + expect(file.shared).toBe(true); + await waitForAsync(isListed); + expect(await isListed()).toBe(true); + expect( + (await listedFile(await temporaryProject(ctx), file.id))?.shared, + ).toBe(true); + + file.shared = false; + await waitForAsync(async () => !(await isListed())); + expect(await isListed()).toBe(false); + }); + + test('availableLibraries summarises a shared library', async (ctx) => { + const library = await sharedLibrary(ctx); + const summary = (await ctx.penpot.library.availableLibraries()).find( + (item) => item.id === library.file.id, + ); + + expect(summary).toBeDefined(); + expect(summary?.name).toBe('Brand library'); + expect(summary?.numColors).toBe(1); + expect(summary?.numTypographies).toBe(1); + expect(summary?.numComponents).toBe(1); + }); + + test('connectLibrary links a shared library to the file', async (ctx) => { + const library = await sharedLibrary(ctx); + const connected = await ctx.penpot.library.connectLibrary( + library.file.id, + ); + + expect(connected.id).toBe(library.file.id); + expect(connected.name).toBe('Brand library'); + expect(connected.colors.map((c) => c.name)).toContain(library.colorName); + expect(connected.typographies.map((t) => t.name)).toContain( + library.typographyName, + ); + expect(connected.components.map((c) => c.name)).toContain( + library.componentName, + ); + expect( + ctx.penpot.library.connected.some((l) => l.id === library.file.id), + ).toBe(true); + }); + + test('connected library colors fill shapes by reference', async (ctx) => { + const library = await sharedLibrary(ctx); + const connected = await ctx.penpot.library.connectLibrary( + library.file.id, + ); + const color = connected.colors.find((c) => c.name === library.colorName); + expect(color).toBeDefined(); + + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.fills = [color!.asFill()]; + + expect(rect.fills[0]?.fillColor).toBe('#112233'); + expect(rect.fills[0]?.fillColorRefId).toBe(color!.id); + expect(rect.fills[0]?.fillColorRefFile).toBe(library.file.id); + }); + + test('connected library components create linked instances', async (ctx) => { + const library = await sharedLibrary(ctx); + const connected = await ctx.penpot.library.connectLibrary( + library.file.id, + ); + const component = connected.components.find( + (c) => c.name === library.componentName, + ); + expect(component).toBeDefined(); + + const instance = component!.instance(); + ctx.board.appendChild(instance); + + expect(instance.isComponentInstance()).toBe(true); + expect(instance.component()?.id).toBe(component!.id); + expect(instance.component()?.libraryId).toBe(library.file.id); + }); + + test('connected libraries reject plugin data writes', async (ctx) => { + const library = await sharedLibrary(ctx); + const connected = await ctx.penpot.library.connectLibrary( + library.file.id, + ); + + expect(() => connected.setPluginData('key', 'value')).toThrow(); + expect(() => + connected.setSharedPluginData('ns', 'key', 'value'), + ).toThrow(); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts index 7c546a9e6a..3009ff8008 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts @@ -52,10 +52,11 @@ describe('Pages', () => { const page = ctx.penpot.createPage(); await ctx.penpot.openPage(page); page.remove(); - // The switch to another page happens asynchronously; wait for it. + // The switch to another page happens asynchronously; wait for it. Until it + // lands the workspace is not ready, so `currentPage` can briefly be null. const start = Date.now(); let active = ctx.penpot.currentPage; - while (active && active.id === page.id && Date.now() - start < 2000) { + while ((!active || active.id === page.id) && Date.now() - start < 2000) { await new Promise((resolve) => setTimeout(resolve, 50)); active = ctx.penpot.currentPage; } diff --git a/plugins/apps/plugin-api-test-suite/src/tests/wait.ts b/plugins/apps/plugin-api-test-suite/src/tests/wait.ts index 36eecc2bb4..dbe60af3b4 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/wait.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/wait.ts @@ -20,3 +20,20 @@ export async function waitFor( await new Promise((resolve) => setTimeout(resolve, interval)); } } + +/** + * Like {@link waitFor}, for conditions that need an async check, such as + * listing from the backend after a change that saves in the background. + */ +export async function waitForAsync( + predicate: () => Promise, + { + timeout = 5000, + interval = 100, + }: { timeout?: number; interval?: number } = {}, +): Promise { + const start = Date.now(); + while (!(await predicate()) && Date.now() - start < timeout) { + await new Promise((resolve) => setTimeout(resolve, interval)); + } +} diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 135defef1f..f1e1d68e53 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -786,42 +786,212 @@ export interface WorkspaceContext { readonly teamId: string | null; } -/** Metadata snapshot of an accessible, non-deleted project. */ -export interface ProjectSummary { +/** + * A team the user belongs to. Requires `manage:teams`. + * + * Values are a snapshot taken when the team was listed; changes made through + * this object update it. + * Property assignments run asynchronously and update values after success. + * Backend failures are logged to the console; assignments cannot be awaited. + */ +export interface Team { readonly id: string; - readonly teamId: string; - readonly name: string; - /** Whether this is the team's Drafts project. */ + /** Whether this is the user's personal team. */ readonly isDefault: boolean; - readonly fileCount: number; + /** + * The team name. Assigning renames the team; requires `manage:teams`. + * Trims the name and requires 1 to 250 characters. + */ + name: string; + /** + * Lists the team's projects. Requires `content:read`. + * + * @example + * ```ts + * const [team] = await penpotMgmt.listTeams(); + * const projects = await team.listProjects(); + * ``` + */ + listProjects(): Promise; + /** Lists projects still recoverable from Trash. Requires `content:read`. */ + listDeletedProjects(): Promise; + /** Lists files still recoverable from Trash. Requires `content:read`. */ + listDeletedFiles(): Promise; + /** + * Creates a project in the team. Requires `manage:projects`. + * + * - Trims `name` and requires 1 to 250 characters. + * - Does not change navigation. + * + * @example + * ```ts + * const project = await team.createProject({ name: 'Design system' }); + * ``` + */ + createProject(options: { name: string }): Promise; } -/** Metadata snapshot of an accessible, non-deleted file. */ -export interface FileSummary { +/** + * A project in the dashboard. + * + * Values are a snapshot taken when the project was listed; changes made through + * this object update it. + * Property assignments run asynchronously and update values after success. + * Backend failures are logged to the console; assignments cannot be awaited. + */ +export interface Project { + readonly id: string; + readonly teamId: string; + /** Whether this is the team's Drafts project. */ + readonly isDefault: boolean; + /** Number of files when the project was listed; file operations do not update it. */ + readonly fileCount: number; + /** + * The project name. Assigning renames the project; requires `manage:projects`. + * Trims the name and requires 1 to 250 characters. + */ + name: string; + /** Whether the project is pinned in the sidebar. Assigning requires `manage:projects`. */ + pinned: boolean; + /** + * Lists the files in the project. Requires `content:read`. + * + * @example + * ```ts + * const files = await project.listFiles(); + * ``` + */ + listFiles(): Promise; + /** Lists the project's files still recoverable from Trash. Requires `content:read`. */ + listDeletedFiles(): Promise; + /** + * Restores the project through its files using the dashboard's Trash operation. + * Requires `manage:projects` and team edit access. Does not require `manage:delete`. + * Rejects if the project has no recoverable files, including empty projects. + */ + restore(): Promise; + /** + * Creates a file with an initial page in the project. Requires `content:write`. + * + * - Trims `name` and requires 1 to 250 characters. + * - Does not open the file. Use `ProjectFile.open` to edit it. + * + * @example + * ```ts + * const file = await project.createFile({ name: 'Login' }); + * await file.open(); + * ``` + */ + createFile(options: { name: string }): Promise; + /** + * Duplicates the project with all its files. Requires `manage:projects`. + * + * `name` defaults to the name the backend chooses. + */ + duplicate(options?: { name?: string }): Promise; + /** Moves the project to another team. Requires `manage:teams`. */ + moveTo(team: Team): Promise; + /** + * Moves the project and its files to Trash. Requires `manage:delete`. + * They can be restored during the retention period. Cannot permanently delete. + */ + remove(): Promise; +} + +/** + * A file in the dashboard, which may or may not be open. + * + * Values are a snapshot taken when the file was listed; changes made through + * this object update it. To read or edit its content, `open` it and use + * `penpot.currentFile`. + * Property assignments run asynchronously and update values after success. + * Backend failures are logged to the console; assignments cannot be awaited. + */ +export interface ProjectFile { readonly id: string; readonly teamId: string; readonly projectId: string; - readonly name: string; /** Last modification time as an ISO 8601 string in UTC. */ readonly modifiedAt: string; + /** + * The file name. Assigning renames the file; requires `content:write`. + * Trims the name and requires 1 to 250 characters. + */ + name: string; + /** + * Whether the file is shared as a library with its team. Assigning requires + * `library:write`. A shared file is listed by + * `penpot.library.availableLibraries()`; unsharing copies its assets into + * every file that uses it and disconnects them. + */ + shared: boolean; + /** + * Opens the file in this tab and resolves once its workspace and page are + * ready. Requires `content:read`. + */ + open(): Promise; + /** + * Restores the file using the dashboard's Trash operation, including its parent + * project if deleted. Requires `content:write` and team edit access. + * Rejects if the file no longer exists. Does not require `manage:delete`. + */ + restore(): Promise; + /** + * Moves the file to another project of the same team. Requires `content:write`. + * Rejects a destination that currently belongs to another team. + */ + moveTo(project: Project): Promise; + /** + * Duplicates the file in its project. Requires `content:write`. + * + * `name` defaults to the name the backend chooses. + */ + duplicate(options?: { name?: string }): Promise; + /** + * Moves the file to Trash. Requires `manage:delete`. + * It can be restored during the retention period. Cannot permanently delete. + */ + remove(): Promise; } /** * Application management API exposed as `penpotMgmt` to global plugins. * - * - Discovery and navigation require `content:read`; creation requires `content:write`. - * - Available in the dashboard and file workspaces. - * - Use this API to discover, create and open files; file operations on `penpot` require a ready workspace. + * - Lists teams, projects and files, and opens files, from the dashboard or a + * file workspace. Teams, projects and files are objects with their own + * properties and methods. + * - Listing and opening require `content:read`; teams require `manage:teams`. + * - File operations on `penpot` require a ready workspace. * - Reacquire file, page and shape objects after changing files. */ export interface PenpotMgmt { /** Current workspace availability and metadata; usable for file operations when status is `ready`. */ readonly workspace: WorkspaceContext; /** - * Lists accessible, non-deleted projects as metadata snapshots. + * Lists the teams of the user. Requires `manage:teams`. + * + * @example + * ```ts + * const teams = await penpotMgmt.listTeams(); + * ``` + */ + listTeams(): Promise; + /** + * Creates a team with the user as owner. Requires `manage:teams`. + * + * - Trims `name` and requires 1 to 250 characters. + * - Does not change navigation. + * + * @example + * ```ts + * const team = await penpotMgmt.createTeam({ name: 'Marketing' }); + * ``` + */ + createTeam(options: { name: string }): Promise; + /** + * Lists the projects of a team. Requires `content:read`. * * - `teamId` defaults to the current team. - * - Queries the backend without opening a file or changing navigation. * - Rejects invalid identifiers and backend access errors. * * @example @@ -829,55 +999,27 @@ export interface PenpotMgmt { * const projects = await penpotMgmt.listProjects(); * ``` */ - listProjects(options?: { teamId?: string }): Promise; + listProjects(options?: { teamId?: string }): Promise; /** - * Lists accessible, non-deleted files in the specified project as metadata snapshots. - * - * - Requires an explicit `projectId`. - * - Queries the backend without opening a file or changing navigation. - * - Rejects invalid identifiers and backend access errors. + * Lists projects still recoverable from Trash. Requires `content:read`. + * `teamId` defaults to the current team. Use `Project.restore` to recover them. + */ + listDeletedProjects(options?: { teamId?: string }): Promise; + /** + * Lists files still recoverable from Trash. Requires `content:read`. + * `teamId` defaults to the current team. Use `ProjectFile.restore` to recover them. + */ + listDeletedFiles(options?: { teamId?: string }): Promise; + /** + * Loads a file by id, open or not. Requires `content:read`. * * @example * ```ts - * const files = await penpotMgmt.listFiles({ projectId: project.id }); + * const file = await penpotMgmt.getFile(penpotMgmt.workspace.fileId!); + * file.name = 'Checkout'; * ``` */ - listFiles(options: { projectId: string }): Promise; - /** - * Creates a project and returns its metadata snapshot. Requires `content:write`. - * - * - `teamId` defaults to the current team. - * - Trims `name` and requires 1 to 250 characters. - * - Does not change navigation or open a file. - * - Rejects invalid input, backend access errors and quota errors. - * - * @example - * ```ts - * const project = await penpotMgmt.createProject({ name: 'Design system' }); - * ``` - */ - createProject(options: { - name: string; - teamId?: string; - }): Promise; - /** - * Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`. - * - * - Requires an explicit `projectId`. - * - Trims `name` and requires 1 to 250 characters. - * - Does not open the file or change navigation. Use `openFile` to edit it. - * - Rejects invalid input, backend access errors and quota errors. - * - * @example - * ```ts - * const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' }); - * await penpotMgmt.openFile(file.id, { teamId: file.teamId }); - * ``` - */ - createFile(options: { - name: string; - projectId: string; - }): Promise; + getFile(fileId: string): Promise; /** * Opens a file in this tab and resolves once its workspace and page are ready. * diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index 44f5645cc5..f122c46ffe 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -419,21 +419,29 @@ export function createApi( checkPermission('content:read', false); return plugin.context.management!.workspace; }, + listTeams() { + checkPermission('manage:teams', false); + return plugin.context.management!.listTeams(); + }, + createTeam(options) { + checkPermission('manage:teams', false); + return plugin.context.management!.createTeam(options); + }, listProjects(options) { checkPermission('content:read', false); return plugin.context.management!.listProjects(options); }, - listFiles(options) { + listDeletedProjects(options) { checkPermission('content:read', false); - return plugin.context.management!.listFiles(options); + return plugin.context.management!.listDeletedProjects(options); }, - createProject(options) { - checkPermission('content:write', false); - return plugin.context.management!.createProject(options); + listDeletedFiles(options) { + checkPermission('content:read', false); + return plugin.context.management!.listDeletedFiles(options); }, - createFile(options) { - checkPermission('content:write', false); - return plugin.context.management!.createFile(options); + getFile(fileId) { + checkPermission('content:read', false); + return plugin.context.management!.getFile(fileId); }, openFile(fileId, options) { checkPermission('content:read', false); diff --git a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts index e53eddacc3..b90ec91f63 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts @@ -54,23 +54,39 @@ describe('Plugin api', () => { vi.clearAllMocks(); }); - describe('management listing', () => { + describe('management', () => { it.each(['none', 'loading', 'ready'])( - 'lists metadata with workspace status %s', + 'lists teams and projects and loads files with workspace status %s', async (status) => { + const teams = [{ id: 'team-id' }]; const projects = [{ id: 'project-id' }]; - const files = [{ id: 'file-id' }]; + const file = { id: 'file-id' }; const management = { workspace: { status }, + listTeams: vi.fn().mockResolvedValue(teams), + createTeam: vi.fn().mockResolvedValue(teams[0]), listProjects: vi.fn().mockResolvedValue(projects), - listFiles: vi.fn().mockResolvedValue(files), + listDeletedProjects: vi.fn().mockResolvedValue(projects), + listDeletedFiles: vi.fn().mockResolvedValue([file]), + getFile: vi.fn().mockResolvedValue(file), }; const { penpotMgmt } = createApi({ ...pluginManager, - manifest: { ...pluginManager.manifest, scope: 'global' }, + manifest: { + ...pluginManager.manifest, + permissions: ['content:read', 'manage:teams'], + scope: 'global', + }, context: { ...pluginManager.context, management }, } as any); + await expect(penpotMgmt.listTeams()).resolves.toEqual(teams); + await expect( + penpotMgmt.createTeam({ name: 'Marketing' }), + ).resolves.toEqual(teams[0]); + expect(management.createTeam).toHaveBeenCalledWith({ + name: 'Marketing', + }); await expect(penpotMgmt.listProjects()).resolves.toEqual(projects); expect(management.listProjects).toHaveBeenLastCalledWith(undefined); await penpotMgmt.listProjects({ teamId: 'team-id' }); @@ -78,18 +94,24 @@ describe('Plugin api', () => { teamId: 'team-id', }); await expect( - penpotMgmt.listFiles({ projectId: 'project-id' }), - ).resolves.toEqual(files); - expect(management.listFiles).toHaveBeenCalledWith({ - projectId: 'project-id', + penpotMgmt.listDeletedProjects({ teamId: 'team-id' }), + ).resolves.toEqual(projects); + expect(management.listDeletedProjects).toHaveBeenCalledWith({ + teamId: 'team-id', }); + await expect(penpotMgmt.listDeletedFiles()).resolves.toEqual([file]); + expect(management.listDeletedFiles).toHaveBeenCalledWith(undefined); + await expect(penpotMgmt.getFile('file-id')).resolves.toEqual(file); + expect(management.getFile).toHaveBeenCalledWith('file-id'); }, ); - it('requires content:read before querying metadata', () => { + it('requires content:read before listing projects or loading files', () => { const management = { listProjects: vi.fn(), - listFiles: vi.fn(), + listDeletedProjects: vi.fn(), + listDeletedFiles: vi.fn(), + getFile: vi.fn(), }; const { penpotMgmt } = createApi({ ...pluginManager, @@ -101,98 +123,66 @@ describe('Plugin api', () => { context: { ...pluginManager.context, management }, } as any); - expect(() => penpotMgmt.listProjects()).toThrow(); - expect(() => penpotMgmt.listFiles({ projectId: 'project-id' })).toThrow(); + expect(() => penpotMgmt.listProjects()).toThrow( + 'Permission content:read is not granted', + ); + expect(() => penpotMgmt.getFile('file-id')).toThrow( + 'Permission content:read is not granted', + ); + expect(() => penpotMgmt.listDeletedProjects()).toThrow( + 'Permission content:read is not granted', + ); + expect(() => penpotMgmt.listDeletedFiles()).toThrow( + 'Permission content:read is not granted', + ); expect(management.listProjects).not.toHaveBeenCalled(); - expect(management.listFiles).not.toHaveBeenCalled(); + expect(management.listDeletedProjects).not.toHaveBeenCalled(); + expect(management.listDeletedFiles).not.toHaveBeenCalled(); + expect(management.getFile).not.toHaveBeenCalled(); }); - }); - describe('management creation', () => { - it.each(['none', 'loading', 'ready'])( - 'creates projects and files with workspace status %s', - async (status) => { - const project = { id: 'project-id', name: 'Buttons' }; - const file = { id: 'file-id', name: 'Login' }; - const management = { - workspace: { status }, - createProject: vi.fn().mockResolvedValue(project), - createFile: vi.fn().mockResolvedValue(file), - }; - const { penpotMgmt } = createApi({ - ...pluginManager, - manifest: { - ...pluginManager.manifest, - permissions: ['content:write'], - scope: 'global', - }, - context: { ...pluginManager.context, management }, - } as any); + it('requires manage:teams before listing or creating teams', () => { + const management = { listTeams: vi.fn(), createTeam: vi.fn() }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { + ...pluginManager.manifest, + permissions: ['content:read'], + scope: 'global', + }, + context: { ...pluginManager.context, management }, + } as any); - await expect( - penpotMgmt.createProject({ name: 'Buttons' }), - ).resolves.toEqual(project); - await expect( - penpotMgmt.createProject({ name: 'Buttons', teamId: 'team-id' }), - ).resolves.toEqual(project); - expect(management.createProject).toHaveBeenLastCalledWith({ - name: 'Buttons', - teamId: 'team-id', - }); - await expect( - penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), - ).resolves.toEqual(file); - expect(management.createFile).toHaveBeenCalledWith({ - name: 'Login', - projectId: 'project-id', - }); - }, - ); + expect(() => penpotMgmt.listTeams()).toThrow( + 'Permission manage:teams is not granted', + ); + expect(() => penpotMgmt.createTeam({ name: 'Marketing' })).toThrow( + 'Permission manage:teams is not granted', + ); + expect(management.listTeams).not.toHaveBeenCalled(); + expect(management.createTeam).not.toHaveBeenCalled(); + }); - it.each([{ permissions: [] }, { permissions: ['content:read'] }])( - 'requires content:write before creating with permissions $permissions', - ({ permissions }) => { - const management = { - createProject: vi.fn(), - createFile: vi.fn(), - }; - const { penpotMgmt } = createApi({ - ...pluginManager, - manifest: { ...pluginManager.manifest, permissions, scope: 'global' }, - context: { ...pluginManager.context, management }, - } as any); - - expect(() => penpotMgmt.createProject({ name: 'Buttons' })).toThrow( - 'Permission content:write is not granted', - ); - expect(() => - penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), - ).toThrow('Permission content:write is not granted'); - expect(management.createProject).not.toHaveBeenCalled(); - expect(management.createFile).not.toHaveBeenCalled(); - }, - ); - - it('preserves backend creation errors', async () => { + it('preserves backend errors', async () => { const error = new Error('Access denied'); const { penpotMgmt } = createApi({ ...pluginManager, - manifest: { ...pluginManager.manifest, scope: 'global' }, + manifest: { + ...pluginManager.manifest, + permissions: ['content:read', 'manage:teams'], + scope: 'global', + }, context: { ...pluginManager.context, management: { - createProject: vi.fn().mockRejectedValue(error), - createFile: vi.fn().mockRejectedValue(error), + listTeams: vi.fn().mockRejectedValue(error), + getFile: vi.fn().mockRejectedValue(error), }, }, } as any); - await expect(penpotMgmt.createProject({ name: 'Buttons' })).rejects.toBe( - error, - ); - await expect( - penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), - ).rejects.toBe(error); + await expect(penpotMgmt.listTeams()).rejects.toBe(error); + await expect(penpotMgmt.getFile('file-id')).rejects.toBe(error); }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts index 357fe9f95f..b2cd2f81af 100644 --- a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts +++ b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts @@ -21,6 +21,9 @@ export const manifestSchema = z.object({ 'allow:downloads', 'allow:localstorage', 'allow:global', + 'manage:projects', + 'manage:teams', + 'manage:delete', 'clipboard:read', 'clipboard:write', ]), From 9f598fca5b55ed67a2a70902210e1c5927fbbcbd Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Wed, 14 Oct 2026 15:26:24 +0200 Subject: [PATCH 009/167] :sparkles: Add component management to plugins API (#12303) --- .../app/main/data/workspace/libraries.cljs | 3 +- frontend/src/app/plugins/library.cljs | 90 ++++++- frontend/src/app/plugins/shape.cljs | 32 +++ .../frontend_tests/plugins/library_test.cljs | 161 ++++++++++++ plugins/CHANGELOG.md | 3 + .../src/generated/api-surface.json | 126 ++++++++++ .../src/tests/components.test.ts | 234 ++++++++++++++++++ plugins/libs/plugin-types/index.d.ts | 48 ++++ 8 files changed, 691 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 2113f76ea1..b84faf4356 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -531,7 +531,8 @@ (-> (pcb/empty-changes it nil) (cll/generate-duplicate-component library component-id new-component-id))] (rx/of - (ptk/data-event :layout/update {:ids [(:id main-instance)]}) + (ptk/data-event :layout/update {:page-id (:main-instance-page (get-in library [:data :components component-id])) + :ids [(:id main-instance)]}) (dch/commit-changes changes))))))) (defn delete-component diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 56782eb1e2..a5a70e1cfa 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -19,6 +19,7 @@ [app.common.types.variant :as ctv] [app.common.uuid :as uuid] [app.main.data.plugins :as dp] + [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.variants :as dwv] @@ -845,12 +846,81 @@ :remove (fn [] - (cond - (not (r/check-permission plugin-id "library:write")) - (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") + (let [component (u/locate-library-component file-id id)] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") - :else - (st/emit! (dwl/delete-component {:id id})))) + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :remove "Can only remove components from the current file's library") + + (nil? component) + (u/not-valid plugin-id :remove "Component does not exist") + + (not (:deleted component)) + (st/emit! (-> (dwl/delete-component {:id id}) + (se/add-event plugin-id)))))) + + :annotation + {:get (fn [] (:annotation (u/locate-library-component file-id id))) + :set + (fn [value] + (let [component (u/locate-library-component file-id id)] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :annotation "Plugin doesn't have 'library:write' permission") + + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :annotation "Can only edit annotations in the current file's library") + + (nil? component) + (u/not-valid plugin-id :annotation "Component does not exist") + + (:deleted component) + (u/not-valid plugin-id :annotation "Cannot edit a deleted component") + + (not (or (nil? value) (and (string? value) (<= (count value) 300)))) + (u/not-valid plugin-id :annotation "Expected null or a string of at most 300 characters") + + :else + (st/emit! (-> (dw/update-component-annotation id value) + (se/add-event plugin-id))))))} + + :duplicate + (fn [] + (let [component (u/locate-library-component file-id id)] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :duplicate "Plugin doesn't have 'library:write' permission") + + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :duplicate "Can only duplicate components in the current file's library") + + (nil? component) + (u/not-valid plugin-id :duplicate "Component does not exist") + + (:deleted component) + (u/not-valid plugin-id :duplicate "Cannot duplicate a deleted component") + + :else + (let [new-id (uuid/next)] + (st/emit! (-> (dwl/duplicate-component file-id id new-id) + (se/add-event plugin-id))) + (lib-component-proxy plugin-id file-id new-id))))) + + :restore + (fn [] + (let [component (u/locate-library-component file-id id)] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :restore "Plugin doesn't have 'library:write' permission") + + (nil? component) + (u/not-valid plugin-id :restore "Component does not exist") + + (:deleted component) + (st/emit! (-> (dwl/restore-component file-id id) + (se/add-event plugin-id)))))) :instance (fn [] @@ -1080,6 +1150,16 @@ (map #(lib-component-proxy plugin-id file-id %)))] (apply array components)))} + :deletedComponents + {:get + (fn [] + (let [components (->> (u/locate-file file-id) + :data + :components + (filter (comp :deleted second)) + (map #(lib-component-proxy plugin-id file-id (first %))))] + (apply array components)))} + :tokens {:this true :get diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 6be18bd4fa..b34b329598 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -1587,6 +1587,38 @@ target-id true))))) + :updateMain + (fn [] + (let [shape (u/locate-shape file-id page-id id) + objects (u/locate-objects file-id page-id) + component (when (:component-id shape) + (u/locate-library-component (:component-file shape) (:component-id shape)))] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :updateMain "Plugin doesn't have 'content:write' permission") + + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :updateMain "Plugin doesn't have 'library:write' permission") + + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :updateMain "Can only update a component from a copy in the current file") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :updateMain "Cannot modify a page that is not currently active") + + (not (and (ctk/instance-head? shape) (ctk/in-component-copy? shape))) + (u/not-valid plugin-id :updateMain "The shape is not a component copy head") + + (u/inside-component-copy? objects shape) + (u/not-valid plugin-id :updateMain "Cannot update the main from a shape inside another component copy") + + (or (nil? component) (:deleted component)) + (u/not-valid plugin-id :updateMain "The main component does not exist or is deleted") + + :else + (st/emit! (-> (dwl/update-component-sync id (:component-file shape)) + (se/add-event plugin-id)))))) + :resetOverrides (fn [] (let [shape (u/locate-shape file-id page-id id)] diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index 70d3e3fb7d..e7e9efb646 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -6,6 +6,12 @@ (ns frontend-tests.plugins.library-test (:require + [app.common.files.changes-builder :as pcb] + [app.common.logic.shapes :as cls] + [app.common.test-helpers.components :as cthc] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as cths] [app.common.types.component :as ctk] [app.common.uuid :as uuid] [app.main.data.event :as ev] @@ -14,8 +20,10 @@ [app.main.data.workspace.variants :as dwv] [app.main.repo :as rp] [app.main.store :as st] + [app.main.worker :as mw] [app.plugins.library :as library] [app.plugins.register :as r] + [app.plugins.shape :as shape] [app.plugins.text :as text] [app.plugins.utils :as u] [app.util.object :as obj] @@ -24,10 +32,163 @@ [cuerdas.core :as str] [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] + [frontend-tests.helpers.state :as ths] [potok.v2.core :as ptk])) (def ^:private plugin-id "00000000-0000-0000-0000-000000000000") +(defn- file-with-component + [] + (-> (cthf/sample-file :restore-file :page-label :main-page) + (cths/add-sample-shape :main :type :frame) + (cths/add-sample-shape :child :parent-label :main) + (cthc/make-component :restorable :main) + (cthc/instantiate-component :restorable :copy))) + +(defn- file-with-deleted-component + [] + (let [file (file-with-component) + page (cthf/current-page file) + changes (-> (pcb/empty-changes nil (:id page)) + (cls/generate-delete-shapes (:data file) page (:objects page) #{(thi/id :main)} {}) + second)] + (cthf/apply-changes file changes))) + +(t/deftest ^:async component-restore-preserves-the-users-view + (doseq [scenario [:same-page :other-page :linked-library]] + (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})) + view {:selected #{(thi/id :copy)} + :zoom 2 + :vport {:x 100 :y 200 :width 800 :height 600} + :vbox {:x 50 :y 100 :width 400 :height 300}} + view-of #(select-keys % [:current-file-id :current-page-id :workspace-local])] + (ptk/emit! store #(assoc % :workspace-local view)) + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (let [before (view-of @store)] + (await + (mock/with-mocks* + {st/state store + st/emit! (mock/stub (fn [& events] (apply ptk/emit! store events))) + mw/ask! (mock/stub (fn [_] (rx/empty))) + shape/lib-component-proxy library/lib-component-proxy} + (let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy)) + component (.component copy)] + (t/is (true? (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted]))) + (.restore component) + (await (async/wait-for #(not (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted])) + "component restored")) + (.restore component) + (await (async/settle)) + (t/is (= (thi/id :main) + (get-in @store [:files (:id file) :data :pages-index (thi/id :main-page) :objects (thi/id :main) :id]))) + (t/is (= (thi/id :child) + (get-in @store [:files (:id file) :data :pages-index (thi/id :main-page) :objects (thi/id :child) :id]))) + (t/is (= before (view-of @store))) + (t/is (= [(str (thi/id :restorable))] + (mapv #(.-id %) (.-components (library/library-proxy plugin-id (:id file))))))))))))) + +(t/deftest ^:async component-restore-checks-library-write-permission + (let [file (file-with-deleted-component) + store (ths/setup-store file {:renderer :svg}) + 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))) + r/check-permission (mock/stub (constantly false))} + (let [component (library/lib-component-proxy plugin-id (:id file) (thi/id :restorable))] + (t/is (thrown-with-msg? js/Error #"library:write" (.restore component))) + (await (async/settle)) + (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-a-missing-component + (let [file (cthf/sample-file :restore-file) + store (ths/setup-store file {:renderer :svg})] + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (await + (mock/with-mocks* + {st/state store} + (let [component (library/lib-component-proxy plugin-id (:id file) (uuid/next))] + (t/is (thrown-with-msg? js/Error #"Component does not exist" (.restore component)))))))) + +(def ^:private component-mutations + [["remove" #(.remove %)] + ["duplicate" #(.duplicate %)] + ["annotation" #(set! (.-annotation %) "Usage")]]) + +(t/deftest ^:async component-mutations-check-library-write-permission + (let [file (file-with-component) + store (ths/setup-store file {:renderer :svg}) + 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))) + r/check-permission (mock/stub (constantly false))} + (let [component (library/lib-component-proxy plugin-id (:id file) (thi/id :restorable))] + (doseq [[operation mutate] component-mutations] + (t/testing operation + (t/is (thrown-with-msg? js/Error #"library:write" (mutate component))))) + (await (async/settle)) + (t/is (empty? @events)) + (t/is (= (:files before) (:files @store)))))))) + +(t/deftest ^:async component-mutations-reject-linked-libraries + (let [library (file-with-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))] + (doseq [[operation mutate] component-mutations] + (t/testing operation + (t/is (thrown-with-msg? js/Error #"current file" (mutate component))))) + (await (async/settle)) + (t/is (empty? @events)) + (t/is (= (:files before) (:files @store)))))))) + +(t/deftest ^:async component-mutations-reject-missing-components + (let [file (cthf/sample-file :restore-file) + store (ths/setup-store file {:renderer :svg})] + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (await + (mock/with-mocks* + {st/state store} + (let [component (library/lib-component-proxy plugin-id (:id file) (uuid/next))] + (doseq [[operation mutate] component-mutations] + (t/testing operation + (t/is (thrown-with-msg? js/Error #"Component does not exist" (mutate component)))))))))) + +(t/deftest ^:async update-main-checks-content-and-library-write-permissions + (doseq [denied ["content:write" "library:write"]] + (let [file (file-with-component) + store (ths/setup-store file {:renderer :svg}) + 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))) + r/check-permission (mock/stub (fn [_ permission] (not= permission denied)))} + (let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy))] + (t/is (thrown-with-msg? js/Error (re-pattern denied) (.updateMain copy))) + (await (async/settle)) + (t/is (empty? @events)))))))) + (t/deftest library-asset-proxies-expose-library-id (let [file-id (random-uuid) id (random-uuid)] diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 4321ec2fc9..af3cc43b0b 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,6 +2,8 @@ ### 🚀 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 `Shape.updateMain()`, `LibraryComponent.duplicate()`, writable component `annotation`, and `Library.deletedComponents`. Declared the existing `LibraryComponent.remove()` method. - **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. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished @@ -9,6 +11,7 @@ ### 🩹 Fixes +- **plugins-runtime:** `LibraryComponent.remove()` now rejects linked-library and missing component handles, and does nothing for an already deleted component. - **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction. - **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape. - **plugins-runtime**: Writing `trigger`, `delay` or `action` on an interaction the shape no longer has now raises a validation error. The write used to be sent to the workspace with no position to apply it at, where it failed out of the plugin's reach: nothing was written and nothing was reported. diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json index b32168ec1b..5034ba9730 100644 --- a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -273,6 +273,7 @@ "createColor", "createComponent", "createTypography", + "deletedComponents", "id", "name", "tokens", @@ -287,9 +288,13 @@ "opacity" ], "LibraryComponent": [ + "annotation", + "duplicate", "instance", "isVariant", "mainInstance", + "remove", + "restore", "transformInVariant" ], "LibraryContext": [ @@ -454,6 +459,7 @@ "swapComponent", "switchVariant", "tokens", + "updateMain", "visible", "waitForLayoutUpdate", "width", @@ -1124,6 +1130,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -1630,6 +1642,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -2774,6 +2792,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -4022,6 +4046,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -4612,6 +4642,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -4994,6 +5030,12 @@ "type": "LibraryComponent", "array": true }, + "deletedComponents": { + "decl": "Library", + "kind": "get", + "type": "LibraryComponent", + "array": true + }, "tokens": { "decl": "Library", "kind": "get", @@ -5154,6 +5196,30 @@ } }, "LibraryComponent": { + "annotation": { + "decl": "LibraryComponent", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "LibraryComponent", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "remove": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "restore": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, "instance": { "decl": "LibraryComponent", "kind": "method", @@ -5524,6 +5590,30 @@ "type": null, "array": false }, + "annotation": { + "decl": "LibraryComponent", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "LibraryComponent", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "remove": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "restore": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, "instance": { "decl": "LibraryComponent", "kind": "method", @@ -6246,6 +6336,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -7088,6 +7184,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -7622,6 +7724,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -8222,6 +8330,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -8794,6 +8908,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -11098,6 +11218,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", diff --git a/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts index ecdd6c5118..92d68fd6aa 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts @@ -199,6 +199,66 @@ describe('Component instances', () => { } }); + test('restore restores a deleted component without changing the view', async (ctx) => { + const originalPage = ctx.penpot.currentPage; + expect(originalPage).not.toBeNull(); + if (!originalPage) return; + + const comp = makeComponent(ctx); + const main = comp.mainInstance() as Board; + const mainId = main.id; + const childIds = main.children.map((child) => child.id); + const inst = comp.instance(); + ctx.board.appendChild(inst); + comp.remove(); + await ctx.penpot.waitForLayoutUpdate(); + expect( + ctx.penpot.library.local.components.some((c) => c.id === comp.id), + ).toBe(false); + expect(originalPage.getShapeById(mainId)).toBeNull(); + + const otherPage = ctx.penpot.createPage(); + try { + await ctx.penpot.openPage(otherPage); + const selected = ctx.penpot.createRectangle(); + (otherPage.root as Board).appendChild(selected); + ctx.penpot.selection = [selected]; + ctx.penpot.viewport.zoom = 2; + ctx.penpot.viewport.center = { x: 300, y: 200 }; + await ctx.penpot.waitForLayoutUpdate(); + + const zoom = ctx.penpot.viewport.zoom; + const center = ctx.penpot.viewport.center; + const deleted = inst.component(); + expect(deleted).not.toBeNull(); + if (!deleted) return; + + deleted.restore(); + await waitFor(() => + ctx.penpot.library.local.components.some((c) => c.id === comp.id), + ); + await ctx.penpot.waitForLayoutUpdate(); + + expect( + ctx.penpot.library.local.components.some((c) => c.id === comp.id), + ).toBe(true); + expect(originalPage.getShapeById(mainId)?.id).toBe(mainId); + expect( + (deleted.mainInstance() as Board).children.map((child) => child.id), + ).toEqual(childIds); + expect(ctx.penpot.currentPage?.id).toBe(otherPage.id); + expect(ctx.penpot.selection.map((shape) => shape.id)).toEqual([ + selected.id, + ]); + expect(ctx.penpot.viewport.zoom).toBeCloseTo(zoom); + expect(ctx.penpot.viewport.center.x).toBeCloseTo(center.x); + expect(ctx.penpot.viewport.center.y).toBeCloseTo(center.y); + } finally { + await ctx.penpot.openPage(originalPage); + otherPage.remove(); + } + }); + test('detach turns an instance into a basic shape', (ctx) => { const inst = instanceOf(ctx); inst.detach(); @@ -319,3 +379,177 @@ describe('Component instances', () => { expect(inst.isComponentInstance()).toBeFalsy(); }); }); + +describe('Component assets', () => { + test('duplicate creates an independent component with its annotation', async (ctx) => { + const source = makeComponent(ctx); + source.annotation = 'Button usage'; + const original = source.mainInstance() as Board; + const duplicate = source.duplicate(); + await ctx.penpot.waitForLayoutUpdate(); + const main = duplicate.mainInstance() as Board; + ctx.board.appendChild(main); + + expect(duplicate.id).not.toBe(source.id); + expect(main.id).not.toBe(original.id); + expect(main.isComponentMainInstance()).toBe(true); + expect(main.component()?.id).toBe(duplicate.id); + expect(main.children).toHaveLength(original.children.length); + expect(main.children[0].id).not.toBe(original.children[0].id); + expect(duplicate.annotation).toBe('Button usage'); + duplicate.name = 'Duplicate button'; + expect(source.name).not.toBe(duplicate.name); + }); + + test('annotations can be created, edited, and removed', async (ctx) => { + const comp = makeComponent(ctx); + expect(comp.annotation).toBeNull(); + comp.annotation = 'Use for primary actions'; + expect(comp.annotation).toBe('Use for primary actions'); + comp.annotation = 'Use for secondary actions'; + expect(comp.annotation).toBe('Use for secondary actions'); + expect(() => { + comp.annotation = 42 as unknown as string; + }).toThrow(); + expect(() => { + comp.annotation = 'x'.repeat(301); + }).toThrow(); + expect(comp.annotation).toBe('Use for secondary actions'); + comp.annotation = null; + await ctx.penpot.waitForLayoutUpdate(); + expect(comp.annotation).toBeNull(); + }); + + test('deletedComponents lists and restores a component without surviving copies', async (ctx) => { + const comp = makeComponent(ctx); + const mainId = comp.mainInstance().id; + const page = ctx.penpot.currentPage; + comp.remove(); + await ctx.penpot.waitForLayoutUpdate(); + expect(page?.getShapeById(mainId)).toBeNull(); + expect( + ctx.penpot.library.local.components.some((c) => c.id === comp.id), + ).toBe(false); + + const deleted = ctx.penpot.library.local.deletedComponents.find( + (c) => c.id === comp.id, + ); + expect(deleted).toBeDefined(); + if (!deleted) return; + deleted.remove(); + deleted.restore(); + await ctx.penpot.waitForLayoutUpdate(); + expect(page?.getShapeById(mainId)?.id).toBe(mainId); + expect( + ctx.penpot.library.local.components.some((c) => c.id === comp.id), + ).toBe(true); + expect( + ctx.penpot.library.local.deletedComponents.some((c) => c.id === comp.id), + ).toBe(false); + }); + + test('updateMain applies a copy override to the main and other copies', async (ctx) => { + const childColor = (board: Board) => { + const fill = board.children[0].fills[0]; + return typeof fill === 'string' ? fill : fill?.fillColor; + }; + const comp = makeComponent(ctx); + const main = comp.mainInstance() as Board; + const copy = comp.instance() as Board; + const other = comp.instance() as Board; + ctx.board.appendChild(copy); + ctx.board.appendChild(other); + copy.children[0].fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + copy.updateMain(); + await ctx.penpot.waitForLayoutUpdate(); + await waitFor(() => childColor(main) === '#ff0000'); + expect(childColor(main)).toBe('#ff0000'); + expect(childColor(other)).toBe('#ff0000'); + + main.children[0].fills = [{ fillColor: '#00ff00', fillOpacity: 1 }]; + await waitFor(() => childColor(copy) === '#00ff00'); + expect(childColor(copy)).toBe('#00ff00'); + }); + + test('updateMain rejects plain shapes, main instances, and copy children', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + expect(() => rect.updateMain()).toThrow(); + const comp = makeComponent(ctx); + expect(() => comp.mainInstance().updateMain()).toThrow(); + const copy = comp.instance() as Board; + ctx.board.appendChild(copy); + expect(() => copy.children[0].updateMain()).toThrow(); + }); + + test('deleted components reject duplication, annotation edits, and updateMain', async (ctx) => { + const comp = makeComponent(ctx); + const copy = comp.instance(); + ctx.board.appendChild(copy); + comp.remove(); + await ctx.penpot.waitForLayoutUpdate(); + expect(() => comp.duplicate()).toThrow('deleted component'); + expect(() => { + comp.annotation = 'Usage'; + }).toThrow('deleted component'); + expect(() => copy.updateMain()).toThrow('main component'); + }); + + test('updateMain rejects a copy on an inactive page and inside another copy', async (ctx) => { + const main = nestedComponentMain(ctx); + const outerCopy = main.component()?.instance() as Board; + ctx.board.appendChild(outerCopy); + expect(() => outerCopy.children[0].updateMain()).toThrow( + 'inside another component copy', + ); + + const original = ctx.penpot.currentPage; + expect(original).not.toBeNull(); + if (!original) return; + const page = ctx.penpot.createPage(); + try { + await ctx.penpot.openPage(page); + expect(() => outerCopy.updateMain()).toThrow('not currently active'); + } finally { + await ctx.penpot.openPage(original); + page.remove(); + } + }); + + test('component assets can be edited without activating their main instance page', async (ctx) => { + const original = ctx.penpot.currentPage; + expect(original).not.toBeNull(); + if (!original) return; + const comp = makeComponent(ctx); + const mainId = comp.mainInstance().id; + const page = ctx.penpot.createPage(); + try { + await ctx.penpot.openPage(page); + const selected = ctx.penpot.createRectangle(); + (page.root as Board).appendChild(selected); + ctx.penpot.selection = [selected]; + const zoom = ctx.penpot.viewport.zoom; + const center = ctx.penpot.viewport.center; + + comp.annotation = 'Edited from another page'; + const duplicate = comp.duplicate(); + await ctx.penpot.waitForLayoutUpdate(); + expect(original.getShapeById(duplicate.mainInstance().id)).not.toBeNull(); + expect(comp.annotation).toBe('Edited from another page'); + duplicate.remove(); + comp.remove(); + await ctx.penpot.waitForLayoutUpdate(); + expect(original.getShapeById(mainId)).toBeNull(); + expect(ctx.penpot.currentPage?.id).toBe(page.id); + expect(ctx.penpot.selection.map((shape) => shape.id)).toEqual([ + selected.id, + ]); + expect(ctx.penpot.viewport.zoom).toBeCloseTo(zoom); + expect(ctx.penpot.viewport.center.x).toBeCloseTo(center.x); + expect(ctx.penpot.viewport.center.y).toBeCloseTo(center.y); + } finally { + await ctx.penpot.openPage(original); + page.remove(); + } + }); +}); diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 884f5002e5..66d73cc4af 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -2668,6 +2668,12 @@ export interface Library extends PluginData { */ readonly components: LibraryComponent[]; + /** + * Deleted components, including individual deleted variants. + * Use a component's `restore()` method to restore it. + */ + readonly deletedComponents: LibraryComponent[]; + /** * A catalog of Design Tokens in the library. * @@ -2763,6 +2769,39 @@ export interface LibraryColor extends LibraryElement { * This interface extends `LibraryElement` and includes properties specific to component elements. */ export interface LibraryComponent extends LibraryElement { + /** + * The component annotation, or null when none is set. + * Set null to delete it. Text can contain at most 300 characters. + * Writing requires `library:write` permission and a live component in the + * current file's library. + */ + annotation: string | null; + + /** + * Duplicates this component and its main instance in the current file's library. + * Requires `library:write` permission. The component must not be deleted. + */ + duplicate(): LibraryComponent; + + /** + * Deletes this component and its main instance from the current file's library. + * Requires `library:write` permission. Does nothing if already deleted. + * Surviving copies keep their link so the component can be restored later. + */ + remove(): void; + + /** + * Restores this component if it has been deleted, including its main instance. + * Leaves the current page, selection, zoom, and viewport unchanged. + * Requires `library:write` permission. Does nothing if already restored. + * + * @example + * ```js + * penpot.selection[0].component()?.restore(); + * ``` + */ + restore(): void; + /** * Creates an instance of the component. * @return Returns a `Shape` object representing the instance of the component. @@ -3980,6 +4019,15 @@ export interface ShapeBase extends PluginData { */ resetOverrides(): void; + /** + * Applies this copy's overrides to its main component and synchronizes copies. + * Similar to the editor's "update main component" action. + * Requires `content:write` and `library:write` permissions. + * The shape must be a component copy head on the active page of the current file, + * outside any other component copy, with a main component that is not deleted. + */ + updateMain(): void; + /** * Switch a VariantComponent copy to the nearest one that has the specified property value * @param pos The position of the property to update From ea45507ac82d9356f3e9a475fe9140ec525e378c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 14 Oct 2026 16:35:03 +0000 Subject: [PATCH 010/167] :wrench: Raise commit message limits to 80 chars Relax the subject limit from 70 to 80 characters and the body wrap limit from 76 to 80. Merge/Revert/Reapply commits skip the subject length check since git prepends those prefixes to an existing subject. Update the checker, its tests, and all related docs and memories accordingly. AI-assisted-by: muse-spark-1.3-contributor Signed-off-by: Andrey Antukh --- .agents/skills/create-commit/SKILL.md | 7 +-- .serena/memories/critical-info.md | 2 +- .serena/memories/workflow/creating-commits.md | 18 ++++---- .serena/memories/workflow/creating-prs.md | 2 +- AGENTS.md | 3 +- CONTRIBUTING.md | 8 ++-- scripts/check-commit | 29 ++++++++----- scripts/test_check_commit.py | 43 +++++++++++++------ 8 files changed, 71 insertions(+), 41 deletions(-) diff --git a/.agents/skills/create-commit/SKILL.md b/.agents/skills/create-commit/SKILL.md index bf0667ca65..dd85191fd9 100644 --- a/.agents/skills/create-commit/SKILL.md +++ b/.agents/skills/create-commit/SKILL.md @@ -23,10 +23,11 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly. ## Iron Rules (non-negotiable) -1. **Wrap every body line at 76 characters or fewer.** Count characters, do +1. **Wrap every body line at 80 characters or fewer.** Count characters, do not eyeball. Exceptions: `Signed-off-by:` / `AI-assisted-by:` trailers and lines carrying a URL. This is the rule agents skip most often. -2. **Subject ≤70 chars**, imperative, capitalized, no trailing period. +2. **Subject ≤80 chars**, imperative, capitalized, no trailing period. + `Merge`/`Revert`/`Reapply` commits skip the length check. 3. **Blank line between subject and body.** 4. **Run `./scripts/check-commit` and require exit code 0.** It mechanically checks rules 1–3. A non-zero exit is a hard blocker: fix the message and @@ -41,7 +42,7 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly. that does not match the stated intent, **STOP** and tell the user before committing. 3. Draft the message following the format in the memory doc, wrapping the body - at 76 characters per line, and run: + at 80 characters per line, and run: ```bash git commit -m "" -m "" ``` diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 5e84c9834e..d0304e1fe8 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -12,7 +12,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. # Development workflow - Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples: - - Before `git commit` → `mem:workflow/creating-commits` (subject/body format, 76-char body wrapping enforced by `scripts/check-commit`, `AI-assisted-by: model-name` trailer) + - Before `git commit` → `mem:workflow/creating-commits` (subject/body format, 80-char body wrapping enforced by `scripts/check-commit`, `AI-assisted-by: model-name` trailer) - 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) diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index a60234819f..678681d4ba 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -11,12 +11,11 @@ automatically pull the identity from the local git config `user.name` and `user. ## Message Format ``` -:emoji: Subject line (imperative, capitalized, no period, <=70 chars) +:emoji: Subject line (imperative, capitalized, no period, <=80 chars) Body explaining what changed and why. -Wrap lines at 76 characters — git log adds a -four-space indent, so 76 + 4 fits an 80-column -terminal. Keep each line concise. +Wrap lines at 80 characters. +Keep each line concise. AI-assisted-by: model-name ``` @@ -26,16 +25,17 @@ AI-assisted-by: model-name These rules are not advisory. Do not commit until every one holds. A commit that breaks them is wrong, even if the code is right. -- **Body lines MUST wrap at 76 characters or fewer.** Measure every line; do - not eyeball it. This is the rule most often skipped. Rationale: `git log` - indents the body four spaces, so 76 + 4 fits an 80-column terminal. -- **Subject MUST be ≤70 chars**, imperative, capitalized, no trailing period. +- **Body lines MUST wrap at 80 characters or fewer.** Measure every line; do + not eyeball it. This is the rule most often skipped. +- **Subject MUST be ≤80 chars**, imperative, capitalized, no trailing period. + Merge/Revert/Reapply commits skip the length check: git prepends those + prefixes to an existing subject. - **MUST be a blank line** between subject and body. - **MUST run `scripts/check-commit` and get exit code 0 before finishing.** It mechanically validates the rules above; a failing run is a blocker. - It checks `HEAD` by default: `./scripts/check-commit` - For another commit: `./scripts/check-commit -c ` -- **NEVER** hand-wave the body as "one long line". If a line exceeds 76, +- **NEVER** hand-wave the body as "one long line". If a line exceeds 80, break it at a space. - **NEVER add a `Co-authored-by:` trailer.** It is forbidden; `scripts/check-commit` fails the commit when any line matches it (case-insensitive). - CI runs `scripts/check-commit` on every new commit (`mem:workflow/creating-commits` is enforced by `.github/workflows/commit-checker.yml`), so a forbidden trailer fails the build. diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index f9b41275ef..4d9d0fe8d4 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -27,7 +27,7 @@ Always add the PR to the Main project (`--project "Main"`) unless the user expli PR titles follow commit title conventions: ``` -:emoji: Subject line (imperative, capitalized, no period, <=70 chars) +:emoji: Subject line (imperative, capitalized, no period, <=80 chars) ``` See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR title as the final commit subject, so title format matters. diff --git a/AGENTS.md b/AGENTS.md index fa95b4c045..e900e32518 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,8 @@ - **Read `AGENTS.local.md` after this file whenever it exists.** It carries the developer's steering for this project and overrides this file on prose style and workflow, but not on project policy, where instructions here prevail. -- **Commit message body lines MUST wrap at ≤76 chars** (subject ≤70 chars) and +- **Commit message body lines MUST wrap at ≤80 chars** (subject ≤80 chars, + except `Merge`/`Revert`/`Reapply` which skip the subject length check) and the commit MUST pass `./scripts/check-commit` with exit code 0 before you consider it done. This is mechanically checked — do not eyeball it. - **Read the workflow memory BEFORE the corresponding action**: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d665b0715..9b23ae0fa9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,7 +124,7 @@ commit history — what matters is the [PR title](#title). Pull request titles **must** follow the same convention as commit subjects: ``` -:emoji: Subject line (imperative, capitalized, no period, <=70 chars) +:emoji: Subject line (imperative, capitalized, no period, <=80 chars) ``` Read [Creating Commits](./.serena/memories/workflow/creating-commits.md) @@ -220,8 +220,10 @@ Commit messages must follow this format: - Capitalize the first letter of the subject - Add clear and concise description on the body - Do not end the subject with a period -- Keep the subject to **70 characters** or fewer -- **Wrap body lines at 76 characters or fewer** (trailers and URLs excepted) +- Keep the subject to **80 characters** or fewer + (`Merge`/`Revert`/`Reapply` commits skip this limit, since git prepends + those prefixes to an existing subject) +- **Wrap body lines at 80 characters or fewer** (trailers and URLs excepted) - Separate the subject from the body with a **blank line** - Never add a `Co-authored-by` trailer — it is forbidden and fails `./scripts/check-commit` diff --git a/scripts/check-commit b/scripts/check-commit index d0e9f59899..df4672ea59 100755 --- a/scripts/check-commit +++ b/scripts/check-commit @@ -5,7 +5,7 @@ Check commit messages against Penpot's commit guidelines. Validates commit messages using the rules defined in: - .github/workflows/commit-checker.yml (regex pattern) - CONTRIBUTING.md (formatting rules, subject length, DCO) - - .serena/memories/workflow/creating-commits.md (body wrapped at 76 chars) + - .serena/memories/workflow/creating-commits.md (body wrapped at 80 chars) By default, checks HEAD. Use --commit to specify a different commit. @@ -39,12 +39,17 @@ COMMIT_PATTERN = re.compile( MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$") +# ── Subject line length ────────────────────────────────────────────────────── +# Subject lines must fit in 80 characters. Merge/Revert/Reapply commits are +# exempt: git prepends "Merge ..."/"Revert ..." to an existing subject, so +# enforcing the limit would fail on text the author did not write. +MAX_SUBJECT_LINE = 80 + # ── Body line wrapping ─────────────────────────────────────────────────────── -# Commit bodies must wrap at 76 characters (see -# .serena/memories/workflow/creating-commits.md). That leaves room for the -# four-space indent git log adds, fitting an 80-column terminal. Trailers and +# Commit bodies must wrap at 80 characters (see +# .serena/memories/workflow/creating-commits.md). Trailers and # URLs are exempt: they cannot be wrapped without losing meaning. -MAX_BODY_LINE = 76 +MAX_BODY_LINE = 80 TRAILER_PATTERN = re.compile( r"^(Signed-off-by|Co-developed-by|Reviewed-by|" @@ -110,11 +115,13 @@ def check_regex(message): def check_subject_length(message): - """Subject line must be ≤ 70 characters.""" + """Subject line must be ≤ 80 characters (Merge/Revert/Reapply exempt).""" first_line = message.split("\n")[0] - if len(first_line) > 70: + if MERGE_PATTERN.match(first_line): + return True, None + if len(first_line) > MAX_SUBJECT_LINE: return False, ( - f"Subject line exceeds 70 characters ({len(first_line)} chars):\n" + f"Subject line exceeds {MAX_SUBJECT_LINE} characters ({len(first_line)} chars):\n" f" {first_line}" ) return True, None @@ -166,7 +173,7 @@ def check_body_blank_line(message): def check_body_line_length(message): - """Body lines must wrap at 76 characters or fewer. + """Body lines must wrap at 80 characters or fewer. The subject (first line) has its own length rule. Trailers (e.g. Signed-off-by) and lines carrying a URL are exempt, since wrapping them @@ -250,11 +257,11 @@ def main(): validators = [ ("Regex pattern", check_regex), - ("Subject ≤ 70 chars", check_subject_length), + ("Subject ≤ 80 chars", check_subject_length), ("No trailing period in subject", check_subject_no_trailing_dot), ("Subject capitalized", check_subject_capitalized), ("Blank line after subject", check_body_blank_line), - ("Body wrapped at 76 chars", check_body_line_length), + ("Body wrapped at 80 chars", check_body_line_length), ("No Co-authored-by trailer", check_no_co_authored_by), ] diff --git a/scripts/test_check_commit.py b/scripts/test_check_commit.py index a3a7a1c2b9..037bc8fd56 100644 --- a/scripts/test_check_commit.py +++ b/scripts/test_check_commit.py @@ -55,20 +55,20 @@ class BodyLineLengthTests(unittest.TestCase): self.assert_ok(message) def test_line_at_limit_passes(self): - line = "x " * 38 # 76 chars, breakable - self.assertEqual(len(line), 76) + line = "x " * 40 # 80 chars, breakable + self.assertEqual(len(line), 80) self.assert_ok(":bug: Fix crash\n\n" + line + "\n") def test_line_one_over_limit_fails(self): - line = "x " * 38 + "x" # 77 chars, breakable - self.assertEqual(len(line), 77) + line = "x " * 40 + "x" # 81 chars, breakable + self.assertEqual(len(line), 81) error = self.assert_fail(":bug: Fix crash\n\n" + line + "\n") - self.assertIn("76", error) + self.assertIn("80", error) def test_long_body_line_fails(self): long_line = "word " * 20 # 100 chars, breakable error = self.assert_fail(":bug: Fix crash\n\n" + long_line + "\n") - self.assertIn("76", error) + self.assertIn("80", error) self.assertIn("line 3", error) def test_subject_is_not_checked(self): @@ -150,15 +150,34 @@ class SubjectRulesRegressionTests(unittest.TestCase): ok, _ = check_commit.check_subject_no_trailing_dot(":bug: Fix crash.") self.assertFalse(ok) - def test_subject_at_70_chars_passes(self): - # ":bug: " is 6 chars, so 64 chars of text reach exactly 70. - ok, error = check_commit.check_subject_length(":bug: " + "S" * 64) + def test_subject_at_80_chars_passes(self): + # ":bug: " is 6 chars, so 74 chars of text reach exactly 80. + ok, error = check_commit.check_subject_length(":bug: " + "S" * 74) self.assertTrue(ok, error) - def test_subject_over_70_chars_fails(self): - ok, error = check_commit.check_subject_length(":bug: " + "S" * 65) + def test_subject_over_80_chars_fails(self): + ok, error = check_commit.check_subject_length(":bug: " + "S" * 75) self.assertFalse(ok) - self.assertIn("70", error) + self.assertIn("80", error) + + def test_merge_subject_skips_length_check(self): + # Git generates Merge subjects from branch names; the limit is skipped. + subject = "Merge branch 'some-very-long-branch-name-that-keeps-going' into develop with extra text here" + self.assertGreater(len(subject), 80) + ok, error = check_commit.check_subject_length(subject) + self.assertTrue(ok, error) + + def test_revert_subject_skips_length_check(self): + subject = 'Revert ":bug: Fix some earlier commit with a fairly long subject line already here" extra' + self.assertGreater(len(subject), 80) + ok, error = check_commit.check_subject_length(subject) + self.assertTrue(ok, error) + + def test_reapply_subject_skips_length_check(self): + subject = 'Reapply ":bug: Fix some earlier commit with a fairly long subject line already here" extra' + self.assertGreater(len(subject), 80) + ok, error = check_commit.check_subject_length(subject) + self.assertTrue(ok, error) if __name__ == "__main__": From 9ffd2189159d6fa0091fbd61b4f36c5f3ee71847 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 09:36:52 +0200 Subject: [PATCH 011/167] :bug: Stop passing tooltip-class to layout item numeric inputs (#12329) The layout item min and max size inputs passed :tooltip-class to numeric-input*, which never reads that prop. It stayed in the rest props and reached the DOM , so React warned about an unknown tooltipClass prop. The class never styled anything. Remove the prop from the four callers and drop the unused .tooltip-wrapper rule. Closes #12311 AI-assisted-by: claude-opus-5-5 --- .../main/ui/workspace/sidebar/options/menus/layout_item.cljs | 4 ---- .../main/ui/workspace/sidebar/options/menus/layout_item.scss | 4 ---- 2 files changed, 8 deletions(-) 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 10b9c42db7..659aea2fb3 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 @@ -602,7 +602,6 @@ :input-type :min-width :nillable true :applied-token applied-token-to-min-w - :tooltip-class (stl/css :tooltip-wrapper) :value min-w}] [:div {:class (stl/css :layout-item-min-w) @@ -632,7 +631,6 @@ :attr :layout-item-max-w :property (tr "workspace.options.layout-item.layout-item-max-w") :nillable true - :tooltip-class (stl/css :tooltip-wrapper) :applied-token applied-token-to-max-w :value max-w}] @@ -663,7 +661,6 @@ :attr :layout-item-min-h :property (tr "workspace.options.layout-item.layout-item-min-h") :nillable true - :tooltip-class (stl/css :tooltip-wrapper) :applied-token applied-token-to-min-h :value min-h}] @@ -694,7 +691,6 @@ :attr :layout-item-max-h :property (tr "workspace.options.layout-item.layout-item-max-h") :nillable true - :tooltip-class (stl/css :tooltip-wrapper) :applied-token applied-token-to-max-h :value max-h}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss index b362df51d0..52b6a165e3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss @@ -192,7 +192,3 @@ .inputs-wrapper { grid-column: 1 / span 2; } - -.tooltip-wrapper { - inline-size: 100%; -} From 2cdb5757122bc14eefb047e8eac01e336d2f29cb Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 09:37:31 +0200 Subject: [PATCH 012/167] :bug: Fix font selector outside click, empty search and tick (#12285) * :bug: Close the font selector when clicking outside it The font selector had no click-outside handling. It only closed with Esc, or when its menu was unmounted, which is why a viewport click (that deselects the text) closed it and a click on the left sidebar, the rulers or the header did not. Listen to click and contextmenu on the document, as the dropdowns do, and close when the target is outside the selector. The listener starts on the next tick so the opening click does not close it, and a target no longer in the document (a font row removed after selecting it) is not taken as an outside click. The token font combobox uses the same component, so it gets the fix too. Closes #12246 AI-assisted-by: claude-opus-5-5 * :bug: Hide the empty Recent section in the font selector The "Recent" heading showed with `(and recent-fonts show-recent)`, but the filtered recent fonts are a vector and an empty one is truthy. So a search with no matches showed the heading over a blank list, and any search showed it over results that are not recent. - Show the "Recent" section only when it has fonts. - Show "No matching fonts" when the search finds nothing. - Square the list's top corners only when it sits under "Recent". Closes #12249 AI-assisted-by: claude-opus-5-5 * :bug: Separate the applied font from the focused one in font selector The tick marked the first search result when the applied font was filtered out, since one value drove both the tick and the font Enter applies. So the tick showed a font that was not applied. The tick and the focus are now two states: - The tick marks the font the text had when the selector opened. - A green border, as in the select dropdowns, marks the font Enter applies. Typing moves it to the first result without previewing it. Arrow keys move it and, once they stop for a moment, preview the font. Previewing every font of a held key flooded the store until React stopped with "Maximum update depth exceeded". - Recent fonts get no border: they are reached with the mouse or by filtering, not with the arrow keys. - Enter applies the focused font. Esc and an outside click undo the preview. Also make the rows fill the list width, so the border is not clipped, and scroll to the focused row only through the virtualized list. Closes #12247 AI-assisted-by: claude-opus-5-5 * :recycle: Refactor typography.scss to the Design System Move typography.scss to the DS tokens and mixins. No visual change intended, except that the rows that took their typography from %asset-element now use the DS body-small (line-height 1.3 instead of 1.4). - Inline the %asset-element and %input-base placeholders, resolving their design-token aliases to DS tokens and using local CSS variables for state colors. - Keep the spacing inputs on %input-element and %button-icon-small, with a TODO: those placeholders style the icon svg and the input elements, and go away when the controls move to the DS numeric-input*, out of scope here. - Use logical properties and DS spacing and size tokens. - Merge the duplicated .typography-sample rule. - Note why the "Ag" samples have no DS typography, and leave TODOs where no DS typography matches exactly. AI-assisted-by: claude-opus-5-5 --- .../sidebar/options/menus/typography.cljs | 156 +++++++++++------- .../sidebar/options/menus/typography.scss | 147 +++++++++++++---- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 4 files changed, 222 insertions(+), 89 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index f4974abc7b..2a6b472977 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -291,21 +291,14 @@ (mf/defc font-item* {::mf/wrap [mf/memo]} - [{:keys [font is-current on-click style]}] - (let [item-ref (mf/use-ref) - on-click (mf/use-fn (mf/deps font) #(on-click font))] - - (mf/with-effect [is-current] - (when is-current - (let [element (mf/ref-val item-ref)] - (when-not (dom/is-in-viewport? element) - (dom/scroll-into-view! element))))) - + [{:keys [font is-current is-focused on-click style]}] + (let [on-click (mf/use-fn (mf/deps font) #(on-click font))] [:div {:class (stl/css :font-wrapper) :style style - :ref item-ref :on-click on-click} - [:div {:class (stl/css-case :font-item true :selected is-current)} + [:div {:class (stl/css-case :font-item true + :selected is-current + :focused is-focused)} [:> font-item-preview* {:font font}] (when is-current [:> icon* {:icon-id i/tick @@ -326,11 +319,20 @@ (mf/defc font-selector* [{:keys [on-select on-close current-font show-recent full-size]}] - (let [selected (mf/use-state current-font) + (let [;; The tick marks the font the text had when the selector opened. + ;; The focus (green border) marks the font Enter applies: arrows move + ;; it and preview the font, Esc and an outside click undo the preview + initial-font (mf/with-memo [] current-font) + focused* (mf/use-state current-font) + focused (deref focused*) + previewed? (mf/use-ref false) + preview? (mf/use-ref false) + state* (mf/use-state #(do {:term "" :backends #{}})) state (deref state*) + root (mf/use-ref) flist (mf/use-ref) input (mf/use-ref) @@ -353,52 +355,57 @@ (filter #(contains? installed-ids (:id %))) (filter-fonts state))) - ;; When the active font is not in the filtered results, pre-select - ;; the first match visually so the user can confirm it with Enter — - ;; without live-applying it on every keystroke. - effective-selected - (if (and (seq fonts) (not (d/seek #(= (:id %) (:id @selected)) fonts))) - (first fonts) - @selected) - full-size? (boolean (and full-size show-recent)) - select-next - (mf/use-fn - (mf/deps fonts) - (fn [event] - (dom/stop-propagation event) - (dom/prevent-default event) - (swap! selected get-next-font fonts))) + ;; `recent-fonts` is a vector, so check it with `seq`: an empty one + ;; is truthy and would show the heading over nothing + show-recent? (boolean (and show-recent (seq recent-fonts))) - select-prev + ;; Arrows only move the focus, from its latest value: a held key + ;; fires again before the listener sees the new state. The preview + ;; follows in an effect + focus-and-preview (mf/use-fn (mf/deps fonts) - (fn [event] + (fn [event get-font] (dom/stop-propagation event) (dom/prevent-default event) - (swap! selected get-prev-font fonts))) + (when (seq fonts) + (mf/set-ref-val! preview? true) + (swap! focused* get-font fonts)))) + + on-cancel + (mf/use-fn + (mf/deps initial-font on-select on-close) + (fn [] + (when (mf/ref-val previewed?) + (on-select initial-font)) + (on-close))) on-key-down (mf/use-fn - (mf/deps fonts on-select on-close) + (mf/deps focused focus-and-preview on-cancel on-select on-close) (fn [event] (cond - (kbd/up-arrow? event) (select-prev event) - (kbd/down-arrow? event) (select-next event) - (kbd/esc? event) (on-close) - (kbd/enter? event) - (let [first-result (when-not (d/seek #(= (:id %) (:id @selected)) fonts) - (first fonts))] - (if first-result - (do (on-select first-result) (on-close)) - (on-close))) + (kbd/up-arrow? event) (focus-and-preview event get-prev-font) + (kbd/down-arrow? event) (focus-and-preview event get-next-font) + (kbd/esc? event) (on-cancel) + (kbd/enter? event) (do (when (some? focused) + (on-select focused)) + (on-close)) :else (dom/focus! (mf/ref-val input))))) on-filter-change (mf/use-fn - (fn [event] - (swap! state* assoc :term event))) + (mf/deps state all-fonts) + (fn [term] + ;; Move the focus to the first result, without previewing it. + ;; `swap!` on a `use-state` returns nil, so build the new state here + (let [state (assoc state :term term)] + (reset! state* state) + (mf/set-ref-val! preview? false) + (when (seq term) + (reset! focused* (first (filter-fonts state all-fonts))))))) on-select-and-close (mf/use-fn @@ -407,10 +414,29 @@ (on-select font) (on-close)))] - (mf/with-effect [fonts on-key-down] + (mf/with-effect [on-key-down] (let [key (events/listen js/document "keydown" on-key-down)] #(events/unlistenByKey key))) + ;; Close on an outside click. Starts on the next tick to skip the + ;; opening click; detached targets (a just-selected row) are ignored + (mf/with-effect [on-cancel] + (let [listening? (volatile! false) + on-click (fn [event] + (let [target (dom/get-target event) + node (mf/ref-val root)] + (when (and @listening? + (some? node) + (.-isConnected ^js target) + (not (dom/child? target node))) + (on-cancel)))) + task (tm/schedule #(vreset! listening? true)) + keys [(events/listen js/document "click" on-click) + (events/listen js/document "contextmenu" on-click)]] + (fn [] + (tm/dispose! task) + (run! events/unlistenByKey keys)))) + ;; Materialize the preview sprite into the DOM only while the picker is open ;; (markup is prefetched on workspace load), removing it on close so its ;; ~2000 nodes aren't kept around idle. The attachment is deferred so the @@ -427,16 +453,26 @@ (when-some [n @node*] (fonts/detach-preview-sprite! n)))))) - (mf/with-effect [@selected] + ;; Preview the focused font once the arrows stop for a moment. Applying + ;; every font of a held key floods the store and the text editor with + ;; updates, until React stops with "Maximum update depth exceeded" + (mf/with-effect [focused] + (when (mf/ref-val preview?) + (mf/set-ref-val! preview? false) + (let [task (tm/schedule 100 (fn [] + (mf/set-ref-val! previewed? true) + (on-select focused)))] + #(tm/dispose! task)))) + + ;; The only scroll to the focused row: the list is virtualized, so the row + ;; may not be rendered yet + (mf/with-effect [focused fonts] (let [node (mf/ref-val flist) - index (:index @selected)] + index (d/index-of-pred fonts #(= (:id %) (:id focused)))] ;; This is nil safe operation, do nothing if node or index are ;; invalid. (dom/scroll-to-row node index))) - (mf/with-effect [@selected] - (on-select @selected)) - (mf/with-effect [] (st/emit! (dsc/push-shortcuts :typography {} :workspace)) (fn [] @@ -451,7 +487,8 @@ (dom/scroll-to-position node offset))))) [:div {:class [(stl/css-case :font-selector true - :fonts-on-modal (not full-size?))]} + :fonts-on-modal (not full-size?))] + :ref root} [:div {:class (stl/css-case :font-selector-dropdown true :font-selector-dropdown-full-size full-size?)} [:div {:class (stl/css :header)} @@ -459,7 +496,7 @@ :value (:term state) :auto-focus true :placeholder (tr "workspace.options.search-font")}] - (when (and recent-fonts show-recent) + (when show-recent? [:section {:class (stl/css :show-recent)} [:p {:class (stl/css :header-title)} (tr "workspace.options.recent-fonts")] (for [[idx font] (d/enumerate recent-fonts)] @@ -467,15 +504,21 @@ :font font :style {} :on-click on-select-and-close - :is-current (= (:id font) (:id effective-selected))}])])] + ;; Recent fonts are reached with the mouse or by + ;; filtering, never with the arrow keys, so no focus + :is-current (= (:id font) (:id initial-font))}])])] [:div {:class (stl/css-case :fonts-list true - :fonts-list-full-size full-size?)} + :fonts-list-full-size full-size? + :fonts-list-below-recent show-recent?)} + (when (empty? fonts) + [:p {:class (stl/css :fonts-list-empty)} + (tr "workspace.options.no-font-results")]) [:> rvt/AutoSizer {} (fn [props] (let [width (unchecked-get props "width") height (unchecked-get props "height") - render #(row-renderer fonts effective-selected on-select-and-close %)] + render #(row-renderer fonts (:id initial-font) (:id focused) on-select-and-close %)] (mf/html [:> rvt/List #js {:height height :ref flist @@ -485,7 +528,7 @@ :rowRenderer render}])))]]]])) (defn row-renderer - [fonts selected on-select props] + [fonts current-id focused-id on-select props] (let [index (unchecked-get props "index") key (unchecked-get props "key") style (unchecked-get props "style") @@ -495,7 +538,8 @@ :font font :style style :on-click on-select - :is-current (= (:id font) (:id selected))}]))) + :is-current (= (:id font) current-id) + :is-focused (= (:id font) focused-id)}]))) (mf/defc font-options* [{:keys [values on-change on-blur show-recent full-size-selector]}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index bbfa19734e..ddc47c5b97 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -13,6 +13,8 @@ // Must match BOX_HEIGHT in scripts/build-fonts-preview.js (the sprite bakes each // font name into that box at 1 unit == 1px). +// NOTE: kept in px on purpose, not px2rem(): the sprite is baked in pixels, so +// the box must not scale with the root font size. $font-preview-box-height: 28px; .typography-entry { @@ -72,15 +74,6 @@ $font-preview-box-height: 28px; } } -.typography-sample { - display: flex; - justify-content: center; - align-items: center; - min-inline-size: $sz-24; - block-size: $sz-32; - color: var(--color-foreground-secondary); -} - .typography-name { color: var(--color-foreground-primary); } @@ -113,18 +106,23 @@ $font-preview-box-height: 28px; } .typography-sample-input { + // No DS typography on purpose: the "Ag" sample is drawn in the + // typography's own font + font-size: px2rem(16); + line-height: 1; display: flex; justify-content: center; align-items: center; inline-size: $sz-24; block-size: 100%; - font-size: px2rem(16); - line-height: 1; overflow: hidden; color: var(--color-foreground-primary); } .adv-typography-name { + // TODO: No exact DS typography match found. Rename input of a + // typography; only the font size is set. + // Closest match: body-small (12px). font-size: px2rem(12); color: var(--font-name-wrapper-foreground-color); flex-grow: 1; @@ -155,25 +153,30 @@ $font-preview-box-height: 28px; } .typography-name-wrapper { - @extend %asset-element; + @include t.use-typography("body-small"); display: grid; grid-template-columns: $sz-24 auto 1fr $sz-28; flex: 1; + align-items: center; block-size: $sz-32; inline-size: 100%; padding: 0 0 0 var(--sp-m); + border-radius: $br-8; background-color: var(--color-background-quaternary); + color: var(--color-foreground-primary); margin-block-end: var(--sp-xs); } .typography-sample { + // No DS typography on purpose: the "Ag" sample is drawn in the + // typography's own font + font-size: px2rem(16); + line-height: 1; display: flex; justify-content: center; align-items: center; min-inline-size: $sz-24; - font-size: px2rem(16); - line-height: 1; block-size: $sz-32; padding: 0; overflow: hidden; @@ -186,8 +189,8 @@ $font-preview-box-height: 28px; margin: 0; min-inline-size: px2rem(6); - padding: 8px 0 8px 6px; - max-width: 100%; + padding: var(--sp-s) 0 var(--sp-s) px2rem(6); + max-inline-size: 100%; color: var(--color-foreground-primary); } @@ -210,7 +213,7 @@ $font-preview-box-height: 28px; display: grid; grid-template-columns: 50% 50%; block-size: $sz-32; - padding-left: var(--sp-xs); + padding-inline-start: var(--sp-xs); } .info-label, @@ -235,11 +238,22 @@ $font-preview-box-height: 28px; } .font-option { - @include t.use-typography("body-small"); - @extend %asset-element; + --font-option-background-color: var(--color-background-tertiary); + @include t.use-typography("body-small"); + + display: flex; + align-items: center; + block-size: $sz-32; padding: var(--sp-s); + border-radius: $br-8; + background-color: var(--font-option-background-color); + color: var(--color-foreground-primary); cursor: pointer; + + &:hover { + --font-option-background-color: var(--color-background-quaternary); + } } .font-option-name { @@ -255,15 +269,26 @@ $font-preview-box-height: 28px; } .font-size-options { - @extend %asset-element; + --font-size-options-background-color: var(--color-background-tertiary); + @include t.use-typography("body-small"); + display: flex; flex-grow: 1; + align-items: center; + block-size: $sz-32; inline-size: px2rem(60); margin: 0; padding: 0; border: $b-1 solid var(--color-background-tertiary); + border-radius: $br-8; + background-color: var(--font-size-options-background-color); + color: var(--color-foreground-primary); position: relative; + + &:hover { + --font-size-options-background-color: var(--color-background-quaternary); + } } .font-variant-options { @@ -283,6 +308,9 @@ $font-preview-box-height: 28px; gap: var(--sp-xs); } +// TODO: Kept on the deprecated %input-element and %button-icon-small +// placeholders, which style the icon svg and the input elements. They go +// away when these controls move to the DS numeric-input*. .line-height, .letter-spacing { @extend %input-element; @@ -301,11 +329,22 @@ $font-preview-box-height: 28px; } .text-transform { - @extend %asset-element; + --text-transform-background-color: var(--color-background-tertiary); + @include t.use-typography("body-small"); + + display: flex; + align-items: center; + block-size: $sz-32; inline-size: fit-content; padding: 0; - background-color: var(--color-background-tertiary); + border-radius: $br-8; + background-color: var(--text-transform-background-color); + color: var(--color-foreground-primary); + + &:hover { + --text-transform-background-color: var(--color-background-quaternary); + } } .font-size-select { @@ -320,10 +359,24 @@ $font-preview-box-height: 28px; outline: none; .numeric-input { - @extend %input-base; @include t.use-typography("body-small"); + @include text-ellipsis; + block-size: $sz-28; + inline-size: 100%; + flex-grow: 1; + margin: var(--sp-xxs) 0; padding: 0; + border: none; + border-radius: $br-8; + background: none; + outline: none; + color: var(--color-foreground-primary); + + &[disabled] { + opacity: 0.5; + pointer-events: none; + } } } @@ -332,16 +385,15 @@ $font-preview-box-height: 28px; justify-content: center; align-items: center; position: absolute; - top: calc(-1 * #{px2rem(18)}); - left: 0; - right: 0; + inset-block-start: calc(-1 * #{px2rem(18)}); + inset-inline: 0; block-size: 100%; inline-size: 100%; z-index: var(--z-index-dropdown); } .fonts-on-modal { - top: 0; + inset-block-start: 0; } .show-recent { @@ -377,22 +429,39 @@ $font-preview-box-height: 28px; } .font-wrapper { - padding-bottom: var(--sp-xs); + padding-block-end: var(--sp-xs); cursor: pointer; } .font-item { - @extend %asset-element; + --font-item-background-color: var(--color-background-tertiary); + + @include t.use-typography("body-small"); - width: px2rem(280); - margin-bottom: var(--sp-xs); - border-radius: $br-8; display: flex; + block-size: $sz-32; + inline-size: 100%; + margin-block-end: var(--sp-xs); + padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; + background-color: var(--font-item-background-color); + color: var(--color-foreground-primary); + + &:hover { + --font-item-background-color: var(--color-background-quaternary); + } &.selected { color: var(--color-foreground-primary); } + // The font Enter applies, same border as the focused option of the + // select dropdowns + &.focused { + outline: $b-1 solid var(--color-accent-primary); + outline-offset: calc(-1 * #{$b-1}); + } + // Center & clip so a previewed font's own metrics never grow/overflow the row. align-items: center; overflow: hidden; @@ -448,9 +517,21 @@ $font-preview-box-height: 28px; } .fonts-list-full-size { + border: $b-1 solid var(--color-background-quaternary); +} + +// Joins the list to the "Recent" section above it +.fonts-list-below-recent { border-start-start-radius: 0; border-start-end-radius: 0; - border: $b-1 solid var(--color-background-quaternary); +} + +.fonts-list-empty { + @include t.use-typography("body-small"); + + color: var(--color-foreground-secondary); + margin: 0; + padding: var(--sp-m); } .dropdown-icon { diff --git a/frontend/translations/en.po b/frontend/translations/en.po index eb04aa1bf8..b9a3544478 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7711,6 +7711,10 @@ msgstr "More library colors" msgid "workspace.options.more-token-colors" msgstr "More color tokens" +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:502 +msgid "workspace.options.no-font-results" +msgstr "No matching fonts" + #: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:117, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:518 msgid "workspace.options.no-size-preset-results" msgstr "No matching size preset" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 59bc353113..9adb3cde53 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -7777,6 +7777,10 @@ msgstr "Más colores de la biblioteca" msgid "workspace.options.more-token-colors" msgstr "Más tokens de color" +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:502 +msgid "workspace.options.no-font-results" +msgstr "No hay fuentes coincidentes" + #: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:117, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:518 msgid "workspace.options.no-size-preset-results" msgstr "No hay tamaños predefinidos coincidentes" From e3b8b006dbc00c60a8944d80971e643c52ad2ebc Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Thu, 15 Oct 2026 10:05:44 +0200 Subject: [PATCH 013/167] :bug: Fix font family space in tokens (#11750) * :bug: Fix font family space in tokens * :bug: Resolve token merge conflicts Keep the font-family token fix and the newer background-plugin change together in the unreleased changelog and plugin API test suite. AI-assisted-by: gpt-6-luna --------- Co-authored-by: niwinz <843689+niwinz@users.noreply.github.com> --- frontend/src/app/main/data/tokenscript.cljs | 71 +++++++++++++++++-- .../data/workspace/tokens/application.cljs | 2 +- .../data/workspace/tokens/propagation.cljs | 3 +- .../forms/controls/fonts_combobox.cljs | 2 +- .../tokens/management/forms/validators.cljs | 3 +- frontend/src/app/plugins/tokens.cljs | 9 +-- .../frontend_tests/plugins/tokens_test.cljs | 65 +++++++++++++++++ plugins/CHANGELOG.md | 1 + .../src/tests/tokens.test.ts | 58 +++++++++++++++ 9 files changed, 199 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/main/data/tokenscript.cljs b/frontend/src/app/main/data/tokenscript.cljs index 22e07efc66..7f0cbee92e 100644 --- a/frontend/src/app/main/data/tokenscript.cljs +++ b/frontend/src/app/main/data/tokenscript.cljs @@ -8,9 +8,12 @@ processTokens TokenSymbol makeConfig]] + [app.common.data :as d] [app.common.logging :as l] [app.common.time :as ct] - [app.main.data.workspace.tokens.errors :as wte])) + [app.common.types.token :as cto] + [app.main.data.workspace.tokens.errors :as wte] + [cuerdas.core :as str])) (l/set-level! :debug) @@ -78,6 +81,17 @@ (declare tokenscript-symbols->penpot-unit) +(defn font-family-symbols->penpot-unit + "Converts a resolved font-family value into a vector of family names. + Each family name is the string form of its whole entry, which is a list of + word symbols when the family reached tokenscript unquoted." + [^js v] + (when (some? v) + (let [entries (.-value v)] + (if (instance? js/Array entries) + (mapv str entries) + [(str v)])))) + (defn structured-token->penpot-map "Converts structured token (record or array) to penpot map format. Structured tokens are non-primitive token types like `typography` or `box-shadow`." @@ -85,9 +99,13 @@ (if (instance? js/Array (.-value token-symbol)) (mapv tokenscript-symbols->penpot-unit (.-value token-symbol)) (let [entries (es6-iterator-seq (.entries (.-value token-symbol)))] - (into {} (map (fn [[k v :as V]] - [(keyword k) (tokenscript-symbols->penpot-unit v)]) - entries))))) + (into {} (map (fn [[k v]] + (let [k (keyword k)] + ;; The font-family member of a composite is a family list + [k (if (= :font-family k) + (font-family-symbols->penpot-unit v) + (tokenscript-symbols->penpot-unit v))]))) + entries)))) (defn tokenscript-symbols->penpot-unit [^js v] (cond @@ -99,6 +117,15 @@ (percent-number-with-unit? v) (/ (.-value v) 100) :else (.-value v))) +(defn resolved-value->penpot-unit + "Converts the resolved value of a token of the given `type`. + Font families need the type: a list of symbols is a list of families at the + top level and the words of a single family name inside an entry." + [type ^js v] + (if (= :font-family type) + (font-family-symbols->penpot-unit v) + (tokenscript-symbols->penpot-unit v))) + ;; Processors ------------------------------------------------------------------ ;; The processor resolves tokens ;; resolved/error tokens get put back into a clojure structure directly during build time @@ -130,11 +157,45 @@ :onError on-error :getResult get-result})) +(defn- quote-font-family + "Quotes a font family name so tokenscript reads it as one string instead of + parsing its words (`Red` would become a color, `2P` a number and a word). + Entries with a token reference or already quoted are kept as they are." + [family] + (cond + (or (not (string? family)) + (seq (cto/find-token-value-references family)) + (re-matches #"^(['\"]).*\1$" family)) + family + + (not (str/includes? family "\"")) + (str "\"" family "\"") + + (not (str/includes? family "'")) + (str "'" family "'") + + :else + family)) + +(defn- quote-font-families + [value] + (if (sequential? value) + (mapv quote-font-family value) + value)) + +(defn- token-value->tokenscript-value + [type value] + (case type + :font-family (quote-font-families value) + :typography (cond-> value + (map? value) (d/update-when :font-family quote-font-families)) + value)) + (defn clj->token->tokenscript-token "Convert penpot token into a format that tokenscript can handle." [{:keys [type value]}] #js {"$type" (name type) - "$value" (clj->js value)}) + "$value" (clj->js (token-value->tokenscript-value type value))}) (defn clj-tokens->tokenscript-tokens "Convert penpot map of tokens into tokenscript map structure. diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 13bfd42689..6b667e3631 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -736,7 +736,7 @@ resolved-value (get-in resolved-tokens [(:name token) :resolved-value]) resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) + (ts/resolved-value->penpot-unit (:type token) resolved-value) resolved-value) tokenized-attributes (cfo/attributes-map attributes token) type (:type token)] diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index 04472fd046..aa43061336 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -218,7 +218,8 @@ (when-let [tokens-tree (cfo/get-tokens-in-active-sets tokens-status tokens-lib)] (->> (if (contains? cf/flags :tokenscript) (rx/of (-> (ts/resolve-tokens tokens-tree) - (d/update-vals #(update % :resolved-value ts/tokenscript-symbols->penpot-unit)))) + (d/update-vals #(update % :resolved-value + (partial ts/resolved-value->penpot-unit (:type %)))))) (sd/resolve-tokens tokens-tree)) (rx/mapcat (fn [sd-tokens] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs index 88e8346580..48d8586565 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs @@ -78,7 +78,7 @@ (fn [resolved-tokens] (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) + (ts/resolved-value->penpot-unit (:type token) resolved-value) resolved-value)] (if resolved-value (rx/of {:value resolved-value}) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs index 4fb9be3e4f..428d2fee31 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs @@ -46,7 +46,8 @@ (fn [resolved-tokens] (let [resolved-token (cond-> (get resolved-tokens (:name token)) (contains? cf/flags :tokenscript) - (update :resolved-value ts/tokenscript-symbols->penpot-unit))] + (update :resolved-value + (partial ts/resolved-value->penpot-unit (:type token))))] (cond (:resolved-value resolved-token) (rx/of resolved-token) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index ae0f6bf171..e811c0db2e 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -252,13 +252,10 @@ (defn- font-families-resolved-value->js "Converts a resolved fontFamilies value (a tokenscript list symbol) into the - documented `string[]` shape rather than leaking the raw tokenscript structure." + documented `string[]` shape." [resolved-value] - (let [v (ts/tokenscript-symbols->penpot-unit resolved-value)] - (cond - (nil? v) nil - (sequential? v) (clj->js v) - :else #js [v]))) + (some-> (ts/font-family-symbols->penpot-unit resolved-value) + (clj->js))) (defn- get-resolved-value [token tokens-tree] diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index 56a3a2529d..60bf4845c6 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -525,6 +525,71 @@ (t/is (array? result)) (t/is (= ["Inter" "Arial"] (vec result))))) +(t/deftest font-family-token-resolved-value-keeps-multi-word-families + ;; An unquoted family whose name has several words resolves to a list of word + ;; symbols, so the family name is the string form of each entry. + (let [token (ctob/make-token + {:name "font.body" + :type :font-family + :value ["Hanken Grotesk" "IBM Plex Mono"]}) + result (get-resolved-value token {(:name token) token})] + (t/is (array? result)) + (t/is (= ["Hanken Grotesk" "IBM Plex Mono"] (vec result))))) + +(t/deftest typography-token-resolved-value-keeps-multi-word-families + (let [token (ctob/make-token + {:name "type.body" + :type :typography + :value {:font-family ["Hanken Grotesk" "Arial"] + :font-size "16px"}}) + result (get-resolved-value token {(:name token) token}) + entry (aget result 0)] + (t/is (= ["Hanken Grotesk" "Arial"] (vec (aget entry "fontFamilies")))))) + +(t/deftest font-family-token-resolved-value-keeps-names-with-expression-words + ;; Words like `Red` or `Black` read as colors and `2P` as a number and a word + ;; when tokenscript parses the family name. + (let [families ["Red Hat Display" "Crimson Pro" "Archivo Black" + "Press Start 2P" "M PLUS 1p" "Crimson"] + token (ctob/make-token + {:name "font.display" + :type :font-family + :value families}) + result (get-resolved-value token {(:name token) token})] + (t/is (= families (vec result))))) + +(t/deftest font-family-token-resolved-value-mixes-references-and-names + (let [base (ctob/make-token + {:name "font.base" + :type :font-family + :value ["Red Hat Text"]}) + token (ctob/make-token + {:name "font.stack" + :type :font-family + :value ["{font.base}" "Rock 3D"]}) + result (get-resolved-value token {(:name base) base + (:name token) token})] + (t/is (= ["Red Hat Text" "Rock 3D"] (vec result))))) + +(t/deftest font-family-token-resolved-value-keeps-quoted-names + (let [token (ctob/make-token + {:name "font.quoted" + :type :font-family + :value ["\"Red Hat Mono\"" "'Black Ops One'"]}) + result (get-resolved-value token {(:name token) token})] + (t/is (= ["Red Hat Mono" "Black Ops One"] (vec result))))) + +(t/deftest typography-token-resolved-value-keeps-names-with-expression-words + (let [token (ctob/make-token + {:name "type.display" + :type :typography + :value {:font-family ["Red Hat Display" "Press Start 2P"] + :font-size "16px"}}) + result (get-resolved-value token {(:name token) token}) + entry (aget result 0)] + (t/is (= ["Red Hat Display" "Press Start 2P"] + (vec (aget entry "fontFamilies")))))) + (t/deftest token-theme-add-set-accepts-token-set-id (let [plugin-id "plugin-id" file-id (uuid/next) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 33623eea15..26c357a8b8 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -25,6 +25,7 @@ - **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001. - **plugins-runtime**: The plugin modal no longer shows two resize grips in Firefox. Firefox now shows only its native grip. Closes #11795. - **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405. +- **plugins-runtime**: `TokenFontFamilies.resolvedValue` and the `fontFamilies` member of `TokenTypographyValue` now keep a family whose name has several words (`Hanken Grotesk`) as one entry, instead of splitting it into an array of its words, and keep names with words the resolver would otherwise read as colors or numbers (`Red Hat Display`, `Press Start 2P`) unchanged. - **plugins-runtime**: Opening a plugin no longer drops running background plugins (`allowBackground`, such as the MCP plugin) from the runtime registry. They kept running, but their UI messages were no longer delivered to the plugin and they could no longer be unloaded, so MCP tasks timed out after another plugin was opened. ## 1.5.0 (2026-07-08) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts index 095a86ac17..550c64f261 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts @@ -418,6 +418,31 @@ describe('Tokens', () => { expect(Object.keys(b.tokens)).toContain('paddingLeft'); }); + // The target is a text shape because the attribute set of a board excludes + // the font-family property, so the application is filtered out there and + // the assertions pass vacuously. + // The family comes from the bundled Google fonts catalog, available in every + // run. `Red` in its name reads as a color if the resolver parses the words. + test('applyToken binds a fontFamilies token to a text shape', async (ctx) => { + const set = activeSet(ctx, unique('set')); + const font = ctx.penpot.fonts.findByName('Red Hat Display'); + if (!font) throw new Error('Red Hat Display font not found'); + const token = set.addToken({ + type: 'fontFamilies', + name: unique('fontFamilies.'), + value: [font.fontFamily], + }); + + const t = ctx.penpot.createText('Hello Penpot'); + if (!t) throw new Error('createText returned null'); + ctx.board.appendChild(t); + + t.applyToken(token, ['fontFamilies']); + await waitFor(() => t.fontFamily === font.fontFamily); + expect(Object.keys(t.tokens)).toContain('fontFamilies'); + expect(t.fontFamily).toBe(font.fontFamily); + }); + test('applyToken accepts the uniform strokeWidth property', async (ctx) => { const set = activeSet(ctx, unique('set')); const token = set.addToken({ @@ -730,6 +755,39 @@ describe('Token types', () => { expect(resolved as unknown as string[]).toContain('Arial'); }); + // The resolver parses a family whose name has several words as a list of + // word symbols, so each family name must survive as a single entry. A + // single-word family takes the simpler path the test above covers. + test('fontFamilies token resolvedValue keeps multi-word families whole', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'fontFamilies', + name: unique('fontFamilies.'), + value: ['Hanken Grotesk'], + }); + // The stored value keeps the family whole, isolating resolution below. + expect(token.value).toEqual(['Hanken Grotesk']); + + const resolved = token.resolvedValue as unknown as string[]; + expect(Array.isArray(resolved)).toBe(true); + expect(resolved).toHaveLength(1); + expect(resolved[0]).toBe('Hanken Grotesk'); + }); + + // Words like `Red` or `Black` read as colors and `2P` as a number and a word + // when the resolver parses the family name. + test('fontFamilies token resolvedValue keeps names with expression words', (ctx) => { + const set = activeSet(ctx, unique('set')); + const families = ['Red Hat Display', 'Archivo Black', 'Press Start 2P']; + const token = set.addToken({ + type: 'fontFamilies', + name: unique('fontFamilies.'), + value: families, + }); + + expect(token.resolvedValue as unknown as string[]).toEqual(families); + }); + test('shadow token exposes its composite value', (ctx) => { const set = activeSet(ctx, unique('set')); const token = set.addToken({ From 7383e086837c5d868de6e29f312f96924236f8bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Thu, 15 Oct 2026 10:10:11 +0200 Subject: [PATCH 014/167] :sparkles: Focus the search field when opening the color tokens tab (#12345) The color tokens tab of the color picker did not focus its search field, so users had to click it before typing a token name. The token dropdown of numeric inputs already focuses its search on open. Closes #12344 AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs index e08eda1d10..7aaf3ab797 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs @@ -310,6 +310,7 @@ :variant "comfortable" :class (stl/css :search-input) :default-value filter-term + :auto-focus true :on-change on-filter-tokens}] (if (seq sorted-tokens) [:div {:class (stl/css :color-tokens-inputs)} From c74d415ddd21c1e51bd9bb82b1ec10755dac6677 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 10:19:26 +0200 Subject: [PATCH 015/167] :bug: Stop oversized audit events from wedging the archive queue (#12252) * :bug: Stop the export-shapes modal event from dumping all exports The `:export-shapes` modal event carried the full `:exports` vector, each export embedding its shape (path data, geometry, text content). One event reached 1.7 MB, which made the audit archive cron POST a chunk past the proxy body limit and wedge the queue. Build a compact body for that modal instead: total exports, distinct shapes and one count per export type, plus the page name. Other modals keep their props unchanged. Move the modal `:origin` prop to `::ev/origin` so `make-proto-event` records it on the event context as `:event-origin`; the UI still reads the plain prop from the raw state. Add tests for the summary and for the modal origin. AI-assisted-by: deepseek-v4.1-flash * :bug: Bound exception messages in reports and audit events A frontend crash whose message embeds a whole payload (a serialized file) produced a 9.4 MB audit event: the message was repeated in the `:hint` prop, in the report context and in the stack trace. A single event that big wedged the audit archive queue. Bound exception messages at the source: `get-hint` clamps to `max-message-length` (1000 chars), and the CLJS `format-throwable` clamps the message line of the stack trace and honors `:trace-length` (parity with the CLJ version). `report-context` now prints `get-hint` instead of the raw `:hint` from `ex-data`. Fix `first-line` on the JVM: `cuerdas/index-of` returns nil when the substring is missing and `(pos? nil)` throws there (it is false in CLJS), so single-line messages crashed the backend path. AI-assisted-by: deepseek-v4.1-flash --- .serena/memories/audit-log.md | 2 + .serena/memories/clojure/idioms.md | 1 + .../frontend/routing-app-shell-subtleties.md | 1 + common/src/app/common/exceptions.cljc | 39 ++++++++++--- common/test/common_tests/exceptions_test.cljc | 57 +++++++++++++++++++ common/test/common_tests/runner.cljc | 2 + frontend/src/app/main/data/modal.cljs | 27 ++++++++- frontend/src/app/main/errors.cljs | 2 +- .../test/frontend_tests/data/modal_test.cljs | 55 ++++++++++++++++++ .../test/frontend_tests/main_errors_test.cljs | 17 ++++++ frontend/test/frontend_tests/runner.cljs | 2 + 11 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 common/test/common_tests/exceptions_test.cljc create mode 100644 frontend/test/frontend_tests/data/modal_test.cljs diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index 8097bd901c..223f370815 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -260,6 +260,8 @@ Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and insert Nexus is the long-term store. Every 5 min the `:audit-log-archive` cron takes 128-row chunks of unarchived rows (`FOR UPDATE SKIP LOCKED`, ordered by `created_at`), POSTs them as transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri, and marks `archived_at = now()` **only on HTTP 204**, in the same transaction. Anything else is retried next run; flag on but no URI raises `:task-not-configured`. It loops with 100 ms pauses until empty. +A chunk is **one POST** and the task advances only on 204, so a single oversized event wedges the whole queue: a crash report whose message embeds a whole payload (a serialized file, for example) can push the request past the proxy body limit and the rejection blocks every later event until it is pruned by hand. Frontend crash reports are therefore size-bounded at the source (`mem:frontend/routing-app-shell-subtleties`): `app.common.exceptions/get-hint` and the CLJS `format-throwable` clamp exception messages (`max-message-length`). + On the other side, Nexus checks `origin` against its allowlist (403 if not allowed), coerces the events, and inserts them into its own `audit_log` (partitioned by `received_at`, with an incrementing sequence number and `source`). Watch the rename: Penpot's `created-at` is stored as **`received-at`** in Nexus; Nexus's `created-at` is when it was created there. `tracked-at` keeps the origin time. Telemetry batches arriving at `telemetry.penpot.app` land in the same table through the telemetry endpoint above. Concurrent runs are safe: rows are locked with `FOR UPDATE SKIP LOCKED` inside the same transaction that sends and marks them, so two archivers running at once each take different rows. Only rows Nexus acknowledges (HTTP 204) get marked; anything else is retried on the next run. diff --git a/.serena/memories/clojure/idioms.md b/.serena/memories/clojure/idioms.md index a4e9f3af72..cf85848c3a 100644 --- a/.serena/memories/clojure/idioms.md +++ b/.serena/memories/clojure/idioms.md @@ -9,3 +9,4 @@ Behaviors confirmed against the language/stdlib — do not re-derive from assump - Valid: `(defn ^:async f)`, `(defn- ^:async f)`, `(^:async fn [] …)` (the latter is what stock `t/async` generates itself). - `^:async` fns never throw synchronously (rejected promises instead); `try/catch/finally` supported; continuations are microtasks, timers and RxJS schedulers are macrotasks (FIFO). - `!` suffix allowed cases (only exceptions to the no-new-`!` rule in `mem:clojure/design-rules`): mutating public `app.db` API (`insert!`, `update!`, `delete!`, `run!`, `tx-run!`, `exec!`, ...) and fns whose contract is to work over transients (stdlib `conj!`, `assoc!`, `dissoc!`, `disj!`, `persistent!` and custom helpers over them); any further case must be registered here with justification before use. +- `cuerdas.core/index-of` returns `nil` (not `-1`) when the substring is absent, like `clojure.string/index-of`. `(pos? nil)` **throws on the JVM** but evaluates to `false` in CLJS (JS coercion), so `(if (pos? (str/index-of s "\n")) …)` works in the browser and crashes the backend. Guard with `some?` before `pos?` (this was `app.common.exceptions/first-line`). diff --git a/.serena/memories/frontend/routing-app-shell-subtleties.md b/.serena/memories/frontend/routing-app-shell-subtleties.md index 3c370b8f6c..f98b7f626d 100644 --- a/.serena/memories/frontend/routing-app-shell-subtleties.md +++ b/.serena/memories/frontend/routing-app-shell-subtleties.md @@ -16,6 +16,7 @@ - `app.main.errors/submit-report` is governed by a dedup governor: each report carries a fingerprint (`report-name|type|code|hint|first stack frame`; the report name is part of it so a handled report never coalesces with an unhandled/exception-page one), the first occurrence is always emitted, repeats within 2 minutes are counted and included in the next emitted report as `:occurrences`, and the fingerprint cache is bounded (first-inserted entry evicted, FIFO, via `:order` queue) so memory stays fixed. It applies to `handled-exception`, `unhandled-exception` and `exception-page`; a report without an exception cause is ignored and does not consume a reservation. - Errors caused by the environment (`environment-error-types`: `:network`, `:offline`, `:bad-gateway`, `:service-unavailable`, `:nitrate-unavailable`, `:nitrate-not-configured`) are not application defects: they are reported as audit-only `handled-exception` (never `unhandled-exception`/`exception-page`, so they skip internal reports and alerts), with a compact report and a fingerprint that drops the stack frame. `:offline` has its own handler and no longer falls through to `:default`; `:network`/`:offline` show the `errors.connection-error` toast. - `generate-report` accepts an explicit `{:format :compact|:full}` (default `:full`) chosen by its caller (`flash`, `exception-section*`): `:compact` keeps the context header plus type/code/uri, and skips the stack, the `ex-data` dump and the last-events list. It is total: if formatting fails it returns a minimal fallback string instead of nil, so an already reserved emission is never dropped. +- Error messages are bounded at the source: `app.common.exceptions/get-hint` clamps to `max-message-length` (1000 chars) and the CLJS `format-throwable` clamps the message line of the stack trace and honors `:trace-length`. A message can embed a whole payload (a serialized file, for example) and reach megabytes; the bound keeps the audit `:hint` prop and the `:report` string small. The `Context: Hint:` header in `report-context` prints `get-hint` (not the raw `:hint` from `ex-data`) for the same reason. - `flash` runs its whole body (report pipeline first, toast after) inside a single `ts/schedule` callback: nothing executes synchronously on the error handler's stack. The report is reserved before being generated, so suppressed occurrences do not pay the `generate-report` cost; a reporting or notification failure is logged to the console and never propagates, and the toast is still attempted. `flash` returns a total promise (never rejects) resolving with the generated report, or nil when nothing is emitted, once the callback completes; production callers ignore it, tests await it. `flash` derives only the payload format from the cause (`environment-error?` → `:compact`); the audit event name is the canonical one requested by `:type` (`handled-exception`/`unhandled-exception`) and is never reclassified, because external tools filter on those names. `exception-section*` picks `handled-exception`/`exception-page` explicitly per cause; `flash-persistence` only adds the toast hint. ## Store and websocket diff --git a/common/src/app/common/exceptions.cljc b/common/src/app/common/exceptions.cljc index 5a8a9acaec..f27d1d531d 100644 --- a/common/src/app/common/exceptions.cljc +++ b/common/src/app/common/exceptions.cljc @@ -29,6 +29,22 @@ (def ^:dynamic *data-length* 8) (def ^:dynamic *data-level* 8) +(def max-message-length + "Upper bound, in characters, for an exception message surfaced as a hint or + embedded in a formatted stack trace. A message can carry a whole payload (a + serialized file, for instance) and grow to megabytes; only the head helps to + identify the failure, and the rest is noise that bloats reports and the audit + events built from them." + 1000) + +(defn- truncate + "Truncates a string to at most `n` characters, appending an ellipsis when it + had to cut. Values that are not strings pass through unchanged." + [s n] + (if (and (string? s) (> (count s) n)) + (str (subs s 0 n) "...") + s)) + (defmacro error [& {:keys [type hint] :as params}] `(ex-info ~(or hint (name type)) @@ -243,7 +259,7 @@ :cljs (defn format-throwable - [cause & {:as opts}] + [cause & {:keys [trace-length]}] (with-out-str (when-let [exdata (ex-data cause)] (when-let [hint (or (get exdata :hint) @@ -251,7 +267,7 @@ (when (str/index-of hint "\n") (println "Hint:") (println "--------------------") - (println hint) + (println (truncate hint max-message-length)) (println))) (when-let [explain (get exdata ::sm/explain)] @@ -272,14 +288,20 @@ (println)) (when-let [trace (.-stack cause)] - (println "Trace:") - (println "--------------------") - (println (.-stack cause)))))) + (let [lines (str/lines trace) + head (truncate (first lines) max-message-length) + tail (rest lines)] + (println "Trace:") + (println "--------------------") + (when head + (println head)) + (doseq [line (if trace-length (take trace-length tail) tail)] + (println line))))))) (defn first-line [s] (let [break-index (str/index-of s "\n")] - (if (pos? break-index) + (if (and (some? break-index) (pos? break-index)) (subs s 0 break-index) s))) @@ -308,5 +330,6 @@ (defn get-hint [cause] - (or (some-> (ex-data cause) (get :hint) first-line) - (some-> (ex-message cause) first-line))) + (truncate (or (some-> (ex-data cause) (get :hint) first-line) + (some-> (ex-message cause) first-line)) + max-message-length)) diff --git a/common/test/common_tests/exceptions_test.cljc b/common/test/common_tests/exceptions_test.cljc new file mode 100644 index 0000000000..6f9df8da60 --- /dev/null +++ b/common/test/common_tests/exceptions_test.cljc @@ -0,0 +1,57 @@ +;; 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.exceptions-test + "Exception message bounding in `app.common.exceptions`. + + A message can carry a whole payload (a serialized file, for instance) and + grow to megabytes. `get-hint` and the CLJS `format-throwable` keep a bounded + head so neither the audit `:hint` prop nor the report can explode." + (:require + #?(:cljs [cuerdas.core :as str]) + [app.common.exceptions :as ex] + [clojure.test :as t])) + +(defn- huge-string + [n] + (apply str (repeat n "x"))) + +(t/deftest first-line-keeps-a-single-line-message-intact-test + (t/is (= "boom" (ex/first-line "boom"))) + (t/is (= "boom" (ex/first-line "boom\nrest of the message")))) + +(t/deftest get-hint-returns-the-first-line-test + (t/is (= "boom" (ex/get-hint (ex-info "boom\nsecond line" {}))))) + +(t/deftest get-hint-prefers-the-ex-data-hint-test + (t/is (= "from-data" (ex/get-hint (ex-info "from-message" {:hint "from-data"}))))) + +(t/deftest get-hint-bounds-a-huge-message-test + (let [huge (huge-string (* 4 ex/max-message-length)) + hint (ex/get-hint (ex-info huge {}))] + (t/is (string? hint)) + (t/is (<= (count hint) (+ ex/max-message-length 3))) + (t/is (= (subs hint 0 32) (subs huge 0 32))))) + +(t/deftest get-hint-bounds-a-huge-ex-data-hint-test + (let [huge (huge-string (* 4 ex/max-message-length)) + hint (ex/get-hint (ex-info "short" {:hint huge}))] + (t/is (<= (count hint) (+ ex/max-message-length 3))) + (t/is (= (subs hint 0 32) (subs huge 0 32))))) + +#?(:cljs + (t/deftest format-throwable-bounds-a-huge-trace-message-test + (let [huge (huge-string (* 4 ex/max-message-length)) + out (ex/format-throwable (ex-info huge {})) + msg (some #(when (str/includes? % (subs huge 0 16)) %) (str/lines out))] + (t/is (some? msg)) + (t/is (<= (count msg) (+ ex/max-message-length 16)))))) + +#?(:cljs + (t/deftest format-throwable-honors-trace-length-test + (let [full (ex/format-throwable (ex-info "boom" {})) + short (ex/format-throwable (ex-info "boom" {}) {:trace-length 1})] + (t/is (< (count (str/lines short)) (count (str/lines full))))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index eb2e3bb1c7..e4cec9b442 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -16,6 +16,7 @@ [common-tests.buffer-test] [common-tests.colors-test] [common-tests.data-test] + [common-tests.exceptions-test] [common-tests.files-builder-test] [common-tests.files-changes-test] [common-tests.files-migrations-0025-test] @@ -118,6 +119,7 @@ 'common-tests.buffer-test 'common-tests.colors-test 'common-tests.data-test + 'common-tests.exceptions-test 'common-tests.files-builder-test 'common-tests.files-changes-test 'common-tests.files-migrations-0025-test diff --git a/frontend/src/app/main/data/modal.cljs b/frontend/src/app/main/data/modal.cljs index 458eba37ad..7db55ee4d7 100644 --- a/frontend/src/app/main/data/modal.cljs +++ b/frontend/src/app/main/data/modal.cljs @@ -17,6 +17,19 @@ ;; TODO: rename `:type` to `:name` +(defn- summarize-export-shapes + "Builds a compact body for the `:export-shapes` modal event. We know the + shape of these props here, so instead of dumping every export (each one + carrying its full shape, which can be megabytes) we emit counters: total + exports, distinct shapes and one count per export type." + [props] + (let [exports (:exports props)] + (merge + {:page (:name props) + :num-shapes (count (distinct (map :object-id exports))) + :num-exports (count exports)} + (frequencies (map :type exports))))) + (defn show ([props] (show (uuid/next) (:type props) props)) @@ -26,9 +39,17 @@ (ptk/reify ::show-modal ev/Event (-data [_] - (-> props - (dissoc :type) - (assoc :name type))) + (let [origin (:origin props) + data (if (= type :export-shapes) + (assoc (summarize-export-shapes props) :name type) + (-> props + (dissoc :type) + (assoc :name type)))] + ;; The origin is event metadata, not a prop: sending it as + ;; ::ev/origin makes `make-proto-event` put it on the event context + ;; (`:event-origin`); as a plain `:origin` it only stayed as a prop. + (cond-> (dissoc data :origin) + (some? origin) (assoc ::ev/origin origin)))) ptk/UpdateEvent (update [_ state] diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index d2c4976024..3eb4496bfb 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -192,7 +192,7 @@ (println "Context:") (println "--------------------") (println "Timestamp:" (ct/format-inst (ct/now) :rfc1123)) - (println "Hint: " (or (:hint data) (ex-message cause) "--")) + (println "Hint: " (or (ex/get-hint cause) "--")) (println "Prof ID: " (str (or profile-id "--"))) (println "Team ID: " (str (or team-id "--"))) (when-let [file-id (or (:file-id data) file-id)] diff --git a/frontend/test/frontend_tests/data/modal_test.cljs b/frontend/test/frontend_tests/data/modal_test.cljs new file mode 100644 index 0000000000..e3b0fd4325 --- /dev/null +++ b/frontend/test/frontend_tests/data/modal_test.cljs @@ -0,0 +1,55 @@ +;; 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.modal-test + (:require + [app.common.uuid :as uuid] + [app.main.data.event :as ev] + [app.main.data.modal :as modal] + [cljs.test :as t :include-macros true])) + +(t/deftest export-shapes-modal-event-carries-a-summary + (let [shape-a (uuid/next) + shape-b (uuid/next) + exports [{:object-id shape-a :type :png} + {:object-id shape-a :type :svg} + {:object-id shape-b :type :png}] + event (modal/show :export-shapes + {:exports exports + :origin "workspace:sidebar" + :name "Page 1"}) + data (ev/-data event)] + + (t/testing "the raw exports never leak into the event" + (t/is (not (contains? data :exports)))) + + (t/testing "the event carries counters instead" + (t/is (= 3 (:num-exports data))) + (t/is (= 2 (:num-shapes data))) + (t/is (= 2 (:png data))) + (t/is (= 1 (:svg data)))) + + (t/testing "the modal context is preserved" + (t/is (= "Page 1" (:page data))) + (t/is (= :export-shapes (:name data)))) + + (t/testing "origin is emitted as event metadata, not as a prop" + (t/is (= "workspace:sidebar" (::ev/origin data))) + (t/is (not (contains? data :origin)))))) + +(t/deftest other-modals-keep-their-props + (let [event (modal/show :some-modal {:foo "bar" :items [1 2 3]}) + data (ev/-data event)] + (t/is (= "bar" (:foo data))) + (t/is (= [1 2 3] (:items data))) + (t/is (= :some-modal (:name data))))) + +(t/deftest modal-origin-becomes-event-metadata + (let [event (modal/show :some-modal {:foo "bar" :origin "dashboard"}) + data (ev/-data event)] + (t/is (= "dashboard" (::ev/origin data))) + (t/is (not (contains? data :origin))) + (t/is (= "bar" (:foo data))))) diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index c622647f84..fd9cfe6450 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -191,6 +191,23 @@ ;; evaluates to a promise, so the body must settle one. (await (async/settle)))))) +(t/deftest ^:async generate-report-bounds-a-huge-exception-message + ;; A message can embed a whole payload (a serialized file) and reach + ;; megabytes. The report must keep a bounded head in both the context and + ;; the trace, so the audit event built from it can never bloat. + (await + (mock/with-mocks* + {st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (let [huge (apply str (repeat 200000 "z")) + report (errors/generate-report (js/Error. huge))] + (t/is (string? report)) + (t/is (< (count report) 50000)) + (t/is (not (str/includes? report (subs huge 0 1500)))) + ;; Trailing settle: the body is synchronous, but `with-mocks*` + ;; evaluates to a promise, so the body must settle one. + (await (async/settle)))))) + (t/deftest ^:async connectivity-handlers-report-governed-compact-audit-events ;; Scenario: a network failure and an offline status through the global ;; handler. Each is reported as a governed compact audit event plus its diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 8ab9b7ac6b..7db391183b 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -14,6 +14,7 @@ [frontend-tests.data.event-test] [frontend-tests.data.exports-assets-test] [frontend-tests.data.fix-deleted-fonts-test] + [frontend-tests.data.modal-test] [frontend-tests.data.nitrate-test] [frontend-tests.data.persistence-retry-test] [frontend-tests.data.persistence-test] @@ -157,6 +158,7 @@ 'frontend-tests.data.dashboard-test 'frontend-tests.data.event-test 'frontend-tests.data.fix-deleted-fonts-test + 'frontend-tests.data.modal-test 'frontend-tests.data.nitrate-test 'frontend-tests.data.persistence-retry-test 'frontend-tests.data.persistence-test From dacc65a37bfbb1f65ff996eda327dfab7ab41298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Thu, 15 Oct 2026 12:26:16 +0200 Subject: [PATCH 016/167] :sparkles: Show trial end date on subscription page (#12342) --- frontend/resources/images/icons/hourglass.svg | 6 ++++ .../main/ui/ds/foundations/assets/icon.cljs | 1 + frontend/src/app/main/ui/icons.cljs | 1 + .../app/main/ui/settings/subscription.cljs | 28 ++++++++++++++----- frontend/translations/en.po | 5 +++- frontend/translations/es.po | 5 +++- 6 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 frontend/resources/images/icons/hourglass.svg diff --git a/frontend/resources/images/icons/hourglass.svg b/frontend/resources/images/icons/hourglass.svg new file mode 100644 index 0000000000..f20d4e15f1 --- /dev/null +++ b/frontend/resources/images/icons/hourglass.svg @@ -0,0 +1,6 @@ + + + + + + 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 da902d690e..991532c963 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -168,6 +168,7 @@ (def ^:icon-id help "help") (def ^:icon-id hide "hide") (def ^:icon-id history "history") +(def ^:icon-id hourglass "hourglass") (def ^:icon-id hsva "hsva") (def ^:icon-id hug-content "hug-content") (def ^:icon-id icon "icon") diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 9e62720d02..7dec276e5d 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -150,6 +150,7 @@ (def ^:icon help (icon-xref :help)) (def ^:icon hide (icon-xref :hide)) (def ^:icon history (icon-xref :history)) +(def ^:icon hourglass (icon-xref :hourglass)) (def ^:icon hsva (icon-xref :hsva)) (def ^:icon hug-content (icon-xref :hug-content)) (def ^:icon icon (icon-xref :icon)) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index b779fc2c70..1ac36461b0 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -469,7 +469,14 @@ (= subscription-type "enterprise") (tr "subscription.settings.enterprise")) subscription-is-trial? - (= (:status subscription) "trialing") + (if nitrate? + (= (:status nitrate-license) "trialing") + (= (:status subscription) "trialing")) + + trial-end-date + (if nitrate? + (ct/format-inst (:trial-end nitrate-license) "d MMMM, yyyy") + (ct/format-inst (:trial-end subscription) "d MMMM, yyyy")) member-since (ct/format-inst (:created-at profile) "d MMMM, yyyy") @@ -699,12 +706,19 @@ [:div {:class (stl/css :membership-container)} (when (or nitrate? (and subscribed-since (not= subscription-type "professional"))) - [:div {:class (stl/css :membership)} - [:> icon* {:class (stl/css :subscription-member) - :icon-id "crown" - :size "m"}] - [:span {:class (stl/css :membership-date)} - (tr "subscription.settings.subscribed-since" subscription-name subscribed-since)]]) + (if subscription-is-trial? + [:div {:class (stl/css :membership)} + [:> icon* {:class (stl/css :subscription-member) + :icon-id "hourglass" + :size "m"}] + [:span {:class (stl/css :membership-date)} + (tr "subscription.settings.trial-ends-on" trial-end-date)]] + [:div {:class (stl/css :membership)} + [:> icon* {:class (stl/css :subscription-member) + :icon-id "crown" + :size "m"}] + [:span {:class (stl/css :membership-date)} + (tr "subscription.settings.subscribed-since" subscription-name subscribed-since)]])) [:div {:class (stl/css :membership)} [:> icon* {:class (stl/css :penpot-member) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b9a3544478..68f7eb7cbf 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -5786,7 +5786,7 @@ msgstr "" #: src/app/main/ui/settings/subscription.cljs:714 msgid "subscription.settings.member-since" -msgstr "Penpot member since: %s" +msgstr "Penpot member since: %s." #: src/app/main/ui/settings/subscription.cljs:736, src/app/main/ui/settings/subscription.cljs:751, src/app/main/ui/settings/subscription.cljs:767, src/app/main/ui/settings/subscription.cljs:787 msgid "subscription.settings.more-information" @@ -5856,6 +5856,9 @@ msgstr "Subscribe" msgid "subscription.settings.subscribed-since" msgstr "You've been on the %s plan since %s." +msgid "subscription.settings.trial-ends-on" +msgstr "Your free trial ends on %s." + #: src/app/main/ui/settings/subscription.cljs:399 msgid "subscription.settings.success.dialog.description" msgstr "" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 9adb3cde53..794f34f57f 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -5848,7 +5848,7 @@ msgstr "" #: src/app/main/ui/settings/subscription.cljs:714 msgid "subscription.settings.member-since" -msgstr "Miembro de penpot desde: %s" +msgstr "Miembro de penpot desde: %s." #: src/app/main/ui/settings/subscription.cljs:736, src/app/main/ui/settings/subscription.cljs:751, src/app/main/ui/settings/subscription.cljs:767, src/app/main/ui/settings/subscription.cljs:787 msgid "subscription.settings.more-information" @@ -5920,6 +5920,9 @@ msgstr "Suscríbete" msgid "subscription.settings.subscribed-since" msgstr "Llevas en el plan %s desde %s." +msgid "subscription.settings.trial-ends-on" +msgstr "Tu prueba gratuita acaba el %s." + #: src/app/main/ui/settings/subscription.cljs:399 msgid "subscription.settings.success.dialog.description" msgstr "" From a53481c3b7cc37a02850097007e184affac170a6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 13:01:18 +0200 Subject: [PATCH 017/167] :sparkles: Enrich backend audit events with team and entity ids (#12233) * :sparkles: Enrich backend audit events with team and entity ids Backend audit events now carry the data a consumer needs to rebuild state without joining production tables: - prepare-rpc-event adds :team-id when the event carries a project or file id; commands that already loaded the team set it themselves. - add-profile-to-team! emits add-team-member on every real insert. - create-project and create-file emit the new row id. - clone-template, import-binfile and duplicate-project emit one event per created entity. - both binfile versions return {:file-ids [...]} from import-files!. Closes #12219 AI-assisted-by: deepseek-v4.1-flash * :sparkles: Emit delete and hard-delete audit events with entity data The event stream must let Nexus rebuild local state without joining production tables. This completes the enrichment with the delete paths: - delete-team keeps returning nil but now carries the deleted row (id, name, timestamps) in the audit props, like delete-project. - create-team adds created-at to its audit props. - delete-profile carries id, email, fullname and deleted-at; its props used to be empty because every param is namespaced. - objects-gc emits hard-delete-team/project/file per purged batch, with the deleted ids, only when the audit log is on. - delete-team-member needs no change: the delete is physical and the params already carry team-id and member-id. The nitrate push-audit-events tests now drop the membership events emitted by their own profile setup before counting. Closes #12219 AI-assisted-by: muse-spark-1.3-free * :sparkles: Announce cascade deletes and missing ids in audit events The :delete-object worker task marked teams, projects and files without leaving any event, so an event-sourced projection kept them alive for weeks. It now emits one cascade-deleted event per execution with every affected id, in the same transaction, only with :audit-log on. Rows purged by objects-gc and snapshot cascades stay silent on purpose. Also: create-team-with-invitations carries the new team id, and update-team-member-role names the demoted member on owner transfers. Closes #12219 AI-assisted-by: muse-spark-1.3-free * :bug: Identify the SREPL profile delete in audit events The SREPL delete-profile! emitted no profile-id and empty props, so the row could not be attributed by any consumer. It now carries the profile id in both fields. delete-team! named the wrong cause in its context. Closes #12219 AI-assisted-by: muse-spark-1.3-free * :bug: Identify the SREPL profile restore in audit events The SREPL restore-profile! emitted no profile-id, so the consumer keyed on the zero profile instead of the restored one. It now carries the profile id like a normal profile event, and no consumer special case is needed. Closes #12219 AI-assisted-by: muse-spark-1.3-free * :bug: Stop attributing SREPL events to the subject profile profile-id names the actor, and SREPL calls have none: setting the subject id blamed the user for an admin action and would have leaked those rows into PostHog as user activity. The subject id stays in props, where the consumer resolves it, and the event profile stays zero. Closes #12219 AI-assisted-by: muse-spark-1.3-free * :sparkles: Expose file analysis data for the nexus pipeline The nexus file-security pipeline needs file content and scheduling state from the backend. This adds a read-only management method, `get-file-for-analysis`, returning the semantic projection of one file (pages, frames, texts, links, interactions; ids as strings; capped with a truncated flag), plus identity, revn, timestamps and share state. No prior projection code existed, so the method ships its own pure `file->projection`. Audit events now carry what the `:local` consumer schedules from: revn and modified-at on update-file, name and timestamps on every create-file emitter, deleted-at on delete-file, and new create/delete-share-link events with file and link ids. AI-assisted-by: muse-spark-1.3-contributor * :recycle: Rename deletion audit events to action names Event names are actions, not past tense: `cascade-deleted` becomes `delete-object-with-cascade` with the same shape and transaction. The three `hard-delete-*` events merge into `hard-delete-objects`: one event per purged chunk carrying only that chunk's entity key (`:team-ids`, `:project-ids` or `:file-ids`), because each GC section purges in its own chunk transactions and a single event could never share them all. Every submit stays inside its delete transaction, `triggered-by` stays annotated, and silent paths stay silent. AI-assisted-by: muse-spark-1.3-contributor * :bug: Read worker parallelism knobs with plain config keys The runner wiring looked up `:app.main/worker-*-parallelism` (namespaced) while the schema and the environment provide plain keys, so `PENPOT_WORKER_DEFAULT_PARALLELISM` and `PENPOT_WORKER_WEBHOOK_PARALLELISM` never applied and workers always ran with the fallback. Read the keys the config holds. AI-assisted-by: muse-spark-1.3-contributor * :bug: Fix binfile audit test with a real file row `submit-file-created-event` reads the light columns back from the row, so the mocked importer must return an existing file id: a phantom id emits no event and the test saw zero `create-file` events. Create the file first and use its id. The audit-log memory now tells both bulk-event helpers apart. AI-assisted-by: muse-spark-1.3-contributor * :bug: Fix audit event fidelity from review findings The review of the audit enrichment found data-fidelity gaps; fix them while the branch is fresh. Membership events now belong to the actor: the flows where the owner adds somebody else pass an explicit actor id, the role is derived from the flags (the table has no role column), and the event carries the request context like its siblings. Also: team-id resolution skips read events, duplicate-project emits the project name and timestamps, delete-profile is idempotent again, the GC tests assert physical purge, and get-file-for-analysis leaves an audit trace. AI-assisted-by: muse-spark-1.3-contributor * :recycle: Nest cascade ids under :cascade in delete event The cascade event carried every affected id flat next to the object identity; nest them under `:cascade` so the payload reads as header plus affected set. The event is an action like its siblings, not a command. Drop the `:audit-log` flag re-check: `submit` already fans out per flag. AI-assisted-by: muse-spark-1.3-contributor * :paperclip: Update copyright name on file header The company name changed; a single header in this branch still carried the old one. Align it with the current form. AI-assisted-by: muse-spark-1.3-contributor * :bug: Fix nil event-id on webhook trigger audit rows process-event generated the row id inside append-audit-entry, so the :process-webhook-event task never received it and the trigger row stored a nil :event-id. Assign the id up-front and share it between the stored row and the worker params; the telemetry shadow row keeps its own id to avoid a PK collision. Add regression tests covering the handoff and the end-to-end trigger row. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/audit-log.md | 21 +- backend/src/app/binfile/v1.clj | 4 +- backend/src/app/loggers/audit.clj | 205 +++++++--- backend/src/app/main.clj | 4 +- backend/src/app/rpc.clj | 3 +- backend/src/app/rpc/commands/binfile.clj | 12 +- backend/src/app/rpc/commands/comments.clj | 4 +- backend/src/app/rpc/commands/files.clj | 4 +- backend/src/app/rpc/commands/files_create.clj | 10 +- backend/src/app/rpc/commands/files_share.clj | 14 +- backend/src/app/rpc/commands/files_update.clj | 17 +- backend/src/app/rpc/commands/management.clj | 37 +- backend/src/app/rpc/commands/media.clj | 37 +- backend/src/app/rpc/commands/profile.clj | 9 +- backend/src/app/rpc/commands/projects.clj | 5 +- backend/src/app/rpc/commands/teams.clj | 67 +++- .../app/rpc/commands/teams_invitations.clj | 20 +- backend/src/app/rpc/commands/webhooks.clj | 8 +- backend/src/app/rpc/management/files.clj | 238 +++++++++++ backend/src/app/srepl/main.clj | 5 +- backend/src/app/tasks/delete_object.clj | 53 ++- backend/src/app/tasks/objects_gc.clj | 109 ++++-- .../test/backend_tests/binfile_svg_test.clj | 15 +- .../test/backend_tests/delete_object_test.clj | 107 +++++ backend/test/backend_tests/helpers.clj | 14 + .../backend_tests/loggers_webhooks_test.clj | 40 ++ .../test/backend_tests/objects_gc_test.clj | 121 +++++- backend/test/backend_tests/rpc_audit_test.clj | 150 ++++++- .../test/backend_tests/rpc_binfile_test.clj | 41 +- .../test/backend_tests/rpc_comment_test.clj | 29 ++ .../backend_tests/rpc_file_security_test.clj | 368 ++++++++++++++++++ backend/test/backend_tests/rpc_file_test.clj | 33 ++ .../rpc_management_nitrate_test.clj | 15 + .../backend_tests/rpc_management_test.clj | 56 ++- backend/test/backend_tests/rpc_media_test.clj | 24 ++ .../test/backend_tests/rpc_profile_test.clj | 27 ++ .../test/backend_tests/rpc_project_test.clj | 13 + backend/test/backend_tests/rpc_team_test.clj | 195 ++++++++++ .../test/backend_tests/rpc_webhooks_test.clj | 26 ++ .../test/backend_tests/srepl_main_test.clj | 57 ++- 40 files changed, 2024 insertions(+), 193 deletions(-) create mode 100644 backend/src/app/rpc/management/files.clj create mode 100644 backend/test/backend_tests/delete_object_test.clj create mode 100644 backend/test/backend_tests/rpc_file_security_test.clj diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index 223f370815..3739612214 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -118,6 +118,21 @@ Critical `profile-id` INVARIANT: the event belongs to **whoever made the request The `::audit/profile-id` override goes through `coerce-profile-id`: a string is parsed, anything that cannot become a uuid is dropped (with a warning) and the event falls back to the caller. Do not pass the value raw: `schema:event` needs a uuid and `submit*` swallows the validation error (it only logs, the RPC still succeeds), so a non-uuid override **loses the row silently**. Hand-built events with `event-from-rpc-params` + `submit` (clone template, accept team invitation, `management/nitrate` push-audit-events) do **not** go through that coercion: they must already carry a uuid. +### Team id and entity ids (projection data) + +The event stream is meant to let a consumer (Nexus) rebuild what it needs **without joining production tables**, so events carry the ids a projection keys on: + +- **Team id enrichment.** After `clean-props`, `prepare-rpc-event` calls `enrich-team-id`: when props have no `:team-id` but carry a `:project-id` or `:file-id`, it resolves the team with a local query and adds `:team-id`. It never resolves from `:id` (read commands put the entity id there, and that would add a query to every read), and it skips read events entirely (`get-*` names plus `has-file-libraries`: their events carry no consumer state). Commands that already loaded the team add `:team-id` themselves in `::audit/props` (`delete-file`, comments, media upload, `delete/update-webhook`, `clone-template`, ...), so the resolver does not run. +- **Membership.** `add-profile-to-team!` emits `add-team-member` (props `:team-id`, `:member-id`, `:role`, `:is-owner`, `:is-admin`, `:can-edit`) whenever it actually inserts a row, only with `:audit-log` on, inside the caller's transaction. It covers the flows that add a member with no event of their own (`create-team` owner, invitee added directly when `:email-verification` is off, approved join-request, duplicated team). With `on-conflict-do-nothing?` a conflict returns nil and emits nothing, so an existing membership is reported once. The event belongs to the actor: the two flows where the owner adds somebody else (`create-invitation`, `add-member-to-team`) pass `:actor-id` through a separate `event-opts` arg (never in `params`, which is inserted as-is); everywhere else actor and member coincide. `:role` is derived in the emitter from the flags (inverse of `permissions-for-role`: owner > admin > editor > viewer) because the table has no role column; request context travels in `event-opts` as `:rpc-params`. +- **Created entity ids.** `create-project` and `create-file` add the new row's `:id` (and `:project-id`/`:team-id`) to `::audit/props`, so a projection can key it. +- **Bulk operations.** `clone-template`, `import-binfile` and `duplicate-project` create entities through internal calls that emit nothing on their own; they emit a `create-file` (and `create-project`) per entity (only with `:audit-log`). The import paths go through `audit/submit-file-created-event`, which reads the light columns (`id`, `name`, `project-id`, timestamps, `revn`) back from the row — the file must really exist, a phantom id emits nothing. `duplicate-project` already holds the rows and uses `audit/submit-create-event` directly. Both binfile versions return the same shape from `import-files!` (`{:file-ids [...]}`). +- **Deletes.** `delete-team` still returns nil but carries the deleted row (`:id`, `:name`, `:created-at`, `:modified-at`, `:deleted-at`) in `::audit/props`, like `delete-project`. `delete-profile` carries `:id`, `:email`, `:fullname`, `:created-at`, `:modified-at` and `:deleted-at` (without this its props are empty: every param is namespaced and `clean-props` drops them). `create-team` adds `:created-at` and `:modified-at`. `delete-team-member` needs nothing: the delete is physical and the params already carry `:team-id`/`:member-id`. +- **Hard deletes.** `objects-gc` emits one `hard-delete-objects` event per purged chunk, carrying only that chunk's entity key (`:team-ids`, `:project-ids` or `:file-ids`; one chunk transaction purges a single entity, so one event can never span sections) with context `{:triggered-by "objects-gc"}`, only with `:audit-log` on. No other GC path emits (profiles, fonts, thumbnails); those purges stay silent. +- **SREPL deletes.** `delete-profile!` carries the subject id in props (it used to emit nothing usable); the event profile stays zero because SREPL has no actor. `delete-team!` props already carried the id. Both travel as actions with a `triggered-by: srepl` context. +- **SREPL restores.** `restore-profile!` carries the full row (with id) in props with a zero event profile; `restore-team!` and `restore-project!` already carried their ids. The consumer resolves the subject from props by a uniform rule, with no SREPL special case. +- **Cascades.** The `:delete-object` worker task emits one `delete-object-with-cascade` event per execution, type `action`, with `:object`/`:id`/`:deleted-at` plus the affected ids nested under `:cascade` (`:team-ids`, `:project-ids`, `:file-ids`), in the same transaction. The handler submits unconditionally (`submit` fans out per flag, so no `:audit-log` re-check); the `audit_log` row itself is still only written with `:audit-log` on. Direct calls (objects-gc) and snapshot cascades stay silent on purpose. +- **Ownership.** `create-team-with-invitations` carries the new team `:id` like `create-team`; `update-team-member-role` names the demoted member in `:prev-owner-id` on owner transfers. + Entry points: - `submit`: the normal one (fills defaults, validates, runs inside `tx-run!`, never breaks the RPC). @@ -223,6 +238,8 @@ Webhooks let an external URL get notified when something happens in a team (for One guarantee: sending webhooks never changes the audit event itself. If delivery fails, only the webhook (and its delivery log) is affected; the audit row stays untouched and still gets archived normally. +Every match also writes a `webhook` / `trigger` row to `audit_log` (even with zero matches): its props are `{:name , :event-id , :total-affected }`. `process-event` assigns the origin row id up-front so the trigger can reference it; the telemetry shadow row always gets its own id (reusing it would collide on the PK). + ## 10. Consumers II: error reporters Both `app.loggers.database` and `app.loggers.mattermost` listen to backend `:error` logs **and** frontend crash events (found by the `::audit/event` marker), through a sliding-buffer channel so a flood cannot stall the app. The database one stores them in `server-error-report` (source 3 = backend log, 4 = audit-log origin, 5 = rate-limit); the Mattermost one forwards a short note to `:error-report-webhook` when set. @@ -308,13 +325,15 @@ Config (`backend/src/app/config.clj`): `:audit-log-archive-uri`, `:telemetry-uri - **New backend command that must (or must not) emit**: `wrap-audit` does it alone; to exclude it set `::audit/skip`. To customize props/name/type use result metadata. Add a test mocking `app.loggers.audit/submit` (nil; `helpers.clj` stubs it globally) and assert `:call-args-list` — see `backend_tests/rpc-audit-test.clj` (full-row, telemetry-only, dual, no-op without flags, `insert`, `prepare-rpc-event`) and `rpc-team-test/accept-organization-invitation-audit-event` (three cases, exact props; counts matter: asserting with `first` passes even when a command emits the same name twice). - **New Penpot frontend event**: create the data-event or implement `Event`/`PerformanceEvent` in `app.main.data.event`, with `::name`/`::type`/`::origin`. Honor `skip-audit?` if the action can resume. Remember: with no props schema, **your asserts are the contract**. - **New admin-console event**: server action → add the backend-log call next to the mutation, best-effort so it never breaks the action; browser action → add a new event helper plus its test. +- **Command that creates an entity or already knows its team**: put the new row's `:id` in `::audit/props` (`create-project`/`create-file`) and set `:team-id` when the team is loaded, so the consumer never has to join production tables. For entities created inside an internal call (`clone-template`, `import-binfile`, `duplicate-project`), emit one event per entity: `audit/submit-file-created-event` when only the id is known (it reads the row back), `audit/submit-create-event` when the row is already in hand. - **Never** put secrets in `props`; `clean-props` is a last defense, not an excuse. - **Do not duplicate events**: if the backend already emits an event for an action, do not add a frontend (or admin-console browser) event for the same action — the backend one wins because it is reliable. A second event for the same action is only acceptable with a strong reason (for example the backend cannot see it at all), and then it must be clearly justified. - **Follow the naming policy**: use kebab-case names (`"create-organization"`, not `"createOrganization"`), and before inventing prop keys, look at similar existing events and reuse their key names (`organization-id`, `team-id`, `file-id`, `event-origin`...). Same concept, same key spelling — that is what keeps the data queryable later. ## 15. Code map -- Backend producer: `backend/src/app/loggers/audit.clj` (`submit`, `submit*`, `insert`, `prepare-rpc-event`, `event-from-rpc-params`, telemetry filters), `backend/src/app/rpc.clj` (`wrap-audit`), `backend/src/app/rpc/commands/audit.clj` (`push-audit-events`, `get-environment-data`, deprecated `get-enabled-flags`), `backend/src/app/rpc/management/nitrate.clj` (`push-audit-events` Nitrate→Penpot), `backend/src/app/http/middleware.clj` + `backend/src/app/http/management.clj` (shared-key auth). +- Backend producer: `backend/src/app/loggers/audit.clj` (`submit`, `submit*`, `insert`, `prepare-rpc-event`, `enrich-team-id`, `submit-create-event`, `event-from-rpc-params`, telemetry filters), `backend/src/app/rpc.clj` (`wrap-audit`), `backend/src/app/rpc/commands/audit.clj` (`push-audit-events`, `get-environment-data`, deprecated `get-enabled-flags`), `backend/src/app/rpc/management/nitrate.clj` (`push-audit-events` Nitrate→Penpot), `backend/src/app/http/middleware.clj` + `backend/src/app/http/management.clj` (shared-key auth). +- Membership emitter: `backend/src/app/rpc/commands/teams.clj` (`add-profile-to-team!` → `add-team-member`). - Penpot frontend producer: `frontend/src/app/main/data/event.cljs` (collector, buffer, send), `frontend/src/app/main/repo.cljs` + `frontend/src/app/util/http.cljs` (headers), `frontend/src/app/main/data/nitrate.cljs` (Nitrate membership events). - Admin-console producer (private repo, no paths cited here): server-side organization/subscription events through the management `push-audit-events`, browser-side UI events through the public `push-audit-events`. - Consumers: `backend/src/app/loggers/webhooks.clj`, `backend/src/app/loggers/database.clj`, `backend/src/app/loggers/mattermost.clj`, `backend/src/app/tasks/telemetry.clj`. diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index d7468eaa38..83423e771a 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -482,9 +482,9 @@ (bfm/apply-pending-migrations! cfg) ;; Knowing that the ids of the created files are in index, - ;; just lookup them and return it as a set + ;; just lookup them and return them in the same shape as v3. (let [files (-> bfc/*state* deref :files)] - (into #{} (keep #(get-in @bfc/*state* [:index %])) files)))))) + {:file-ids (into [] (keep #(get-in @bfc/*state* [:index %])) files)}))))) (defmethod read-import :v1 [options] diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 085d402faf..a25e765ba5 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -234,7 +234,9 @@ (defn- append-audit-entry [cfg params] (let [params (-> params - (assoc :id (uuid/next)) + ;; Keep a pre-assigned id (process-event assigns one so + ;; the webhook task can reference the stored row). + (update :id #(or % (uuid/next))) (update :props db/tjson) (update :context db/tjson) (update :ip-addr db/inet)) @@ -253,62 +255,70 @@ (defn- process-event [cfg event] - (when (contains? cf/flags :audit-log-logger) - (l/log! ::l/logger "app.audit" - ::l/level :info - :profile-id (str (:profile-id event)) - :ip-addr (str (:ip-addr event)) - :type (:type event) - :name (:name event) - :props (json/encode (:props event) :key-fn json/write-camel-key) - :context (json/encode (:context event) :key-fn json/write-camel-key))) + ;; Assign the row id up-front so every sink below shares it: the + ;; stored row and the `:process-webhook-event` task params. The + ;; webhook trigger row links back to its origin event through props + ;; `:event-id`, which used to land nil because the id was generated + ;; inside `append-audit-entry` without reaching the worker params. + (let [event (assoc event :id (or (:id event) (uuid/next)))] + (when (contains? cf/flags :audit-log-logger) + (l/log! ::l/logger "app.audit" + ::l/level :info + :profile-id (str (:profile-id event)) + :ip-addr (str (:ip-addr event)) + :type (:type event) + :name (:name event) + :props (json/encode (:props event) :key-fn json/write-camel-key) + :context (json/encode (:context event) :key-fn json/write-camel-key))) - (when (contains? cf/flags :audit-log) - (append-audit-entry cfg event)) + (when (contains? cf/flags :audit-log) + (append-audit-entry cfg event)) - (when (contains? cf/flags :telemetry) - ;; NOTE: when both audit-log and telemetry are enabled, events are stored - ;; twice: once with full details (above) and once stripped of props and - ;; ip-addr, tagged with source="telemetry" so the telemetry task can - ;; collect and ship them. The profile-id is preserved (UUIDs are already - ;; anonymous random identifiers). Only a safe subset of context fields - ;; is kept: initiator, version, client-version and client-user-agent. - ;; Timestamps are truncated to day precision to avoid leaking exact event - ;; timing. - (let [event (-> event - (filter-telemetry-props) - (filter-telemetry-context) - (update :created-at ct/truncate :days) - (update :tracked-at ct/truncate :days) - (assoc :source "telemetry:backend") - (assoc :ip-addr "0.0.0.0"))] - (append-audit-entry cfg event))) + (when (contains? cf/flags :telemetry) + ;; NOTE: when both audit-log and telemetry are enabled, events are stored + ;; twice: once with full details (above) and once stripped of props and + ;; ip-addr, tagged with source="telemetry" so the telemetry task can + ;; collect and ship them. The profile-id is preserved (UUIDs are already + ;; anonymous random identifiers). Only a safe subset of context fields + ;; is kept: initiator, version, client-version and client-user-agent. + ;; Timestamps are truncated to day precision to avoid leaking exact event + ;; timing. The shadow row gets its own id: reusing the full row id + ;; would collide on the primary key. + (let [event (-> event + (assoc :id (uuid/next)) + (filter-telemetry-props) + (filter-telemetry-context) + (update :created-at ct/truncate :days) + (update :tracked-at ct/truncate :days) + (assoc :source "telemetry:backend") + (assoc :ip-addr "0.0.0.0"))] + (append-audit-entry cfg event))) - (when (and (contains? cf/flags :webhooks) - (::webhooks/event? event)) - (let [batch-key (::webhooks/batch-key event) - batch-timeout (::webhooks/batch-timeout event) - label (dm/str "rpc:" (:name event)) - label (cond - (ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event))) - (string? batch-key) (dm/str label ":" batch-key) - :else label) - dedupe? (boolean (and batch-key batch-timeout))] + (when (and (contains? cf/flags :webhooks) + (::webhooks/event? event)) + (let [batch-key (::webhooks/batch-key event) + batch-timeout (::webhooks/batch-timeout event) + label (dm/str "rpc:" (:name event)) + label (cond + (ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event))) + (string? batch-key) (dm/str label ":" batch-key) + :else label) + dedupe? (boolean (and batch-key batch-timeout))] - (wrk/submit! (-> cfg - (assoc ::wrk/task :process-webhook-event) - (assoc ::wrk/queue :webhooks) - (assoc ::wrk/max-retries 0) - (assoc ::wrk/delay (or batch-timeout 0)) - (assoc ::wrk/dedupe dedupe?) - (assoc ::wrk/label label) - (assoc ::wrk/params (-> event - (d/without-qualified) - (dissoc :source) - (dissoc :context) - (dissoc :ip-addr) - (dissoc :type))))))) - event) + (wrk/submit! (-> cfg + (assoc ::wrk/task :process-webhook-event) + (assoc ::wrk/queue :webhooks) + (assoc ::wrk/max-retries 0) + (assoc ::wrk/delay (or batch-timeout 0)) + (assoc ::wrk/dedupe dedupe?) + (assoc ::wrk/label label) + (assoc ::wrk/params (-> event + (d/without-qualified) + (dissoc :source) + (dissoc :context) + (dissoc :ip-addr) + (dissoc :type))))))) + event)) (defn submit* "A public API, lower-level than submit, assumes all required fields are filled" @@ -396,6 +406,47 @@ coerced)) +(def ^:private sql:team-id-by-project + "SELECT id FROM team + WHERE id = (SELECT team_id FROM project WHERE id = ?)") + +(def ^:private sql:team-id-by-file + "SELECT t.id FROM team AS t + JOIN project AS p ON (p.team_id = t.id) + JOIN file AS f ON (f.project_id = p.id) + WHERE f.id = ?") + +(def ^:private read-event-names + "Read commands excluded from team-id resolution on top of the `get-*` + prefix: their events carry no consumer state, so the lookup would only + tax the read path." + #{"has-file-libraries"}) + +(defn- read-event? + [event-name] + (or (str/starts-with? event-name "get-") + (contains? read-event-names event-name))) + +(defn- enrich-team-id + "Adds the team id to the event props when it is missing and can be resolved + locally, so consumers do not have to join production tables. Only events that + already carry a project or file id are considered: read commands put the + entity id under `:id`, and resolving those would add a query to every read." + [cfg event-name props] + (if (or (:team-id props) + (read-event? event-name) + (and (nil? (:project-id props)) + (nil? (:file-id props)))) + props + (let [project-id (:project-id props) + file-id (:file-id props) + team-id (or (when (some? project-id) + (:id (db/exec-one! cfg [sql:team-id-by-project project-id]))) + (when (some? file-id) + (:id (db/exec-one! cfg [sql:team-id-by-file file-id]))))] + (cond-> props + (some? team-id) (assoc :team-id team-id))))) + (defn prepare-rpc-event [cfg mdata params result] (let [resultm (meta result) @@ -410,25 +461,32 @@ (::rpc/profile-id params) uuid/zero) + module (get cfg ::rpc/module) + event-name (or (::name resultm) + (let [sname (::sv/name mdata)] + (if (not= module "main") + (str module "-" sname) + sname))) + props (-> (or (::replace-props resultm) (merge params (::props resultm))) (clean-props)) + ;; Resolve the team locally when the event carries a project or file + ;; id, so the consumers do not need to join production tables. + ;; Reads are excluded: their events carry no consumer state. + props (enrich-team-id cfg event-name props) + context (-> (::context resultm) (merge (prepare-context-from-request request)) (assoc :request-id (::rpc/request-id params)) (d/without-nils)) - ip-addr (inet/parse-request request) - module (get cfg ::rpc/module)] + ip-addr (inet/parse-request request)] {:type (or (::type resultm) (::rpc/type cfg)) - :name (or (::name resultm) - (let [sname (::sv/name mdata)] - (if (not= module "main") - (str module "-" sname) - sname))) + :name event-name :profile-id profile-id :ip-addr ip-addr @@ -485,6 +543,33 @@ (d/without-nils))] (submit* cfg event))) +(defn submit-create-event + "Emits a `create-*` audit event for an entity created as a side effect of a + bulk operation (import, duplicate, clone). Those entities get no event from + their own command, so a local projection cannot key them otherwise. The + event is built by hand and does not pass through `prepare-rpc-event`, so its + props are passed as-is minus nils." + [cfg params event-name props] + (when (contains? cf/flags :audit-log) + (submit cfg + (-> (event-from-rpc-params params) + (assoc :name event-name) + (assoc :props (d/without-nils props)))))) + +(def ^:private sql:get-file-event-columns + "SELECT id, name, project_id, created_at, modified_at, revn + FROM file WHERE id = ?;") + +(defn submit-file-created-event + "Emit a `create-file` event for a file born inside a bulk operation, + with the same prop shape as the `create-file` command event. Only + the file id is needed: the light columns are read back (no data + blob), so projections that key on name, revn or timestamps work. + `extra` carries what the row lacks (the team id)." + [cfg params file-id extra] + (when-let [row (db/exec-one! cfg [sql:get-file-event-columns file-id])] + (submit-create-event cfg params "create-file" (merge row extra)))) + (defn insert "Submit audit event to the collector, intended to be used only from command line helpers because this skips all webhooks and telemetry diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index d1cff7f2ab..c8898f90e6 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -665,7 +665,7 @@ ::wrk/tenant (cf/get :tenant)} [::default ::wrk/runner] - {::wrk/parallelism (cf/get ::worker-default-parallelism 1) + {::wrk/parallelism (cf/get :worker-default-parallelism 1) ::wrk/queue :default ::wrk/tenant (cf/get :tenant) ::rds/client (ig/ref ::rds/client) @@ -674,7 +674,7 @@ ::db/pool (ig/ref ::db/pool)} [::webhook ::wrk/runner] - {::wrk/parallelism (cf/get ::worker-webhook-parallelism 1) + {::wrk/parallelism (cf/get :worker-webhook-parallelism 1) ::wrk/queue :webhooks ::wrk/tenant (cf/get :tenant) ::rds/client (ig/ref ::rds/client) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index a558315376..e28485181f 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -426,7 +426,8 @@ (defn- resolve-management-methods [cfg] (let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing) - mods (cond->> (list 'app.rpc.management.exporter) + mods (cond->> (list 'app.rpc.management.exporter + 'app.rpc.management.files) (contains? cf/flags :admin-console) (cons 'app.rpc.management.nitrate))] diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index de0d3faadd..c4fc37cb99 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] @@ -91,7 +91,7 @@ ;; --- Command: import-binfile (defn- import-binfile - [{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id]}] + [{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id] :as params}] (let [team (teams/get-team pool :profile-id profile-id @@ -133,6 +133,14 @@ {:id project-id} {::db/return-keys false}) + ;; The imported files get no event from their own command, so emit one per + ;; file inside a single transaction. + (db/tx-run! cfg + (fn [cfg] + (doseq [file-id (:file-ids result)] + (audit/submit-file-created-event cfg params file-id + {:team-id (:id team)})))) + result)) (def ^:private schema:import-binfile diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj index 9de796a72e..8ca0a165bc 100644 --- a/backend/src/app/rpc/commands/comments.clj +++ b/backend/src/app/rpc/commands/comments.clj @@ -486,7 +486,7 @@ (let [params (assoc params ::file file) thread (db/tx-run! cfg create-comment-thread params)] - (vary-meta thread assoc ::audit/props thread)))) + (vary-meta thread assoc ::audit/props (assoc thread :team-id team-id))))) (defn- create-comment-thread [{:keys [::db/conn] :as cfg} @@ -650,7 +650,7 @@ (send-comment-emails! conn profile comment thread file) - (vary-meta comment assoc ::audit/props comment)))) + (vary-meta comment assoc ::audit/props (assoc comment :team-id team-id))))) ;; --- COMMAND: Update Comment diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index fd313f6170..1679e76204 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1098,7 +1098,9 @@ {::audit/props {:project-id (:project-id file) :name (:name file) :created-at (:created-at file) - :modified-at (:modified-at file)}}))) + :modified-at (:modified-at file) + :deleted-at (:deleted-at file) + :team-id (:id team)}}))) (sv/defmethod ::delete-file {::doc/added "1.17" diff --git a/backend/src/app/rpc/commands/files_create.clj b/backend/src/app/rpc/commands/files_create.clj index d1b14283a5..23959989af 100644 --- a/backend/src/app/rpc/commands/files_create.clj +++ b/backend/src/app/rpc/commands/files_create.clj @@ -137,5 +137,11 @@ {:id team-id} {::db/return-keys false})))) - (-> (create-file cfg params) - (vary-meta assoc ::audit/props {:team-id team-id})))) + (let [file (create-file cfg params)] + (vary-meta file assoc ::audit/props {:id (:id file) + :project-id project-id + :team-id team-id + :name (:name file) + :created-at (:created-at file) + :modified-at (:modified-at file) + :revn (:revn file)})))) diff --git a/backend/src/app/rpc/commands/files_share.clj b/backend/src/app/rpc/commands/files_share.clj index 6643554a06..66bb1447ad 100644 --- a/backend/src/app/rpc/commands/files_share.clj +++ b/backend/src/app/rpc/commands/files_share.clj @@ -12,9 +12,11 @@ [app.common.schema :as sm] [app.common.uuid :as uuid] [app.db :as db] + [app.loggers.audit :as-alias audit] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] [app.rpc.doc :as-alias doc] + [app.rpc.helpers :as rph] [app.util.services :as sv])) ;; --- MUTATION: Create Share Link @@ -39,7 +41,11 @@ ::db/transaction true} [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id] :as params}] (files/check-edition-permissions! conn profile-id file-id) - (create-share-link conn (assoc params :profile-id profile-id))) + (let [slink (create-share-link conn (assoc params :profile-id profile-id))] + ;; The consumers key share scope by file, so the event carries + ;; both ids; `:file-id` is already in params, `:share-id` is new. + (rph/with-meta slink + {::audit/props {:share-id (:id slink)}}))) (defn create-share-link [conn {:keys [profile-id file-id pages who-comment who-inspect]}] @@ -80,4 +86,8 @@ :hint "You can only delete share-links you created"))) (db/delete! conn :share-link {:id id}) - nil)) + ;; The delete params carry only the link id, so resolve the file + ;; from the row fetched above before it is gone. + (rph/with-meta (rph/wrap) + {::audit/props {:file-id (:file-id slink) + :share-id id}}))) diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 9d996c6dd4..373a06717f 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -283,11 +283,18 @@ (with-meta {:revn revn :lagged (get-lagged-changes conn params)} {::audit/replace-props - {:id (:id file) - :name (:name file) - :features (:features file) - :project-id (:project-id file) - :team-id (:team-id file)}})))) + {:id (:id file) + :name (:name file) + :features (:features file) + :project-id (:project-id file) + :team-id (:team-id file) + ;; NOTE: `file` carries the post-update revn here (`revn` + ;; above is the pre-update one kept for the response), and + ;; `modified-at` mirrors `persist-file!`: same `::timestamp` + ;; fallback, so both agree to the millisecond when the + ;; request timestamp exists. + :revn (:revn file) + :modified-at (or timestamp (ct/now))}})))) (defn get-file "Get not-decoded file, only decodes the features set." diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index 62d5a88330..b0ac390b57 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -142,12 +142,29 @@ (when (uuid? profile-id) (teams/create-project-role conn profile-id (:id project) :owner)) + ;; The new entities are created by internal calls, so they carry no + ;; event of their own; emit one per entity for a local projection. + (audit/submit-create-event cfg params "create-project" + {:id (:id project) + :team-id (:team-id project) + :name (:name project) + :created-at (:created-at project) + :modified-at (:modified-at project)}) + (doseq [file-id files] (let [params (-> params (dissoc :name) (assoc :file-id file-id) - (assoc :reset-shared-flag false))] - (duplicate-file cfg params))) + (assoc :reset-shared-flag false)) + file (duplicate-file cfg params)] + (audit/submit-create-event cfg params "create-file" + {:id (:id file) + :project-id (:project-id file) + :team-id (:team-id project) + :name (:name file) + :created-at (:created-at file) + :modified-at (:modified-at file) + :revn (:revn file)}))) project)))) @@ -211,7 +228,7 @@ (update :team-id bfc/lookup-index) (assoc :created-at timestamp) (assoc :modified-at timestamp))] - (teams/add-profile-to-team! cfg params {::db/return-keys false}))) + (teams/add-profile-to-team! cfg params))) ;; Duplicate team fonts (doseq [font fonts] @@ -444,14 +461,12 @@ {:id project-id} {::db/return-keys false}) - (let [props (audit/clean-props params)] - (doseq [file-id result] - (let [props (assoc props :id file-id) - event (-> (audit/event-from-rpc-params params) - (assoc :profile-id profile-id) - (assoc :name "create-file") - (assoc :props props))] - (audit/submit cfg event)))))) + (doseq [file-id (:file-ids result)] + (audit/submit-file-created-event + cfg + (assoc params :profile-id profile-id) + file-id + {:team-id (:id team)})))) result)) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index e914ff3302..1c9e07ed01 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -68,27 +68,28 @@ (quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team ::quotes/profile-id profile-id ::quotes/team-id team-id - ::quotes/incr (:size content)})) + ::quotes/incr (:size content)}) - (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - ;; We get the minimal file for proper checking if - ;; file is not already deleted - (let [_ (files/get-minimal-file conn file-id) - mobj (create-file-media-object cfg params)] + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + ;; We get the minimal file for proper checking if + ;; file is not already deleted + (let [_ (files/get-minimal-file conn file-id) + mobj (create-file-media-object cfg params)] - (db/update! conn :file - {:modified-at (ct/now) - :has-media-trimmed false} - {:id file-id} - {::db/return-keys false}) + (db/update! conn :file + {:modified-at (ct/now) + :has-media-trimmed false} + {:id file-id} + {::db/return-keys false}) - (with-meta mobj - {::audit/replace-props - {:name (:name params) - :file-id file-id - :is-local (:is-local params) - :size (:size content) - :mtype (:mtype content)}}))))) + (with-meta mobj + {::audit/replace-props + {:name (:name params) + :file-id file-id + :team-id team-id + :is-local (:is-local params) + :size (:size content) + :mtype (:mtype content)}})))))) (defn- big-enough-for-thumbnail? "Checks if the provided image info is big enough for diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 686884aeed..82d307bb46 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -540,7 +540,8 @@ {::doc/added "1.0" ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id] :as params}] - (let [teams (get-owned-teams conn profile-id) + (let [profile (db/get* conn :profile {:id profile-id} {::db/remove-deleted false}) + teams (get-owned-teams conn profile-id) deleted-at (ct/now)] ;; If we found owned teams with participants, we don't allow @@ -581,6 +582,12 @@ (session/invalidate-all cfg profile-id) (-> (rph/wrap nil) + (rph/with-meta {::audit/props {:id (:id profile) + :email (:email profile) + :fullname (:fullname profile) + :created-at (:created-at profile) + :modified-at (:modified-at profile) + :deleted-at deleted-at}}) (rph/with-transform (session/delete-fn cfg))))) (def sql:get-subscription-editors diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index 157b44310a..5c809cfd96 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -222,8 +222,9 @@ ::quotes/profile-id profile-id ::quotes/team-id team-id}) - (let [params (assoc params :profile-id profile-id)] - (db/tx-run! cfg create-project params))) + (let [params (assoc params :profile-id profile-id) + project (db/tx-run! cfg create-project params)] + (rph/with-meta project {::audit/props {:id (:id project)}}))) ;; --- MUTATION: Toggle Project Pin diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index a878018511..0cfb0f2baf 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -28,6 +28,7 @@ [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] + [app.rpc.helpers :as rph] [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.setup :as-alias setup] @@ -563,7 +564,9 @@ team (db/tx-run! cfg create-team params)] (with-meta team - {::audit/props {:id (:id team)}}))) + {::audit/props {:id (:id team) + :created-at (:created-at team) + :modified-at (:modified-at team)}}))) (defn create-default-organization-team @@ -618,10 +621,42 @@ :default-team-id default-team-id})) default-team-id)))))))) +(defn- params->role + "Derive the team role from membership flags, inverse of + `types.team/permissions-for-role`. `team_profile_rel` carries no role + column, so no caller can pass it in `params`: derive it here so the event + always carries one." + [{:keys [is-owner is-admin can-edit]}] + (cond is-owner :owner is-admin :admin can-edit :editor :else :viewer)) + +(defn- submit-membership-event + "Emits an audit event for a new team membership, so the event stream carries + every membership add, including the ones that only happen as a side effect of + another command (team creation, invitation accepted without email roundtrip, + duplicated team). `event-opts` carries what the row lacks: `:actor-id` (the + action owner, defaulting to the member) and `:rpc-params` (request context, + which sibling events get from `prepare-rpc-event`)." + [cfg {:keys [profile-id team-id] :as params} {:keys [actor-id rpc-params]}] + (when (contains? cf/flags :audit-log) + (audit/submit cfg + (-> (audit/event-from-rpc-params (or rpc-params {})) + (assoc :name "add-team-member" + :type "action" + :profile-id (or actor-id profile-id)) + (assoc :props (d/without-nils + {:team-id team-id + :member-id profile-id + :role (params->role params) + :is-owner (boolean (:is-owner params)) + :is-admin (boolean (:is-admin params)) + :can-edit (boolean (:can-edit params))})))))) + (defn add-profile-to-team! ([cfg params] - (add-profile-to-team! cfg params nil)) - ([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options] + (add-profile-to-team! cfg params nil nil)) + ([cfg params options] + (add-profile-to-team! cfg params options nil)) + ([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options event-opts] (assert (db/connection-map? cfg) "expected cfg with valid connection") (when (contains? cf/flags :admin-console) @@ -631,7 +666,13 @@ (some? (:organization-id membership)) ;; the team do belong to an organization (not (:is-member membership))) ;; the user is not a member of the organization yet (initialize-user-in-organization cfg profile-id (:organization-id membership))))) - (db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options))) + (let [options (merge options {::db/return-keys [:id]}) + result (db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options)] + ;; Only emit when a row was actually inserted: with `on-conflict-do-nothing` + ;; an existing membership returns nil and must not be reported twice. + (when (some? result) + (submit-membership-event cfg params event-opts)) + result))) (defn create-team "This is a complete team creation process, it creates the team @@ -894,8 +935,14 @@ ::sm/params schema:delete-team ::db/transaction true} [cfg {:keys [::rpc/profile-id id] :as params}] - (delete-team cfg {:team-id id :profile-id profile-id}) - nil) + (let [team (delete-team cfg {:team-id id :profile-id profile-id})] + ;; The client keeps receiving nil; the audit event carries the deleted row. + (rph/with-meta (rph/wrap) + {::audit/props {:id (:id team) + :name (:name team) + :created-at (:created-at team) + :modified-at (:modified-at team) + :deleted-at (:deleted-at team)}}))) ;; --- Mutation: Team Update Role @@ -962,7 +1009,13 @@ {::doc/added "1.17" ::sm/params schema:update-team-member-role} [cfg {:keys [::rpc/profile-id] :as params}] - (db/tx-run! cfg update-team-member-role (assoc params :profile-id profile-id))) + (db/tx-run! cfg update-team-member-role (assoc params :profile-id profile-id)) + ;; The client keeps receiving nil; when ownership moves, the audit event + ;; names the demoted member so a projection can keep a single owner. + (rph/with-meta (rph/wrap) + (if (= :owner (:role params)) + {::audit/props {:prev-owner-id profile-id}} + {}))) ;; --- Mutation: Delete Team Member diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index fe8abd5b24..66ac06493d 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -202,7 +202,10 @@ (when (contains? cf/flags :admin-console) (teams/initialize-user-in-organization cfg (:id member) (:id organization) email)) ;; Insert the invited member to the team - (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})) + (teams/add-profile-to-team! cfg params + {::db/on-conflict-do-nothing? true} + {:actor-id (:id profile) + :rpc-params params})) ;; If profile is not yet verified, mark it as verified because ;; accepting an invitation link serves as verification. @@ -318,7 +321,7 @@ :role :editor)))) (defn- add-member-to-team - [{:keys [::db/conn] :as cfg} profile team role member] + [{:keys [::db/conn] :as cfg} profile team role member event-opts] (assert (db/connection-map? cfg) "expected cfg with valid connection") @@ -340,7 +343,9 @@ ::quotes/team-id team-id}) ;; Insert the member to the team - (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}) + (teams/add-profile-to-team! cfg params + {::db/on-conflict-do-nothing? true} + event-opts) ;; Delete any request (db/delete! conn :team-access-request @@ -438,7 +443,9 @@ (filter #(contains? invitation-emails (key %))) (map (fn [[email member]] (let [role (:role (first (filter #(= (:email %) email) invitation-data)))] - (add-member-to-team cfg profile team role member)))) + (add-member-to-team cfg profile team role member + {:actor-id (:id profile) + :rpc-params params})))) (doall)) invitations)) @@ -593,7 +600,7 @@ :hint "the maximum of invitation on single request is reached" :threshold max-invitations-by-request-threshold)) - (let [props {:name name :features features} + (let [props {:id (:id team) :name name :features features} event (-> (audit/event-from-rpc-params params) (assoc :name "create-team") (assoc :props props))] @@ -609,7 +616,8 @@ (map (fn [email] (assoc params :email email))) (map (partial create-invitation cfg)))] - (vary-meta team assoc ::audit/props {:invitations (count invitations)})))) + (vary-meta team assoc ::audit/props {:id (:id team) + :invitations (count invitations)})))) ;; --- Query: get-team-invitation-token diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 2476128e7c..f22a693fd2 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -14,10 +14,12 @@ [app.common.uuid :as uuid] [app.db :as db] [app.http.client :as http] + [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as webhooks] [app.rpc :as-alias rpc] [app.rpc.commands.teams :refer [check-read-permissions!] :as t] [app.rpc.doc :as-alias doc] + [app.rpc.helpers :as rph] [app.rpc.permissions :as perms] [app.util.services :as sv] [cuerdas.core :as str])) @@ -137,7 +139,8 @@ (let [whook (-> (db/get pool :webhook {:id id}) (decode-row))] (check-webhook-edition-permissions! pool profile-id (:team-id whook)) (validate-webhook! cfg whook params) - (update-webhook! cfg whook params))) + (rph/with-meta (update-webhook! cfg whook params) + {::audit/props {:team-id (:team-id whook)}}))) (def ^:private schema:delete-webhook [:map {:title "delete-webhook"} @@ -151,7 +154,8 @@ (let [whook (-> (db/get conn :webhook {:id id}) decode-row)] (check-webhook-edition-permissions! conn profile-id (:team-id whook)) (db/delete! conn :webhook {:id id}) - nil)) + (rph/with-meta (rph/wrap) + {::audit/props {:team-id (:team-id whook)}}))) ;; --- Query: Webhooks diff --git a/backend/src/app/rpc/management/files.clj b/backend/src/app/rpc/management/files.clj new file mode 100644 index 0000000000..d947ec7549 --- /dev/null +++ b/backend/src/app/rpc/management/files.clj @@ -0,0 +1,238 @@ +;; 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.rpc.management.files + "Read-only file content for the nexus file-security pipeline. + + The single method `get-file-for-analysis` returns the semantic + projection of one file (pages, frames, text, links, interactions): + small, neutral data the decision model consumes. Full file binaries + never cross this boundary. Every call leaves an audit event like any + other management call, so content reads stay traceable; the volume + scales with analyzed files, and the nexus consumer ignores the event." + (:require + [app.binfile.common :as bfc] + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.types.text :as txt] + [app.db :as db] + [app.rpc :as-alias rpc] + [app.rpc.doc :as doc] + [app.util.services :as sv] + [cuerdas.core :as str])) + +(def projection-version 1) + +;; Bounds for the data that crosses to nexus on every sweep. The +;; projection stays small by construction; anything past the caps sets +;; `:truncated?` instead of growing the payload. +(def ^:const max-text-chars 30000) +(def ^:const max-links 1000) +(def ^:const max-interactions 2000) + +(defn- page-shapes + "All shapes of one page with their page id attached." + [{:keys [id objects]}] + (let [page-id (str id)] + (->> (vals objects) + (filter map?) + (map #(assoc % :page-id page-id))))) + +(defn- shape-text + "Plain text of a text shape, or nil when it has none. Never throws: + corrupt content skips the shape instead of failing the file." + [shape] + (when (and (= :text (:type shape)) + (some? (:content shape))) + (let [text (ex/ignoring (txt/content->text (:content shape)))] + (when-not (str/blank? text) + text)))) + +(defn- open-url? + [{:keys [action-type url]}] + (and (= :open-url action-type) + (not (str/blank? url)))) + +(defn file->projection + "Turn decoded file data into the neutral projection the decision + model consumes. Pure: takes the `:data` map, returns plain data + with all ids as strings." + [{:keys [pages pages-index]}] + (let [shapes (mapcat page-shapes + (keep #(get pages-index %) pages)) + frames (into [] + (comp (filter #(= :frame (:type %))) + (map (fn [{:keys [id name page-id]}] + (d/without-nils + {:id (str id) + :page-id page-id + :name name})))) + shapes) + texts (into [] + (comp (map (juxt :id :page-id shape-text)) + (remove #(nil? (nth % 2))) + (map (fn [[id page-id text]] + {:shape-id (str id) + :page-id page-id + :text text}))) + shapes) + ;; Texts are capped by total characters, the unit the model + ;; bills; links and interactions by count. + texts (loop [acc [] + chars 0 + [item & rest] texts] + (cond + (nil? item) + {:items acc :truncated? false} + + (> (+ chars (count (:text item))) max-text-chars) + {:items acc :truncated? true} + + :else + (recur (conj acc item) + (+ chars (count (:text item))) + rest))) + inter (into [] + (comp (mapcat (fn [shape] + (->> (:interactions shape) + (filter map?) + (filter :action-type) + (filter :event-type) + (map #(assoc % :shape-id (:id shape) + :page-id (:page-id shape)))))) + (map (fn [{:keys [shape-id page-id event-type action-type destination url]}] + (d/without-nils + {:shape-id (str shape-id) + :page-id page-id + :event (d/name event-type) + :action (d/name action-type) + :target (or url (some-> destination str))})))) + shapes) + links (into [] + (comp (mapcat (fn [shape] + (->> (:interactions shape) + (filter map?) + (filter open-url?) + (map #(assoc % :shape-id (:id shape) + :page-id (:page-id shape)))))) + (map (fn [{:keys [shape-id page-id url]}] + {:shape-id (str shape-id) + :page-id page-id + :url url}))) + shapes)] + {:version projection-version + :pages (mapv (fn [page-id] + (let [page (get pages-index page-id)] + (d/without-nils + {:id (str page-id) + :name (:name page)}))) + pages) + :frames frames + :texts texts + :links (if (> (count links) max-links) + {:items (subvec links 0 max-links) :truncated? true} + {:items links :truncated? false}) + :interactions (if (> (count inter) max-interactions) + {:items (subvec inter 0 max-interactions) :truncated? true} + {:items inter :truncated? false})})) + +(def ^:private sql:has-share-link + "SELECT EXISTS (SELECT 1 FROM share_link WHERE file_id = ?) AS shared;") + +(def ^:private sql:get-team-id + "SELECT team_id FROM project WHERE id = ?;") + +(def ^:private + schema:get-file-for-analysis-params + [:map {:title "get-file-for-analysis-params"} + [:file-id ::sm/uuid]]) + +(def ^:private schema:projection-page + [:map + [:id :string] + [:name {:optional true} :string]]) + +(def ^:private schema:projection-frame + [:map + [:id :string] + [:page-id :string] + [:name {:optional true} :string]]) + +(def ^:private schema:projection-text + [:map + [:shape-id :string] + [:page-id :string] + [:text :string]]) + +(def ^:private schema:projection-link + [:map + [:shape-id :string] + [:page-id :string] + [:url :string]]) + +(def ^:private schema:projection-interaction + [:map + [:shape-id :string] + [:page-id :string] + [:event :string] + [:action :string] + [:target {:optional true} :string]]) + +(def ^:private schema:projection + [:map {:title "file-projection"} + [:version ::sm/int] + [:pages [:vector schema:projection-page]] + [:frames [:vector schema:projection-frame]] + [:texts [:map + [:items [:vector schema:projection-text]] + [:truncated? :boolean]]] + [:links [:map + [:items [:vector schema:projection-link]] + [:truncated? :boolean]]] + [:interactions [:map + [:items [:vector schema:projection-interaction]] + [:truncated? :boolean]]]]) + +(def ^:private + schema:get-file-for-analysis-result + [:map {:title "get-file-for-analysis-result"} + [:id ::sm/uuid] + [:revn ::sm/int] + [:modified-at ::ct/inst] + [:project-id ::sm/uuid] + [:team-id ::sm/uuid] + [:has-share-link :boolean] + [:projection-version ::sm/int] + [:projection schema:projection]]) + +(sv/defmethod ::get-file-for-analysis + "Get the semantic projection of one file for security analysis. + + Read-only: returns the file identity, revision, share state and the + neutral projection (pages, frames, text, links, interactions). + Called by nexus with the shared-key identity; takes no profile. + Every call leaves an audit event like any other management call." + {::doc/added "2.20" + ::sm/params schema:get-file-for-analysis-params + ::sm/result schema:get-file-for-analysis-result + ::rpc/auth false} + [cfg {:keys [file-id]}] + ;; `realize?` loads the whole object tree eagerly: the projection + ;; walks every shape, so lazy pointers (no `load-fn` bound here) + ;; would fail on first touch. Same choice as the file exporter. + (let [file (bfc/get-file cfg file-id :realize? true) + shared (:shared (db/exec-one! cfg [sql:has-share-link file-id])) + team-id (:team-id (db/exec-one! cfg [sql:get-team-id (:project-id file)]))] + {:id (:id file) + :revn (:revn file) + :modified-at (:modified-at file) + :project-id (:project-id file) + :team-id team-id + :has-share-link (boolean shared) + :projection-version projection-version + :projection (file->projection (:data file))})) diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index b617934c46..8a475e2cae 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -782,7 +782,7 @@ :type "action" :props {:id team-id} :context {:triggered-by "srepl" - :cause "explicit call to delete-profile!"} + :cause "explicit call to delete-team!"} :tracked-at tnow}) (wrk/invoke! (-> sys/system @@ -851,6 +851,7 @@ (audit/insert sys/system {:name "delete-profile" :type "action" + :props {:id profile-id} :context {:triggered-by "srepl" :cause "explicit call to delete-profile!"} :tracked-at tnow}) @@ -1039,4 +1040,4 @@ (let [params (-> rel (assoc :id (uuid/next)) (assoc :team-id (:id team)))] - (teams/add-profile-to-team! cfg params {::db/return-keys false})))))))) + (teams/add-profile-to-team! cfg params)))))))) diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj index fad5b54a36..abdc4bc5f3 100644 --- a/backend/src/app/tasks/delete_object.clj +++ b/backend/src/app/tasks/delete_object.clj @@ -11,6 +11,7 @@ [app.common.time :as ct] [app.db :as db] [app.db.sql :as-alias sql] + [app.loggers.audit :as audit] [app.rpc.commands.files :as files] [app.rpc.commands.profile :as profile] [app.storage :as sto] @@ -18,6 +19,29 @@ (def ^:dynamic *team-deletion* false) +(def ^:dynamic *cascade* + "Accumulates the ids marked deleted by the current cascade execution, + grouped by entity. Bound by the task entry point; nil on direct calls + (e.g. objects-gc, whose purges are announced by their own procs)." + nil) + +(defn- record + [kind id] + (when (some? *cascade*) + (swap! *cascade* update kind conj id))) + +(defn- top-exists? + "The cascade marks blindly, so an unknown top id would report phantom + rows. Every queue submission and SREPL call names a real row; only + check, never change behavior: a missing row just skips the run." + [{:keys [::db/conn]} {:keys [object id]}] + (let [object (when (some? object) (keyword (name object)))] + (if (contains? #{:team :project :file :profile} object) + (some? (db/get* conn object {:id id} + {::db/remove-deleted false + ::sql/columns [:id]})) + true))) + (defmulti delete-object (fn [_ props] (:object props))) @@ -88,7 +112,9 @@ (db/update! conn :file-tagged-object-thumbnail {:deleted-at deleted-at} {:file-id id} - {::db/return-keys false}))) + {::db/return-keys false}) + + (record :files id))) (defmethod delete-object :project [{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}] @@ -105,7 +131,9 @@ {::db/columns [:id :deleted-at]})] (delete-object cfg (assoc file :object :file - :deleted-at deleted-at)))) + :deleted-at deleted-at))) + + (record :projects id)) (defmethod delete-object :team [{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}] @@ -127,7 +155,9 @@ {::db/columns [:id :deleted-at]})] (delete-object cfg (assoc project :object :project - :deleted-at deleted-at))))) + :deleted-at deleted-at)))) + + (record :teams id)) (defmethod delete-object :profile [{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}] @@ -156,4 +186,19 @@ (defmethod ig/init-key ::handler [_ cfg] (fn [{:keys [props] :as task}] - (db/tx-run! cfg delete-object props))) + (db/tx-run! cfg + (fn [cfg] + (when (top-exists? cfg props) + (binding [*cascade* (atom {:teams [] :projects [] :files []})] + (delete-object cfg props) + (let [{:keys [teams projects files]} @*cascade*] + (when (or (seq teams) (seq projects) (seq files)) + (audit/submit cfg {:name "delete-object-with-cascade" + :type "action" + :props {:object (:object props) + :id (:id props) + :deleted-at (:deleted-at props) + :cascade {:team-ids teams + :project-ids projects + :file-ids files}} + :context {:triggered-by "delete-object"}}))))))))) diff --git a/backend/src/app/tasks/objects_gc.clj b/backend/src/app/tasks/objects_gc.clj index 7ddc210291..1d6f842e30 100644 --- a/backend/src/app/tasks/objects_gc.clj +++ b/backend/src/app/tasks/objects_gc.clj @@ -10,8 +10,10 @@ (:require [app.common.logging :as l] [app.common.time :as ct] + [app.config :as cf] [app.db :as db] [app.features.fdata :as fdata] + [app.loggers.audit :as audit] [app.storage :as sto] [app.tasks.delete-object :as dobj] [integrant.core :as ig])) @@ -27,6 +29,22 @@ ;; reduction is running, so the key is read inside the reducing function: ;; buffering the row itself yields nil columns. +(defn- submit-hard-delete-event + "Emits one hard-delete audit event for the batch of rows just purged + by this task, so a projection of the audit stream can drop its local + rows. Each GC section purges in its own chunk transactions, so each + chunk carries only its own entity key (`:team-ids`, `:project-ids` + or `:file-ids`); a single event spanning sections could never share + their transactions. Runs inside the caller transaction, next to the + deletes: if the purge aborts, the event aborts with it." + [cfg ids-key ids] + (when (contains? cf/flags :audit-log) + (audit/submit cfg + {:name "hard-delete-objects" + :type "command" + :props {ids-key (vec ids)} + :context {:triggered-by "objects-gc"}}))) + (def ^:private sql:get-upload-sessions "SELECT us.id FROM upload_session AS us @@ -179,23 +197,28 @@ [cfg ids] (try (db/tx-run! cfg - (fn [{:keys [::db/conn ::timestamp ::sto/storage]}] + (fn [{:keys [::db/conn ::timestamp ::sto/storage] :as cfg}] (db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"]) - (let [ids (db/create-array conn "uuid" ids)] - (->> (db/exec! conn [sql:lock-teams ids timestamp]) - (reduce (fn [total {:keys [id photo-id deleted-at]}] - (l/trc :obj "team" - :id (str id) - :deleted-at (ct/format-inst deleted-at)) + (let [ids (db/create-array conn "uuid" ids) + [total deleted-ids] + (->> (db/exec! conn [sql:lock-teams ids timestamp]) + (reduce (fn [[total deleted-ids] {:keys [id photo-id deleted-at]}] + (l/trc :obj "team" + :id (str id) + :deleted-at (ct/format-inst deleted-at)) - ;; Mark as deleted the storage object - (some->> photo-id (sto/touch-object! storage)) + ;; Mark as deleted the storage object + (some->> photo-id (sto/touch-object! storage)) - ;; And finally, permanently delete the team. - (let [affected (-> (db/delete! conn :team {:id id}) - (db/get-update-count))] - (+ total affected))) - 0))))) + ;; And finally, permanently delete the team. + (let [affected (-> (db/delete! conn :team {:id id}) + (db/get-update-count))] + [(+ total affected) + (cond-> deleted-ids (pos? affected) (conj id))])) + [0 []]))] + (when (seq deleted-ids) + (submit-hard-delete-event cfg :team-ids deleted-ids)) + total))) (catch Throwable cause (l/err :obj "team" :hint "error on chunk deletion, skipping it" @@ -287,20 +310,25 @@ [cfg ids] (try (db/tx-run! cfg - (fn [{:keys [::db/conn ::timestamp]}] + (fn [{:keys [::db/conn ::timestamp] :as cfg}] (db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"]) - (let [ids (db/create-array conn "uuid" ids)] - (->> (db/exec! conn [sql:lock-projects ids timestamp]) - (reduce (fn [total {:keys [id team-id deleted-at]}] - (l/trc :obj "project" - :id (str id) - :team-id (str team-id) - :deleted-at (ct/format-inst deleted-at)) + (let [ids (db/create-array conn "uuid" ids) + [total deleted-ids] + (->> (db/exec! conn [sql:lock-projects ids timestamp]) + (reduce (fn [[total deleted-ids] {:keys [id team-id deleted-at]}] + (l/trc :obj "project" + :id (str id) + :team-id (str team-id) + :deleted-at (ct/format-inst deleted-at)) - (let [affected (-> (db/delete! conn :project {:id id}) - (db/get-update-count))] - (+ total affected))) - 0))))) + (let [affected (-> (db/delete! conn :project {:id id}) + (db/get-update-count))] + [(+ total affected) + (cond-> deleted-ids (pos? affected) (conj id))])) + [0 []]))] + (when (seq deleted-ids) + (submit-hard-delete-event cfg :project-ids deleted-ids)) + total))) (catch Throwable cause (l/err :obj "project" :hint "error on chunk deletion, skipping it" @@ -336,20 +364,25 @@ [cfg ids] (try (db/tx-run! cfg - (fn [{:keys [::db/conn ::timestamp]}] + (fn [{:keys [::db/conn ::timestamp] :as cfg}] (db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"]) - (let [ids (db/create-array conn "uuid" ids)] - (->> (db/exec! conn [sql:lock-files ids timestamp]) - (reduce (fn [total {:keys [id deleted-at project-id]}] - (l/trc :obj "file" - :id (str id) - :project-id (str project-id) - :deleted-at (ct/format-inst deleted-at)) + (let [ids (db/create-array conn "uuid" ids) + [total deleted-ids] + (->> (db/exec! conn [sql:lock-files ids timestamp]) + (reduce (fn [[total deleted-ids] {:keys [id deleted-at project-id]}] + (l/trc :obj "file" + :id (str id) + :project-id (str project-id) + :deleted-at (ct/format-inst deleted-at)) - (let [affected (-> (db/delete! conn :file {:id id}) - (db/get-update-count))] - (+ total affected))) - 0))))) + (let [affected (-> (db/delete! conn :file {:id id}) + (db/get-update-count))] + [(+ total affected) + (cond-> deleted-ids (pos? affected) (conj id))])) + [0 []]))] + (when (seq deleted-ids) + (submit-hard-delete-event cfg :file-ids deleted-ids)) + total))) (catch Throwable cause (l/err :obj "file" :hint "error on chunk deletion, skipping it" diff --git a/backend/test/backend_tests/binfile_svg_test.clj b/backend/test/backend_tests/binfile_svg_test.clj index a12d30e895..92fe3f2a0e 100644 --- a/backend/test/backend_tests/binfile_svg_test.clj +++ b/backend/test/backend_tests/binfile_svg_test.clj @@ -374,13 +374,14 @@ of its single media object." [profile file] (let [bundle (export-v1 profile file)] - (let [result (-> th/*system* - (assoc ::bfc/project-id (:default-project-id profile)) - (assoc ::bfc/profile-id (:id profile)) - (assoc ::bfc/input (io/input-stream bundle)) - (v1/import-files!))] - (t/is (= 1 (count result))) - (let [rows (th/db-query :file-media-object {:file-id (first result)}) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input (io/input-stream bundle)) + (v1/import-files!)) + file-id (first (:file-ids result))] + (t/is (= 1 (count (:file-ids result)))) + (let [rows (th/db-query :file-media-object {:file-id file-id}) storage (:app.storage/storage th/*system*)] (t/is (= 1 (count rows))) (let [object (sto/get-object storage (:media-id (first rows)))] diff --git a/backend/test/backend_tests/delete_object_test.clj b/backend/test/backend_tests/delete_object_test.clj new file mode 100644 index 0000000000..878bea305d --- /dev/null +++ b/backend/test/backend_tests/delete_object_test.clj @@ -0,0 +1,107 @@ +;; 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.delete-object-test + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [backend-tests.helpers :as th] + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- submitted-events + [audit-mock] + (->> (:call-args-list @audit-mock) + (map second))) + +(defn- cascade-events + [audit-mock] + (filter #(= "delete-object-with-cascade" (:name %)) + (submitted-events audit-mock))) + +(t/deftest delete-object-team-cascade-emits-event + ;; The cascade marks the team, its projects and their files; the event + ;; carries every affected id so a projection can follow without reading + ;; production tables. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 91 {:profile-id (:id profile)}) + project (th/create-project* 91 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 91 {:profile-id (:id profile) + :project-id (:id project)}) + deleted-at (ct/now)] + (th/run-task! :delete-object {:object :team + :id (:id team) + :deleted-at deleted-at}) + (t/is (some? (:deleted-at (th/db-get :team {:id (:id team)} + {::db/remove-deleted false})))) + (t/is (some? (:deleted-at (th/db-get :project {:id (:id project)} + {::db/remove-deleted false})))) + (t/is (some? (:deleted-at (th/db-get :file {:id (:id file)} + {::db/remove-deleted false})))) + (let [events (cascade-events audit-mock)] + (t/is (= 1 (count events))) + (t/is (= "action" (:type (first events)))) + (let [props (:props (first events))] + (t/is (= "team" (name (:object props)))) + (t/is (= (:id team) (:id props))) + (t/is (= deleted-at (:deleted-at props))) + (t/is (not (contains? props :team-ids))) + (t/is (not (contains? props :project-ids))) + (t/is (not (contains? props :file-ids))) + (t/is (= #{(:id team)} (set (get-in props [:cascade :team-ids])))) + (t/is (= #{(:default-project-id team) (:id project)} + (set (get-in props [:cascade :project-ids])))) + (t/is (= #{(:id file)} (set (get-in props [:cascade :file-ids])))))))))) + +(t/deftest delete-object-file-cascade-emits-event + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 92 {:profile-id (:id profile)}) + project (th/create-project* 92 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 92 {:profile-id (:id profile) + :project-id (:id project)}) + deleted-at (ct/now)] + (th/run-task! :delete-object {:object :file + :id (:id file) + :deleted-at deleted-at}) + (let [events (cascade-events audit-mock)] + (t/is (= 1 (count events))) + (t/is (= "action" (:type (first events)))) + (let [props (:props (first events))] + (t/is (= "file" (name (:object props)))) + (t/is (= (:id file) (:id props))) + (t/is (= [] (get-in props [:cascade :team-ids]))) + (t/is (= [] (get-in props [:cascade :project-ids]))) + (t/is (= [(:id file)] (get-in props [:cascade :file-ids]))))))))) + +(t/deftest delete-object-missing-row-emits-nothing + ;; A cascade over an unknown id marks nothing, so there is nothing to report. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (th/run-task! :delete-object {:object :team + :id (uuid/next) + :deleted-at (ct/now)}) + (t/is (empty? (:call-args-list @audit-mock)))))) + +(t/deftest delete-object-snapshot-emits-nothing + ;; Snapshots have no projection table; their cascade stays silent. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (th/run-task! :delete-object {:object :snapshot + :id (uuid/next) + :file-id (uuid/next) + :deleted-at (ct/now)}) + (t/is (empty? (:call-args-list @audit-mock)))))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 2718e48615..0335369709 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -483,6 +483,20 @@ (let [params (prepare-rpc-params data)] (try-on! (method-fn params))))) +(defn command-raw! + "Like `command!`, but returns the raw result without unwrapping it, so a test + can read the result metadata (for example the audit props a command sets on a + nil result). The HTTP layer unwraps it the same way in production." + [{:keys [::type] :as data}] + (let [[_ method-fn] (get-in *system* [:app.rpc/methods type])] + (when-not method-fn + (ex/raise :type :assertion + :code :rpc-method-not-found + :hint (str/ffmt "rpc method '%' not found" (name type)))) + + (let [params (prepare-rpc-params data)] + (method-fn params)))) + (defn management-command! [{:keys [::type] :as data}] (let [[_ method-fn] (get-in *system* [:app.rpc/management-methods type])] diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj index ece038fef7..5bad72051b 100644 --- a/backend/test/backend_tests/loggers_webhooks_test.clj +++ b/backend/test/backend_tests/loggers_webhooks_test.clj @@ -6,9 +6,12 @@ (ns backend-tests.loggers-webhooks-test (:require + [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.http :as http] + [app.loggers.audit :as audit] [app.storage :as sto] [backend-tests.helpers :as th] [clojure.test :as t] @@ -17,6 +20,12 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +(defn decode-row + [{:keys [props context] :as row}] + (cond-> row + (db/pgobject? props) (assoc :props (db/decode-transit-pgobject props)) + (db/pgobject? context) (assoc :context (db/decode-transit-pgobject context)))) + (t/deftest process-event-handler-with-no-webhooks (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] (let [prof (th/create-profile* 1 {:is-active true}) @@ -40,6 +49,37 @@ (t/is (= 1 (:call-count @submit-mock))) (t/is (nil? res))))) +(t/deftest process-event-handler-persists-origin-event-id + ;; End-to-end regression test for the `webhook/trigger` row: its props + ;; `:event-id` must hold the origin event row id. It used to be nil + ;; because `process-event` never forwarded the id to the worker task. + (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (with-redefs [cf/flags #{:audit-log :webhooks}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:type "action" + :name "delete-project" + :profile-id (:id prof) + :props {:team-id (:default-team-id prof)} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend" + :app.loggers.webhooks/event? true}] + (audit/submit* th/*system* event) + (let [params (-> @submit-mock :call-args-list ffirst :app.worker/params)] + (t/is (some? (:id params))) + ;; Run the handler the way the worker would. + (th/run-task! :process-webhook-event params) + (let [[origin trigger :as rows] (->> (th/db-exec! ["select * from audit_log order by created_at"]) + (mapv decode-row))] + (t/is (= 2 (count rows))) + (t/is (= "delete-project" (:name origin))) + (t/is (= "webhook" (:name trigger))) + (t/is (= "trigger" (:type trigger))) + (t/is (= "delete-project" (get-in trigger [:props :name]))) + (t/is (= (:id origin) (get-in trigger [:props :event-id]))) + (t/is (= 0 (get-in trigger [:props :total-affected]))))))))) + (t/deftest run-webhook-handler-1 (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [prof (th/create-profile* 1 {:is-active true}) diff --git a/backend/test/backend_tests/objects_gc_test.clj b/backend/test/backend_tests/objects_gc_test.clj index f02a437f4f..5a3052901f 100644 --- a/backend/test/backend_tests/objects_gc_test.clj +++ b/backend/test/backend_tests/objects_gc_test.clj @@ -8,11 +8,14 @@ (:require [app.common.logging :as l] [app.common.time :as ct] + [app.config :as cf] [app.db :as db] + [app.rpc :as-alias rpc] [app.storage :as sto] [app.tasks.objects-gc :as ogc] [backend-tests.helpers :as th] - [clojure.test :as t])) + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -123,3 +126,119 @@ (t/testing "the run finishes" (t/is (pos? (:processed result)))))) + +(defn- submitted-events + [audit-mock] + (->> (:call-args-list @audit-mock) + (map second))) + +(defn- events-named + [audit-mock event-name] + (filter #(= event-name (:name %)) + (submitted-events audit-mock))) + +(t/deftest gc-emits-hard-delete-objects-for-teams + ;; When the GC physically deletes a team, the projection must drop its row, + ;; so the event carries the purged ids. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 71 {:profile-id (:id profile)})] + (th/command! {::th/type :delete-team + ::rpc/profile-id (:id profile) + :id (:id team)}) + ;; Run the queued cascade so the default project is marked deleted too, + ;; then purge everything the way production does. + (th/run-pending-tasks!) + (th/reset-mock! audit-mock) + (th/run-task! :objects-gc {:skip-delay true}) + (t/is (nil? (th/db-get :team {:id (:id team)} {::db/remove-deleted false}))) + (let [events (filter #(contains? (:props %) :team-ids) + (events-named audit-mock "hard-delete-objects"))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "command" (:type event))) + (t/is (= [(:id team)] (get-in event [:props :team-ids]))) + (t/is (= "objects-gc" (get-in event [:context :triggered-by]))))))))) + +(t/deftest gc-emits-hard-delete-objects-for-projects + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 72 {:profile-id (:id profile)}) + project (th/create-project* 72 {:profile-id (:id profile) + :team-id (:id team)})] + (th/command! {::th/type :delete-project + ::rpc/profile-id (:id profile) + :id (:id project)}) + (th/run-pending-tasks!) + (th/reset-mock! audit-mock) + (th/run-task! :objects-gc {:skip-delay true}) + (t/is (nil? (th/db-get :project {:id (:id project)} {::db/remove-deleted false}))) + (let [events (filter #(contains? (:props %) :project-ids) + (events-named audit-mock "hard-delete-objects"))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "command" (:type event))) + (t/is (= [(:id project)] (get-in event [:props :project-ids]))) + (t/is (= "objects-gc" (get-in event [:context :triggered-by]))))))))) + +(t/deftest gc-emits-hard-delete-objects-for-files + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 73 {:profile-id (:id profile)}) + project (th/create-project* 73 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 73 {:profile-id (:id profile) + :project-id (:id project)})] + (th/command! {::th/type :delete-file + ::rpc/profile-id (:id profile) + :id (:id file)}) + (th/run-pending-tasks!) + (th/reset-mock! audit-mock) + (th/run-task! :objects-gc {:skip-delay true}) + (t/is (nil? (th/db-get :file {:id (:id file)} {::db/remove-deleted false}))) + (let [events (filter #(contains? (:props %) :file-ids) + (events-named audit-mock "hard-delete-objects"))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "command" (:type event))) + (t/is (= [(:id file)] (get-in event [:props :file-ids]))) + (t/is (= "objects-gc" (get-in event [:context :triggered-by]))))))))) + +(t/deftest gc-emits-no-event-without-audit-log-flag + ;; The GC must stay silent when the audit log is off, even though the row is + ;; still purged. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 74 {:profile-id (:id profile)})] + (th/command! {::th/type :delete-team + ::rpc/profile-id (:id profile) + :id (:id team)}) + (th/run-pending-tasks!) + (th/reset-mock! audit-mock) + (th/run-task! :objects-gc {:skip-delay true}) + (t/is (nil? (th/db-get :team {:id (:id team)} {::db/remove-deleted false}))) + (t/is (empty? (:call-args-list @audit-mock)))))) + +(t/deftest gc-emits-events-only-for-committed-chunks + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1) + rolled-back (create-deleted-file! profile 1 (ct/in-past "30m")) + blocked (create-deleted-file! profile 2 (ct/in-past "20m")) + clean (create-deleted-file! profile 3 (ct/in-past "10m"))] + (block-file! blocked) + (th/reset-mock! audit-mock) + (let [result (th/run-task! :objects-gc {:chunk-size 2}) + events (filter #(contains? (:props %) :file-ids) + (events-named audit-mock "hard-delete-objects"))] + (t/testing "the failed chunk keeps both files" + (t/is (some? (find-file (:id rolled-back)))) + (t/is (some? (find-file (:id blocked))))) + (t/testing "the next chunk commits and reports only its deleted file" + (t/is (nil? (find-file (:id clean)))) + (t/is (pos? (:processed result))) + (t/is (= 1 (count events))) + (t/is (= [(:id clean)] (get-in (first events) [:props :file-ids]))))))))) diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index 50ee79f2e8..81ca636dbe 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -17,6 +17,7 @@ [app.util.services :as sv] [backend-tests.helpers :as th] [clojure.test :as t] + [mockery.core :refer [with-mocks]] [yetti.request])) (t/use-fixtures :once th/state-init) @@ -329,6 +330,58 @@ (t/is (= day-now (:created-at row2))) (t/is (= day-now (:tracked-at row2)))))))) +(t/deftest backend-process-event-forwards-id-to-webhook-task + ;; The `webhook/trigger` row links back to its origin event through + ;; props `:event-id`, so `process-event` must forward the stored row + ;; id to the `:process-webhook-event` worker task. Regression test: + ;; the id used to be generated inside `append-audit-entry` without + ;; ever reaching the worker params, so `:event-id` always landed nil. + (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (with-redefs [cf/flags #{:audit-log :webhooks}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:type "action" + :name "delete-project" + :profile-id (:id prof) + :props {:team-id (:default-team-id prof)} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend" + :app.loggers.webhooks/event? true}] + (audit/submit* th/*system* event) + (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (= 1 (count rows))) + (t/is (= 1 (:call-count @submit-mock))) + (let [params (-> @submit-mock :call-args-list ffirst :app.worker/params)] + (t/is (some? (:id params))) + (t/is (= (:id row) (:id params))))))))) + +(t/deftest backend-process-event-telemetry-row-gets-own-id + ;; When both `:audit-log` and `:telemetry` are active, the shadow + ;; telemetry row must not reuse the full row id (shared PK), while + ;; the webhook task still carries the full row id. + (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (with-redefs [cf/flags #{:audit-log :telemetry :webhooks}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:type "action" + :name "delete-project" + :profile-id (:id prof) + :props {:team-id (:default-team-id prof)} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend" + :app.loggers.webhooks/event? true}] + (audit/submit* th/*system* event) + (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"]) + (mapv decode-row))] + (t/is (= 2 (count rows))) + (t/is (not= (:id row1) (:id row2))) + (let [params (-> @submit-mock :call-args-list ffirst :app.worker/params)] + (t/is (= (:id (first (filter #(= "backend" (:source %)) rows))) + (:id params))))))))) + (t/deftest push-events-disabled-when-no-flags-and-no-telemetry ;; When neither audit-log nor telemetry is enabled, no rows should ;; be stored. @@ -651,17 +704,22 @@ (defn- prepare-event "Call prepare-rpc-event with a bare request, returning the built event." - [caller result] - (let [mdata {::sv/name "test-cmd"} - params {::rpc/profile-id caller - ::rpc/request-id (uuid/next) - ::rpc/request-at (ct/now)} - mock-req (reify - yetti.request/IRequest - (get-header [_ _] nil) - (remote-addr [_] "127.0.0.1")) - params (with-meta params {:app.http/request mock-req})] - (audit/prepare-rpc-event th/*system* mdata params result))) + ([caller result] + (prepare-event caller "test-cmd" result)) + ([caller cmd-name result] + (let [mdata {::sv/name cmd-name} + params {::rpc/profile-id caller + ::rpc/request-id (uuid/next) + ::rpc/request-at (ct/now)} + mock-req (reify + yetti.request/IRequest + (get-header [_ _] nil) + (remote-addr [_] "127.0.0.1")) + params (with-meta params {:app.http/request mock-req})] + ;; Production always resolves commands with `::module` set ("main" or + ;; "management"): the event name (and the read exclusion) depends on it. + (audit/prepare-rpc-event (assoc th/*system* ::rpc/module "main") + mdata params result)))) (t/deftest prepare-rpc-event-ignores-profile-id-from-result ;; An audit event belongs to the caller, never to whatever `:profile-id` @@ -696,6 +754,76 @@ (t/is (= uuid/zero (:profile-id (prepare-event nil {:some-data "value"}))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PREPARE-RPC-EVENT TEAM-ID ENRICHMENT +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest prepare-rpc-event-enriches-team-id-from-project-id + ;; An event that carries a project id resolves its team locally, so the + ;; downstream consumers do not need to join production tables. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 10 {:profile-id (:id profile)}) + project (th/create-project* 10 {:profile-id (:id profile) + :team-id (:id team)}) + result (with-meta {} {::audit/props {:project-id (:id project)}}) + event (prepare-event (:id profile) result)] + (t/is (= (:id team) (get-in event [:props :team-id]))))) + +(t/deftest prepare-rpc-event-enriches-team-id-from-file-id + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 11 {:profile-id (:id profile)}) + project (th/create-project* 11 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 11 {:profile-id (:id profile) + :project-id (:id project)}) + result (with-meta {} {::audit/props {:file-id (:id file)}}) + event (prepare-event (:id profile) result)] + (t/is (= (:id team) (get-in event [:props :team-id]))))) + +(t/deftest prepare-rpc-event-keeps-existing-team-id + ;; The resolver never overwrites a team id the command already set. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 12 {:profile-id (:id profile)}) + project (th/create-project* 12 {:profile-id (:id profile) + :team-id (:id team)}) + result (with-meta {} {::audit/props {:team-id (:id team) + :project-id (:id project)}}) + event (prepare-event (:id profile) result)] + (t/is (= (:id team) (get-in event [:props :team-id]))))) + +(t/deftest prepare-rpc-event-does-not-resolve-without-project-or-file + ;; Read commands put the entity id under `:id`; enriching those would add a + ;; query to every read event, so the resolver must leave them untouched. + (let [profile (th/create-profile* 1 {:is-active true}) + result (with-meta {} {::audit/props {:id (uuid/next)}}) + event (prepare-event (:id profile) result)] + (t/is (not (contains? (:props event) :team-id))))) + +(t/deftest prepare-rpc-event-resolves-default-team-id + ;; The resolver adds the team id whenever it can resolve it, including the + ;; personal (default) team. + (let [profile (th/create-profile* 1 {:is-active true}) + result (with-meta {} {::audit/props {:project-id (:default-project-id profile)}}) + event (prepare-event (:id profile) result)] + (t/is (= (:default-team-id profile) (get-in event [:props :team-id]))))) + +(t/deftest prepare-rpc-event-skips-team-id-resolution-for-reads + ;; Read events carry no consumer state: resolving their team would add a + ;; query to every read, so `get-*` names (plus the known non-get reads) + ;; are left untouched even when they carry a project or file id. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 13 {:profile-id (:id profile)}) + project (th/create-project* 13 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 13 {:profile-id (:id profile) + :project-id (:id project)})] + (doseq [cmd-name ["get-file-fragment" "get-comments" "has-file-libraries"] + props [{:file-id (:id file)} {:project-id (:id project)}]] + (let [result (with-meta {} {::audit/props props}) + event (prepare-event (:id profile) cmd-name result)] + (t/is (not (contains? (:props event) :team-id)) + (str cmd-name " with " (keys props) " must not resolve team-id")))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PREPARE-RPC-EVENT PROFILE-ID COERCION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index b1a144ab70..ccdcc7fafa 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -6,13 +6,17 @@ (ns backend-tests.rpc-binfile-test (:require + [app.binfile.v3 :as bf.v3] [app.common.schema :as sm] [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] [app.rpc :as-alias rpc] [app.rpc.commands.binfile :as binfile] [backend-tests.helpers :as th] [clojure.test :as t] - [datoteka.fs :as fs])) + [datoteka.fs :as fs] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -72,3 +76,38 @@ ;; Version 4 should be rejected (t/is (false? (validator (assoc base-params :version 4))) "version 4 should be rejected"))) + +(t/deftest import-binfile-audit-events-carry-entity-ids + ;; The imported files are created inside the importer, so the command emits a + ;; create-file event per file for a local projection. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 51 {:profile-id (:id profile)}) + project (th/create-project* 51 {:profile-id (:id profile) + :team-id (:id team)}) + ;; A real row: `submit-file-created-event` reads the light + ;; columns back, so a phantom id would emit no event. + file (th/create-file* 99 {:profile-id (:id profile) + :project-id (:id project)}) + file-id (:id file)] + (with-redefs [bf.v3/import-files! (fn [_cfg] {:file-ids [file-id]})] + (#'binfile/import-binfile + th/*system* + {::rpc/profile-id (:id profile) + :profile-id (:id profile) + :project-id (:id project) + :version 3 + :name "imported" + :file {:filename "package.penpot" + :path (th/tempfile "backend_tests/test_files/svg-attrs-camel-case.penpot") + :mtype "application/zip" + :size 1}})) + + (let [events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "create-file" (:name %))))] + (t/is (= 1 (count events))) + (t/is (= file-id (get-in (first events) [:props :id]))) + (t/is (= (:id project) (get-in (first events) [:props :project-id]))) + (t/is (= (:id team) (get-in (first events) [:props :team-id])))))))) diff --git a/backend/test/backend_tests/rpc_comment_test.clj b/backend/test/backend_tests/rpc_comment_test.clj index ea64723545..0c58687751 100644 --- a/backend/test/backend_tests/rpc_comment_test.clj +++ b/backend/test/backend_tests/rpc_comment_test.clj @@ -478,3 +478,32 @@ :content "member comment" :frame-id uuid/zero})] (t/is (th/success? out)))))) + +(t/deftest comment-audit-events-carry-team-id + ;; Comment events carry the file id, so the resolver could find the team; the + ;; command already has the file with its team id and sets it without a join. + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-teams-per-profile 200})}] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:team-id (:id team) + :profile-id (:id profile)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + page-id (get-in file [:data :pages 0]) + thread (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id profile) + :file-id (:id file) + :page-id page-id + :position (gpt/point 0) + :content "hello" + :frame-id uuid/zero}) + comment (th/command! {::th/type :create-comment + ::rpc/profile-id (:id profile) + :thread-id (get-in thread [:result :id]) + :content "comment"})] + (t/is (= (:id team) + (get-in (meta (:result thread)) [:app.loggers.audit/props :team-id]))) + (t/is (= (:id team) + (get-in (meta (:result comment)) [:app.loggers.audit/props :team-id])))))) diff --git a/backend/test/backend_tests/rpc_file_security_test.clj b/backend/test/backend_tests/rpc_file_security_test.clj new file mode 100644 index 0000000000..15ac56c53d --- /dev/null +++ b/backend/test/backend_tests/rpc_file_security_test.clj @@ -0,0 +1,368 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-file-security-test + "Tests for the nexus file-security backend prerequisite: the + `get-file-for-analysis` management method, the semantic projection, + and the audit prop coverage the `:local` consumer schedules from." + (:require + [app.binfile.common :as bfc] + [app.common.features :as cfeat] + [app.common.time :as ct] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.loggers.audit :as audit] + [app.rpc :as-alias rpc] + [app.rpc.management.files :as mgmt.files] + [app.util.services :as sv] + [backend-tests.helpers :as th] + [clojure.test :as t] + [yetti.request])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;; --- Projection unit tests (pure, no database) + +(def ^:private page-id (uuid/random)) +(def ^:private frame-id (uuid/random)) +(def ^:private text-id (uuid/random)) +(def ^:private link-id (uuid/random)) + +(defn- test-data + [objects] + {:pages [page-id] + :pages-index {page-id {:id page-id + :name "Page 1" + :objects objects}}}) + +(t/deftest projection-empty-file + (let [projection (mgmt.files/file->projection (test-data {}))] + (t/is (= 1 (:version projection))) + (t/is (= [{:id (str page-id) :name "Page 1"}] (:pages projection))) + (t/is (= [] (:frames projection))) + (t/is (= {:items [] :truncated? false} (:texts projection))) + (t/is (= {:items [] :truncated? false} (:links projection))) + (t/is (= {:items [] :truncated? false} (:interactions projection))))) + +(t/deftest projection-picks-frames-text-links-and-interactions + (let [data (test-data + {frame-id {:id frame-id :name "Frame" :type :frame} + text-id {:id text-id :name "Text" :type :text + :content {:type "root" + :children [{:type "paragraph" + :children [{:text "hello"} + {:text " world"}]}]}} + link-id {:id link-id :name "Button" :type :rect + :interactions [{:event-type :click + :action-type :open-url + :url "https://phish.example/login"} + {:event-type :click + :action-type :navigate + :destination page-id} + ;; No event, cannot trigger: + ;; collected as link, never + ;; as interaction. + {:action-type :open-url + :url "https://quiet.example"}]}}) + projection (mgmt.files/file->projection data)] + (t/is (= [{:id (str frame-id) :page-id (str page-id) :name "Frame"}] + (:frames projection))) + (t/is (= {:items [{:shape-id (str text-id) + :page-id (str page-id) + :text "hello world"}] + :truncated? false} + (:texts projection))) + (t/is (= {:items [{:shape-id (str link-id) + :page-id (str page-id) + :url "https://phish.example/login"} + {:shape-id (str link-id) + :page-id (str page-id) + :url "https://quiet.example"}] + :truncated? false} + (:links projection))) + (t/is (= {:items [{:shape-id (str link-id) + :page-id (str page-id) + :event "click" + :action "open-url" + :target "https://phish.example/login"} + {:shape-id (str link-id) + :page-id (str page-id) + :event "click" + :action "navigate" + :target (str page-id)}] + :truncated? false} + (:interactions projection))))) + +(t/deftest projection-skips-blank-text-and-corrupt-content + (let [bad-id (uuid/random) + projection (mgmt.files/file->projection + (test-data + {text-id {:id text-id :type :text + :content {:type "root" + :children [{:type "paragraph" + :children [{:text " "}]}]}} + bad-id {:id bad-id :type :text + :content {:type "root" :children 42}}}))] + ;; Blank text contributes nothing; corrupt content skips the shape + ;; instead of failing the file. + (t/is (= {:items [] :truncated? false} (:texts projection))))) + +(t/deftest projection-truncates-links + (let [objects (into {} + (map (fn [i] + (let [id (uuid/random)] + [id {:id id :type :rect + :interactions [{:event-type :click + :action-type :open-url + :url (str "https://example.com/" i)}]}]))) + (range (inc mgmt.files/max-links))) + projection (mgmt.files/file->projection (test-data objects))] + (t/is (= mgmt.files/max-links (count (get-in projection [:links :items])))) + (t/is (true? (get-in projection [:links :truncated?]))) + (t/is (false? (get-in projection [:interactions :truncated?]))))) + +;; --- Management method (through the worker stack) + +(def text-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "cheap meds"}]}]}]}) + +(defn- create-file! + [profile-id project-id] + (let [out (th/command! {::th/type :create-file + ::rpc/profile-id profile-id + :project-id project-id + :name "Shop" + :is-shared false})] + (t/is (nil? (:error out))) + (get-in out [:result :id]))) + +(defn- file-page-id + [file-id] + (get-in (bfc/get-file th/*system* file-id) [:data :pages 0])) + +(defn- update-file! + [profile-id file-id page-id changes revn] + (let [out (th/command! {::th/type :update-file + ::rpc/profile-id profile-id + :id file-id + :session-id (uuid/random) + :revn revn + :vern 0 + :features cfeat/supported-features + :changes changes})] + (t/is (nil? (:error out))) + (:result out))) + +(defn- add-text-shape-change + [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 "Buy now" + :frame-id uuid/zero + :parent-id uuid/zero + :type :text + :content text-content})}]) + +(t/deftest get-file-for-analysis-returns-projection + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file-id (create-file! (:id profile) (:id project)) + page-id (file-page-id file-id)] + ;; One edit so revn moves past zero and the file has content. + (update-file! (:id profile) file-id page-id + (add-text-shape-change page-id (uuid/random)) + 0) + (let [out (th/management-command! {::th/type :get-file-for-analysis + :file-id file-id})] + (t/is (nil? (:error out))) + (let [result (:result out)] + (t/is (= file-id (:id result))) + (t/is (= 1 (:revn result))) + (t/is (some? (:modified-at result))) + (t/is (= (:id project) (:project-id result))) + (t/is (= (:id team) (:team-id result))) + (t/is (false? (:has-share-link result))) + (t/is (= mgmt.files/projection-version (:projection-version result))) + (t/is (= "cheap meds" + (-> result :projection :texts :items first :text))))) + ;; A share link flips the share state, no re-edit needed. + (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id profile) + :file-id file-id + :pages #{page-id} + :who-comment "all" + :who-inspect "all"}) + (let [out (th/management-command! {::th/type :get-file-for-analysis + :file-id file-id})] + (t/is (nil? (:error out))) + (t/is (true? (:has-share-link (:result out))))))) + +(t/deftest get-file-for-analysis-rejects-unknown-file + (let [out (th/management-command! {::th/type :get-file-for-analysis + :file-id (uuid/random)})] + (t/is (some? (:error out))) + (t/is (= :not-found (th/ex-type (:error out)))))) + +;; --- Audit prop coverage (through the commands, into real events) +;; +;; NOTE: the test system neutralizes `audit/submit`, so no audit row +;; is ever written. The observable contract of a command is the +;; metadata it sets on its result, which `prepare-rpc-event` turns +;; into the stored event: feed one into the other with the real +;; method metadata and assert the final props. + +(def mock-request + (reify + yetti.request/IRequest + (get-header [_ _] nil) + (remote-addr [_] "127.0.0.1"))) + +(defn- event-for + "Run a main RPC command and return the audit event it would emit." + [type params] + (let [[mdata _] (get-in th/*system* [:app.rpc/methods type]) + result (th/command-raw! (assoc params ::th/type type)) + params (-> params + (assoc ::rpc/request-id (uuid/next) + ::rpc/request-at (ct/now)) + (with-meta {:app.http/request mock-request})) + ;; In production the caller assocs the module before + ;; preparing the event; without it every name would come out + ;; with a leading dash. + cfg (assoc th/*system* ::rpc/module "main")] + (audit/prepare-rpc-event cfg mdata params result))) + +(t/deftest get-file-for-analysis-emits-audit-event + ;; Content reads stay traceable: with the skip gone, the call flows + ;; through `wrap-audit` like any other management method. `wrap-audit` + ;; itself is fixed at system init (no audit flags there), so assert the + ;; metadata flip plus the event shape through `prepare-rpc-event` with + ;; the real method metadata. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file-id (create-file! (:id profile) (:id project)) + [mdata _] (get-in th/*system* [:app.rpc/management-methods :get-file-for-analysis])] + (t/is (not (::audit/skip mdata))) + (let [out (th/management-command! {::th/type :get-file-for-analysis + :file-id file-id}) + result (:result out) + params (-> {:file-id file-id} + (assoc ::rpc/request-id (uuid/next) + ::rpc/request-at (ct/now)) + (with-meta {:app.http/request mock-request})) + cfg (assoc th/*system* ::rpc/module "management") + event (audit/prepare-rpc-event cfg mdata params result)] + (t/is (nil? (:error out))) + (t/is (= "management-get-file-for-analysis" (:name event))) + (t/is (= file-id (get-in event [:props :file-id]))) + (t/is (= (:id team) (get-in event [:props :team-id])))))) + +(t/deftest file-events-carry-scheduling-props + (let [profile (th/create-profile* 2 {:is-active true}) + team (th/create-team* 2 {:profile-id (:id profile)}) + project (th/create-project* 2 {:profile-id (:id profile) + :team-id (:id team)}) + base {::rpc/profile-id (:id profile)}] + (t/testing "create-file" + (let [event (event-for :create-file + (assoc base + :project-id (:id project) + :name "Shop" + :is-shared false)) + props (:props event)] + (t/is (= "create-file" (:name event))) + (t/is (some? (:id props))) + (t/is (= "Shop" (:name props))) + (t/is (some? (:created-at props))) + (t/is (some? (:modified-at props))) + (t/is (= 0 (:revn props))))) + (t/testing "update-file" + ;; A file to edit, created through the command so the event + ;; below belongs to a real row. + (let [created (event-for :create-file + (assoc base + :project-id (:id project) + :name "Edit me" + :is-shared false)) + file-id (get-in created [:props :id]) + page-id (file-page-id file-id) + shape-id (uuid/random) + event (event-for :update-file + (assoc base + :id file-id + :session-id (uuid/random) + :revn 0 + :vern 0 + :features cfeat/supported-features + :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})}])) + props (:props event)] + (t/is (= "update-file" (:name event))) + (t/is (= file-id (:id props))) + (t/is (= 1 (:revn props))) + (t/is (some? (:modified-at props))))) + (t/testing "share-link create and delete" + (let [created (event-for :create-file + (assoc base + :project-id (:id project) + :name "Shared" + :is-shared false)) + file-id (get-in created [:props :id]) + page-id (file-page-id file-id) + event (event-for :create-share-link + (assoc base + :file-id file-id + :pages #{page-id} + :who-comment "all" + :who-inspect "all")) + props (:props event) + slink-id (:share-id props)] + (t/is (= "create-share-link" (:name event))) + (t/is (= file-id (:file-id props))) + (t/is (some? slink-id)) + (let [event (event-for :delete-share-link + (assoc base :id slink-id)) + props (:props event)] + (t/is (= "delete-share-link" (:name event))) + (t/is (= file-id (:file-id props))) + (t/is (= slink-id (:share-id props)))))) + (t/testing "delete-file" + (let [created (event-for :create-file + (assoc base + :project-id (:id project) + :name "Doomed" + :is-shared false)) + file-id (get-in created [:props :id]) + event (event-for :delete-file + (assoc base :id file-id)) + props (:props event)] + (t/is (= "delete-file" (:name event))) + (t/is (= file-id (:id props))) + (t/is (some? (:deleted-at props))))))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 2ac2bf8424..aadd976c2d 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2782,3 +2782,36 @@ (t/is (th/ex-info? (:error out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation)))) + +(t/deftest delete-file-audit-event-carries-team-id + ;; The event already carries the project id, so the resolver could find the + ;; team; the command has the team row and sets the id without a lookup. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + wrapped (th/command-raw! {::th/type :delete-file + ::rpc/profile-id (:id profile) + :id (:id file)})] + (t/is (= (:id team) + (get-in (meta wrapped) [:app.loggers.audit/props :team-id]))))) + +(t/deftest create-file-audit-event-carries-its-id + ;; The command returns the new file but did not emit its id, so a consumer + ;; could not key a local projection. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + out (th/command! {::th/type :create-file + ::rpc/profile-id (:id profile) + :project-id (:id project) + :name "foobar" + :is-shared false})] + (t/is (th/success? out)) + (t/is (= (get-in out [:result :id]) + (get-in (meta (:result out)) [:app.loggers.audit/props :id]))) + (t/is (= (:id team) + (get-in (meta (:result out)) [:app.loggers.audit/props :team-id]))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index a754e45b1c..30ff6a3823 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -1916,6 +1916,9 @@ :type "action"}]} params (with-meta params {::http/request http-request}) + ;; The profile setup above emits membership events; drop them so + ;; only the pushed events are counted below. + _ (th/reset-mock! audit-mock) out (th/management-command! params)] (t/is (nil? (:error out))) (t/is (:called? @audit-mock)) @@ -1936,6 +1939,9 @@ :profile-id (:id prof)}]} params (with-meta params {::http/request http-request}) + ;; The profile setup above emits membership events; drop them so + ;; only the pushed events are asserted below. + _ (th/reset-mock! audit-mock) out (th/management-command! params)] (t/is (nil? (:error out))) (let [[_ event] (:call-args @audit-mock)] @@ -1955,6 +1961,9 @@ :type "action"}]} params (with-meta params {::http/request http-request}) + ;; The profile setup above emits membership events; drop them so + ;; only the pushed events are counted below. + _ (th/reset-mock! audit-mock) out (th/management-command! params)] (t/is (nil? (:error out))) (t/is (= 2 (:call-count @audit-mock))) @@ -1974,6 +1983,9 @@ :foo "bar"}}]} params (with-meta params {::http/request http-request}) + ;; The profile setup above emits membership events; drop them so + ;; only the pushed events are asserted below. + _ (th/reset-mock! audit-mock) out (th/management-command! params)] (t/is (nil? (:error out))) (let [[_ event] (:call-args @audit-mock)] @@ -1998,6 +2010,9 @@ params (with-meta params {::http/request (assoc http-request ::http/auth-key-id :admin-console)}) + ;; The profile setup above emits membership events; drop them so + ;; only the pushed events are asserted below. + _ (th/reset-mock! audit-mock) out (th/management-command! params)] (t/is (nil? (:error out))) (let [[_ event] (:call-args @audit-mock)] diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index 4b449af993..e1f6e81950 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -9,6 +9,7 @@ [app.binfile.common :as bfc] [app.common.features :as cfeat] [app.common.pprint :as pp] + [app.common.time :as ct] [app.common.types.shape :as cts] [app.common.uuid :as uuid] [app.config :as cf] @@ -22,7 +23,8 @@ [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] - [datoteka.io :as io])) + [datoteka.io :as io] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -666,3 +668,55 @@ (t/is (vector? result)) (t/is (= 1 (count result))) (t/is (= "test" (:id (first result))))))) + +(t/deftest clone-template-audit-events-carry-team-id + ;; The per-file `create-file` events are built by hand, so they do not pass + ;; through the generic resolver: the command must copy the team id itself. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [prof (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id prof)}) + project (th/create-project* 1 {:profile-id (:id prof) + :team-id (:id team)}) + out (th/command! {::th/type :clone-template + ::rpc/profile-id (:id prof) + :project-id (:id project) + :template-id "test"})] + (t/is (th/success? out)) + (th/consume-sse (:result out)) + (let [events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "create-file" (:name %))))] + (t/is (seq events)) + (t/is (every? #(= (:id team) (get-in % [:props :team-id])) events))))))) + +(t/deftest duplicate-project-audit-events-carry-entity-ids + ;; The duplicated project and files are created by internal calls, so the + ;; command emits one create event per entity for a local projection. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 52 {:profile-id (:id profile)}) + project (th/create-project* 52 {:profile-id (:id profile) + :team-id (:id team)}) + _file (th/create-file* 52 {:profile-id (:id profile) + :project-id (:id project)}) + out (th/command! {::th/type :duplicate-project + ::rpc/profile-id (:id profile) + :project-id (:id project) + :name "copy"}) + events (->> (:call-args-list @audit-mock) + (map second) + (filter #(contains? #{"create-project" "create-file"} (:name %))))] + (t/is (th/success? out)) + (t/is (some #(and (= "create-project" (:name %)) + (= (get-in out [:result :id]) (get-in % [:props :id])) + (= (:id team) (get-in % [:props :team-id])) + (= "copy" (get-in % [:props :name]))) + events)) + (t/is (some #(and (= "create-file" (:name %)) + (string? (get-in % [:props :name])) + (int? (get-in % [:props :revn])) + (ct/inst? (get-in % [:props :modified-at]))) + events)) + (t/is (every? #(= (:id team) (get-in % [:props :team-id])) events)))))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index fb34396a09..985fb68fc2 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -1226,3 +1226,27 @@ (t/is (th/ex-info? (:error out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation)))) + +(t/deftest media-audit-event-carries-team-id + ;; The media event carries the file id, so the resolver could find the team; + ;; the command already resolved the team for the quota check and sets it. + (let [prof (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id prof)}) + project (th/create-project* 1 {:profile-id (:id prof) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:id project) + :is-shared false}) + mfile {:filename "sample.jpg" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + out (th/command! {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content mfile})] + (t/is (th/success? out)) + (t/is (= (:id team) + (get-in (meta (:result out)) [:app.loggers.audit/replace-props :team-id]))))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 1bfb54aeee..6200261e86 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -1802,3 +1802,30 @@ (t/is (th/success? out)) (t/is (= (:id member) event-pid)) (t/is (not= (:id owner) event-pid)))))) + +(t/deftest delete-profile-audit-event-carries-profile-data + ;; The event used to carry empty props because every param is namespaced and + ;; clean-props drops them; the projection needs the deleted identity. + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/command-raw! {::th/type :delete-profile + ::rpc/profile-id (:id profile)}) + props (:app.loggers.audit/props (meta out))] + (t/is (= (:id profile) (:id props))) + (t/is (= "profile1.test@nodomain.com" (:email props))) + (t/is (= "Profile 1" (:fullname props))) + (t/is (ct/inst? (:created-at props))) + (t/is (ct/inst? (:modified-at props))) + (t/is (ct/inst? (:deleted-at props))))) + +(t/deftest delete-profile-is-idempotent + ;; Deleting an already-deleted profile (client retry after a lost response) + ;; succeeds: the row is still there, only marked. + (let [profile (th/create-profile* 1 {:is-active true}) + params {::th/type :delete-profile + ::rpc/profile-id (:id profile)} + first-out (th/command-raw! params)] + (t/is (th/success? first-out)) + (let [second-out (th/command-raw! params)] + (t/is (th/success? second-out)) + (t/is (= (:id profile) + (get-in (meta second-out) [:app.loggers.audit/props :id])))))) diff --git a/backend/test/backend_tests/rpc_project_test.clj b/backend/test/backend_tests/rpc_project_test.clj index f5855a43e8..c27bb31868 100644 --- a/backend/test/backend_tests/rpc_project_test.clj +++ b/backend/test/backend_tests/rpc_project_test.clj @@ -274,3 +274,16 @@ (t/is (th/ex-info? (:error out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation)))) + +(t/deftest create-project-audit-event-carries-its-id + ;; The command returns the new project but did not emit its id, so a consumer + ;; could not key a local projection. + (let [profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + out (th/command! {::th/type :create-project + ::rpc/profile-id (:id profile) + :team-id (:id team) + :name "test project"})] + (t/is (th/success? out)) + (t/is (= (get-in out [:result :id]) + (get-in (meta (:result out)) [:app.loggers.audit/props :id]))))) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index ed6520b815..991b380548 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -6,8 +6,10 @@ (ns backend-tests.rpc-team-test (:require + [app.common.data :as d] [app.common.logging :as l] [app.common.time :as ct] + [app.common.types.team :as types.team] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -16,6 +18,7 @@ [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.teams :as teams] + [app.rpc.helpers :as rph] [app.storage :as sto] [app.tokens :as tokens] [backend-tests.helpers :as th] @@ -1751,3 +1754,195 @@ ::rpc/profile-id (:id stranger)})] (t/is (th/success? out)) (t/is (nil? (some #(= (:id project) (:id %)) (:result out)))))))) + +(t/deftest create-team-emits-add-team-member-audit-event + ;; Team creation adds the owner through add-profile-to-team!, which used to + ;; leave no membership event. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 21 {:profile-id (:id owner)}) + events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "add-team-member" (:name %))) + (filter #(= (:id team) (get-in % [:props :team-id]))))] + (t/is (= 1 (count events))) + (t/is (= (:id owner) (get-in (first events) [:props :member-id]))) + (t/is (true? (get-in (first events) [:props :is-owner]))))))) + +(t/deftest add-team-member-event-skipped-when-membership-exists + ;; Adding an existing member with on-conflict-do-nothing inserts nothing and + ;; must not report a membership that already exists. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 22 {:profile-id (:id owner)})] + (th/reset-mock! audit-mock) + (db/tx-run! th/*system* + (fn [cfg] + (teams/add-profile-to-team! cfg + {:team-id (:id team) + :profile-id (:id owner)} + {::db/on-conflict-do-nothing? true}))) + (t/is (empty? (:call-args-list @audit-mock))))))) + +(t/deftest no-add-team-member-event-without-audit-log + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + _team (th/create-team* 23 {:profile-id (:id owner)}) + events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "add-team-member" (:name %))))] + (t/is (empty? events))))) + +(t/deftest add-team-member-event-carries-derived-role + ;; `team_profile_rel` has no role column, so no caller can pass it: the + ;; emitter derives it from the flags, inverse of permissions-for-role. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 26 {:profile-id (:id owner)}) + editor (th/create-profile* 27 {:is-active true}) + admin (th/create-profile* 28 {:is-active true}) + viewer (th/create-profile* 29 {:is-active true})] + (db/tx-run! th/*system* + (fn [cfg] + (doseq [[member role] [[editor :editor] + [admin :admin] + [viewer :viewer]]] + (teams/add-profile-to-team! + cfg + (merge {:team-id (:id team) + :profile-id (:id member)} + (get types.team/permissions-for-role role)))))) + (let [by-member (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "add-team-member" (:name %))) + (d/index-by #(get-in % [:props :member-id])))] + (t/is (= :owner (get-in by-member [(:id owner) :props :role]))) + (t/is (= :editor (get-in by-member [(:id editor) :props :role]))) + (t/is (= :admin (get-in by-member [(:id admin) :props :role]))) + (t/is (= :viewer (get-in by-member [(:id viewer) :props :role])))))))) + +(t/deftest add-team-member-event-carries-request-context + ;; Sibling events built by `prepare-rpc-event` carry the request context; + ;; the hand-built membership event must not lose it when rpc params travel. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [owner (th/create-profile* 1 {:is-active true}) + member (th/create-profile* 2 {:is-active true}) + team (th/create-team* 30 {:profile-id (:id owner)})] + (th/reset-mock! audit-mock) + (db/tx-run! th/*system* + (fn [cfg] + (teams/add-profile-to-team! + cfg + (merge {:team-id (:id team) + :profile-id (:id member)} + (get types.team/permissions-for-role :editor)) + nil + {:rpc-params {::rpc/profile-id (:id owner) + ::rpc/request-id "req-1"}}))) + (let [events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "add-team-member" (:name %))))] + (t/is (= 1 (count events))) + (t/is (= "req-1" (get-in (first events) [:context :request-id])))))))) + +(t/deftest invitation-direct-add-attributes-event-to-inviter + ;; With email verification off, inviting an existing profile adds the + ;; member directly; the event belongs to the inviter, not the member. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags #{:audit-log}] + (let [owner (th/create-profile* 1 {:is-active true}) + member (th/create-profile* 2 {:is-active true}) + team (th/create-team* 31 {:profile-id (:id owner)})] + (th/reset-mock! audit-mock) + (let [out (th/command! {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :editor + :emails [(:email member)]})] + (t/is (th/success? out)) + (let [events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "add-team-member" (:name %))) + (filter #(= (:id member) (get-in % [:props :member-id]))))] + (t/is (= 1 (count events))) + (t/is (= (:id owner) (:profile-id (first events)))) + (t/is (= :editor (get-in (first events) [:props :role]))))))))) + +(t/deftest create-team-audit-event-carries-created-at + ;; The projection keys the new team on the event id and needs its creation + ;; time from the same event. + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/command-raw! {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "team"}) + props (:app.loggers.audit/props (meta out))] + (t/is (= (:id out) (:id props))) + (t/is (= (:created-at out) (:created-at props))) + (t/is (ct/inst? (:created-at props))) + (t/is (= (:modified-at out) (:modified-at props))) + (t/is (ct/inst? (:modified-at props))))) + +(t/deftest delete-team-audit-event-carries-entity-data + ;; The projection marks the team deleted from this single event, so it needs + ;; the identity, the name and the timestamps. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 31 {:profile-id (:id profile)}) + out (th/command-raw! {::th/type :delete-team + ::rpc/profile-id (:id profile) + :id (:id team)}) + props (:app.loggers.audit/props (meta out))] + ;; The API response is unchanged: the command still returns nil. + (t/is (nil? (rph/unwrap out))) + (t/is (= (:id team) (:id props))) + (t/is (= (:name team) (:name props))) + (t/is (= (:created-at team) (:created-at props))) + (t/is (ct/inst? (:modified-at props))) + (t/is (ct/inst? (:deleted-at props))))) + +(t/deftest create-team-with-invitations-audit-event-carries-team-id + ;; The team is created by an internal call, so both the command event and + ;; the manual event must carry its id for a projection to key it. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil} + email-mock {:target 'app.email/send! :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/command-raw! {::th/type :create-team-with-invitations + ::rpc/profile-id (:id profile) + :name "team" + :emails #{"invitee@example.com"} + :role :editor}) + props (:app.loggers.audit/props (meta out)) + events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "create-team" (:name %))))] + (t/is (= (:id out) (:id props))) + (t/is (= 1 (count events))) + (t/is (= (:id out) (get-in (first events) [:props :id]))))))) + +(t/deftest update-team-member-role-to-owner-names-demoted-member + ;; Promoting a member to owner demotes the actor; the event names them so a + ;; projection can keep a single owner. + (let [owner (th/create-profile* 1 {:is-active true}) + editor (th/create-profile* 2 {:is-active true}) + team (th/create-team* 82 {:profile-id (:id owner)})] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id editor) + :role :editor}) + (let [out (th/command-raw! {::th/type :update-team-member-role + ::rpc/profile-id (:id owner) + :team-id (:id team) + :member-id (:id editor) + :role :editor}) + props (:app.loggers.audit/props (meta out))] + (t/is (nil? (:prev-owner-id props)))) + (let [out (th/command-raw! {::th/type :update-team-member-role + ::rpc/profile-id (:id owner) + :team-id (:id team) + :member-id (:id editor) + :role :owner}) + props (:app.loggers.audit/props (meta out))] + (t/is (= (:id owner) (:prev-owner-id props)))))) diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index 5b15d46111..de5227aeb2 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -374,3 +374,29 @@ (t/is (nil? (:result out))) (let [rows (th/db-exec! ["select * from webhook"])] (t/is (= 0 (count rows))))))))))) + +(t/deftest webhook-audit-events-carry-team-id + ;; `update-webhook` and `delete-webhook` have no team id in their params, so + ;; the command copies it from the stored webhook into the audit props. + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] + (let [prof (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id prof)}) + whook (-> (th/command! {::th/type :create-webhook + ::rpc/profile-id (:id prof) + :team-id (:id team) + :uri (u/uri "http://example.com") + :mtype "application/json"}) + :result) + upd (th/command! {::th/type :update-webhook + ::rpc/profile-id (:id prof) + :id (:id whook) + :uri (u/uri "http://example.com/updated") + :mtype "application/json" + :is-active true}) + del (th/command-raw! {::th/type :delete-webhook + ::rpc/profile-id (:id prof) + :id (:id whook)})] + (t/is (= (:id team) + (get-in (meta (:result upd)) [:app.loggers.audit/props :team-id]))) + (t/is (= (:id team) + (get-in (meta del) [:app.loggers.audit/props :team-id])))))) diff --git a/backend/test/backend_tests/srepl_main_test.clj b/backend/test/backend_tests/srepl_main_test.clj index 1e2add5361..aebc99c9bf 100644 --- a/backend/test/backend_tests/srepl_main_test.clj +++ b/backend/test/backend_tests/srepl_main_test.clj @@ -11,7 +11,8 @@ [app.system :as sys] [app.worker :as wrk] [backend-tests.helpers :as th] - [clojure.test :as t])) + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -150,3 +151,57 @@ #(srepl/process-deleted-files-cascade :ids [(:id file)]))] (t/is (= [{:object :file :id (:id file)}] (mapv #(select-keys % [:object :id]) (params-of submitted)))))))))) + +(t/deftest delete-profile-emits-identifiable-audit-event + ;; SREPL has no actor, so the event profile stays zero; the subject id in + ;; props is what makes the row attributable. Both insert and cascade are + ;; mocked, so this has no side effects. + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil} + wrk-mock {:target 'app.worker/invoke! :return nil}] + (let [profile (th/create-profile* 1 {:is-active true})] + (srepl/delete-profile! (str (:id profile))) + (let [events (->> (:call-args-list @audit-mock) + (map second))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "delete-profile" (:name event))) + (t/is (= "action" (:type event))) + ;; No actor on SREPL calls; insert defaults the missing profile + ;; to the zero one. + (t/is (nil? (:profile-id event))) + (t/is (= (:id profile) (get-in event [:props :id])))))))) + +(t/deftest delete-team-emits-identifiable-audit-event + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil} + wrk-mock {:target 'app.worker/invoke! :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 95 {:profile-id (:id profile)})] + (srepl/delete-team! (str (:id team))) + (let [events (->> (:call-args-list @audit-mock) + (map second))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "delete-team" (:name event))) + (t/is (= "action" (:type event))) + (t/is (= (:id team) (get-in event [:props :id]))) + (t/is (= "explicit call to delete-team!" + (get-in event [:context :cause])))))))) + +(t/deftest restore-profile-emits-identifiable-audit-event + ;; SREPL has no actor, so the event profile stays zero; the subject id in + ;; props is what makes the row attributable. + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil} + wrk-mock {:target 'app.worker/invoke! :return nil}] + (with-redefs [sys/system th/*system*] + (let [profile (th/create-profile* 1 {:is-active true})] + (srepl/restore-profile! (str (:id profile))) + (let [events (->> (:call-args-list @audit-mock) + (map second))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "restore-profile" (:name event))) + (t/is (= "action" (:type event))) + ;; No actor on SREPL calls; insert defaults the missing profile + ;; to the zero one. + (t/is (nil? (:profile-id event))) + (t/is (= (:id profile) (get-in event [:props :id]))))))))) From 0d9b1c48fc1c06f7401161b6c577f172449d0667 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 13:23:54 +0200 Subject: [PATCH 018/167] :bug: Fix legacy path migration crash on paths without points (#12357) The legacy-6 migration read the rect of each path from its points and checked whether the width or height was close to zero. A path with missing or empty points has no rect, so the check got nil and threw a NullPointerException. The migration then failed on every load of that file, and get-team-shared-files returned 500 for the whole team, so the "Add library" modal could not open. Treat a missing rect like a degenerate one and rebuild the selrect and points from the path content. Closes #12343 AI-assisted-by: claude-opus-5-5 --- common/src/app/common/files/migrations.cljc | 6 ++- .../common_tests/files_migrations_test.cljc | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index dbe0b0abd7..adc25e6cf6 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -223,8 +223,10 @@ [data _] (letfn [(fix-line-paths [shape] (if (= (:type shape) :path) - (let [{:keys [width height]} (grc/points->rect (:points shape))] - (if (or (mth/almost-zero? width) (mth/almost-zero? height)) + ;; A path with missing or empty points has no rect; rebuild + ;; its geometry from the content as for line-like paths. + (let [{:keys [width height] :as rect} (grc/points->rect (:points shape))] + (if (or (nil? rect) (mth/almost-zero? width) (mth/almost-zero? height)) (let [selrect (path.segment/content->selrect (:content shape)) points (grc/rect->points selrect) transform (gmt/matrix) diff --git a/common/test/common_tests/files_migrations_test.cljc b/common/test/common_tests/files_migrations_test.cljc index 0ac8084708..b76f4f369c 100644 --- a/common/test/common_tests/files_migrations_test.cljc +++ b/common/test/common_tests/files_migrations_test.cljc @@ -605,3 +605,45 @@ data' (cfm/migrate-data data migration-id)] (t/is (= [child-id] (get-in data' [:components component-id :objects main-id :shapes]))))) + +(defn- make-path-shape + [shape-id] + (cts/setup-shape {:id shape-id + :type :path + :content [{:command :move-to :params {:x 10 :y 20}} + {:command :line-to :params {:x 110 :y 70}}]})) + +(t/deftest migration-legacy-6-repairs-paths-without-points + (let [migration-id "legacy-6" + page-id (uuid/next) + shape-id (uuid/next) + shape (make-path-shape shape-id)] + + (doseq [points [nil []]] + (let [data {:pages-index {page-id {:objects {shape-id (assoc shape :points points)}}}} + data' (cfm/migrate-data data migration-id) + shape' (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (= (:points shape) (:points shape')) + (str "points rebuilt from content when they were " (pr-str points))) + (t/is (= (:selrect shape) (:selrect shape')) + "selrect rebuilt from content"))))) + +(t/deftest migration-legacy-6-runs-through-file-migration + ;; A file saved before version 6 runs every migration from legacy-6 on, + ;; and the whole chain must succeed for the file to load. + (let [migration-id "legacy-6" + shape-id (uuid/next) + file (ctf/make-file {:name "Legacy path without points"}) + page-id (first (get-in file [:data :pages])) + shape (-> (make-path-shape shape-id) + (assoc :points [])) + file (-> file + (dissoc :migrations) + (assoc :version 5) + (assoc-in [:data :pages-index page-id :objects shape-id] shape)) + file' (cfm/migrate-file file (delay {}))] + + (t/is (contains? (:migrations file') migration-id) "migration id persisted") + (t/is (= 4 (count (get-in file' [:data :pages-index page-id :objects shape-id :points]))) + "path points repaired before schema validation"))) From 1783f9380d07a0dd6ae980cad9b979a65d39e69f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 14:18:57 +0200 Subject: [PATCH 019/167] :sparkles: Scan upload-session chunks in storage gc-touched (#12305) Route upload-session chunk liveness through the mark-time reference scan instead of the delete-time cleanup in gc-deleted. gc-touched now checks upload_session_chunk before marking a chunk, so referenced chunks freeze and only orphans are marked. The freeze path re-verifies references under row locks, which also closes the stale-freeze race for every scanned bucket. gc-deleted drops its upload-session special case: deferred rows keep their mappings until the final give-up, and a live reference aborts the chunk loud. Closes #12302 AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/backend/storage.md | 5 +- backend/src/app/storage/gc_deleted.clj | 30 +--- backend/src/app/storage/gc_touched.clj | 53 +++++- backend/test/backend_tests/rpc_font_test.clj | 6 +- backend/test/backend_tests/storage_test.clj | 169 ++++++++++++++++++- 5 files changed, 227 insertions(+), 36 deletions(-) diff --git a/.serena/memories/backend/storage.md b/.serena/memories/backend/storage.md index 3d978642df..816e254b2b 100644 --- a/.serena/memories/backend/storage.md +++ b/.serena/memories/backend/storage.md @@ -25,8 +25,9 @@ - Existing object values can remain readable until physical deletion. - `:expired-at` blocks reads after the expiration time. - `del-object!` sets `deleted_at` on live rows only (`deleted_at IS NULL`): a repeated call returns `false`. It does not remove backend content. -- `storage-gc-deleted` removes the database row and backend content after the deletion delay, streaming the pending rows with `db/plan` inside a read-only transaction and deleting them in chunks of 25, each chunk in its own transaction: a chunk that fails (for example, an object a foreign key still references) is logged and skipped instead of aborting the run. A physical-deletion failure inside a chunk defers its rows one day (`deletion_attempts + 1`) and gives up after 7 attempts, accepting the orphan blob; chunk mappings (`upload_session_chunk`) are removed before the `storage_object` rows (NO ACTION foreign keys). +- `storage-gc-deleted` removes the database row and backend content after the deletion delay, streaming the pending rows with `db/plan` inside a read-only transaction and deleting them in chunks of 25, each chunk in its own transaction: a chunk that fails (for example, an object a foreign key still references) is logged and skipped instead of aborting the run. A physical-deletion failure inside a chunk defers its rows one day (`deletion_attempts + 1`) and gives up after 7 attempts, accepting the orphan blob. Deferred rows keep their references until the final give-up. The freeze path re-verifies references under row locks, so a concurrent purge can delay a freeze but never swallow a touch. - `storage-gc-touched` finds references before it sets `deleted_at`. +- Orphan watch: `SELECT COALESCE(metadata->>'bucket', metadata->>'~:bucket') AS bucket, count(*) FROM storage_object so WHERE touched_at IS NULL AND deleted_at IS NULL AND status = 'valid' AND NOT EXISTS (SELECT 1 FROM upload_session_chunk usc WHERE usc.object_id = so.id) GROUP BY 1;` Only the `upload-session` row of that output is a leak signal (its rows always have a mapping or a pending touch/deleted mark); other buckets use domain-table references, not this mapping. - `objects-gc` removes deleted domain rows and touches their storage object IDs. Each proc streams its pending keys with `db/plan` inside a read-only transaction and deletes them in chunks, each chunk in its own transaction with `FOR UPDATE SKIP LOCKED`: a chunk that fails is logged and skipped instead of aborting the run. - Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction. @@ -83,7 +84,7 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + ` | `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. | | `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. | | `tempfile` | Export files and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. | -| `upload-session` | Chunked-upload chunks. References: `upload_session_chunk.object_id` and `upload_session_chunk.session_id` (both NO ACTION DEFERRABLE: restrict semantics, procedural deletion). | No | Authentication required | No reference scan. A touched object is deleted after the delay; `gc-deleted` removes mappings before rows. | +| `upload-session` | Chunked-upload chunks. References: `upload_session_chunk.object_id` and `upload_session_chunk.session_id` (both NO ACTION DEFERRABLE: restrict semantics, procedural deletion). | No | Authentication required | Reference scan on `upload_session_chunk.object_id`; a referenced chunk freezes, an orphan is deleted after the delay. Deferred rows keep their mappings until give-up. | | `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. | | `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. | | `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. | diff --git a/backend/src/app/storage/gc_deleted.clj b/backend/src/app/storage/gc_deleted.clj index 6c7e419676..22677ab429 100644 --- a/backend/src/app/storage/gc_deleted.clj +++ b/backend/src/app/storage/gc_deleted.clj @@ -57,18 +57,6 @@ (-> (db/exec-one! conn [sql:delete-sobjects ids]) (db/get-update-count)))) -(def ^:private sql:delete-upload-session-chunks - "DELETE FROM upload_session_chunk - WHERE object_id = ANY(?::uuid[])") - -(defn- delete-upload-session-chunks - "Remove the chunk mappings for the given storage object ids. This must run - before the storage_object rows are deleted: the upload_session_chunk - foreign keys are ON DELETE NO ACTION." - [conn ids] - (let [ids (db/create-array conn "uuid" ids)] - (db/exec-one! conn [sql:delete-upload-session-chunks ids]))) - (def ^:private sql:increment-attempts-and-defer "UPDATE storage_object SET deletion_attempts = deletion_attempts + 1, @@ -117,21 +105,17 @@ :backend (name backend-id))) (when (seq ok-ids) - ;; NOTE: the chunk mappings must be removed before the - ;; storage_object rows (NO ACTION foreign keys). It only affects - ;; objects of the upload-session bucket; for any other bucket the - ;; delete matches no rows. - (delete-upload-session-chunks conn ok-ids) + ;; NOTE: rows arrive here with no live references (the mark-time + ;; scan in gc-touched owns that check, also for the upload-session + ;; bucket). If a NO ACTION reference still exists, this DELETE + ;; aborts the chunk loud instead of breaking anyone silently. (delete-sobjects conn ok-ids)) (when (seq fail-ids) + ;; NOTE: deferred rows keep both their row and their references + ;; (upload-session mappings included) until the final give-up, so + ;; a backend outage does not break live sessions. (increment-attempts-and-defer conn fail-ids) - ;; NOTE: same NO ACTION ordering as above: the give-up DELETE below - ;; removes storage_object rows, so chunk mappings must go first. - ;; Deferred objects keep their rows; only the mapping of a - ;; permanently given-up object disappears early, and that object is - ;; already deleted-marked. - (delete-upload-session-chunks conn fail-ids) (let [given-up (delete-give-up conn fail-ids)] (when (pos? (db/get-update-count given-up)) (l/wrn :hint "giving up on orphan blob after max attempts" diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index f73b6024f1..104f9b7f17 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -77,6 +77,15 @@ (-> (db/exec-one! conn [sql:has-file-thumbnail-refs id]) (get :has-refs))) +(def ^:private + sql:has-upload-session-chunk-refs + "SELECT EXISTS (SELECT 1 FROM upload_session_chunk WHERE object_id = ?) AS has_refs") + +(defn- has-upload-session-chunk-refs? + [conn {:keys [id]}] + (-> (db/exec-one! conn [sql:has-upload-session-chunk-refs id]) + (get :has-refs))) + (def sql:exists-file-data-refs "SELECT EXISTS ( SELECT 1 FROM file_data @@ -146,6 +155,31 @@ :id (str id)) sto/default-bucket))) +(def ^:private sql:lock-storage-objects + "SELECT id FROM storage_object WHERE id = ANY(?::uuid[]) ORDER BY id FOR UPDATE") + +(defn- verify-frozen + "Re-checks, holding the row locks, that the about-to-freeze objects are + still referenced. A concurrent purge (mapping delete plus touch) either + commits before the check and the object moves to deletion, or blocks on + the lock and its touch wins last, so no touch is ever swallowed by a + stale freeze. Ids are locked in order to avoid lock-order inversions + between workers. Returns [still-frozen-ids now-deletable-ids]." + [conn has-refs? bucket objects] + (let [by-id (into {} (map (juxt :id identity)) objects) + ids (db/create-array conn "uuid" (sort (keys by-id))) + locked (into #{} (map :id) (db/exec! conn [sql:lock-storage-objects ids]))] + (reduce (fn [[frozen deletable] {:keys [id] :as object}] + (if (and (contains? locked id) (has-refs? conn object)) + [(conj frozen id) deletable] + (do + (l/dbg :id (str id) + :status "delete-after-recheck" + :bucket bucket) + [frozen (conj deletable id)]))) + [#{} #{}] + objects))) + (defn- process-objects [conn has-refs? bucket objects] (loop [to-freeze #{} @@ -157,17 +191,24 @@ (l/dbg :id (str id) :status "freeze" :bucket bucket) - (recur (conj to-freeze id) to-delete (rest objects))) + (recur (conj to-freeze object) to-delete (rest objects))) (do (l/dbg :id (str id) :status "delete" :bucket bucket) (recur to-freeze (conj to-delete id) (rest objects)))) - (do - (some->> (seq to-freeze) (mark-freeze-in-bulk conn)) - (some->> (seq to-delete) (mark-delete-in-bulk conn)) - [(count to-freeze) (count to-delete)])))) + (let [[frozen deletable] (if (seq to-freeze) + (verify-frozen conn has-refs? bucket to-freeze) + [#{} #{}]) + doomed (into to-delete deletable)] + (some->> (seq frozen) (mark-freeze-in-bulk conn)) + (some->> (seq doomed) (mark-delete-in-bulk conn)) + [(count frozen) (count doomed)])))) +;; NOTE: upload-session chunks are 1:1 with their mapping and never +;; shared, but the scan owns the liveness decision anyway: a referenced +;; chunk freezes, so gc-deleted never meets a live NO ACTION reference. +;; The freeze path re-verifies under row locks (see verify-frozen). (defn- process-bucket [conn bucket objects] (cond @@ -178,7 +219,7 @@ (= bucket "profile") (process-objects conn has-profile-refs? bucket objects) (= bucket "file-data") (process-objects conn has-file-data-refs? bucket objects) (= bucket sto/tempfile-bucket) (process-objects conn (constantly false) sto/tempfile-bucket objects) - (= bucket sto/upload-session-bucket) (process-objects conn (constantly false) sto/upload-session-bucket objects) + (= bucket sto/upload-session-bucket) (process-objects conn has-upload-session-chunk-refs? sto/upload-session-bucket objects) (= bucket "organization") (process-objects conn (constantly false) bucket objects) :else (ex/raise :type :internal diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 90dabf62a0..bd81112bcb 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -140,7 +140,7 @@ (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) + (t/is (= 8 (:freeze res)))) (let [params {::th/type :delete-font ::rpc/profile-id (:id prof) @@ -209,7 +209,7 @@ (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) + (t/is (= 8 (:freeze res)))) (let [params {::th/type :delete-font ::rpc/profile-id (:id prof) @@ -260,7 +260,7 @@ ;; freeze with hours 3 clock (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) + (t/is (= 8 (:freeze res)))) (let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof) :team-id team-id :id (-> out1 :result :id)} diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index abdd8c1503..f85319bc47 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -16,6 +16,7 @@ [app.rpc :as-alias rpc] [app.storage :as sto] [app.storage.fs :as-alias sto.fs] + [app.storage.gc-touched :as gc-touched] [app.storage.impl :as impl] [app.storage.s3 :as-alias sto.s3] [app.storage.schema :as stsch] @@ -328,8 +329,10 @@ ;; run the touched gc task (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] - (t/is (= 5 (:freeze res))) - (t/is (= 1 (:delete res))) + (t/is (= 6 (:freeze res))) + ;; the consumed-but-not-yet-purged session keeps its mapping, + ;; so its chunk freezes instead of being marked + (t/is (= 0 (:delete res))) (let [result-1 (:result out1) result-2 (:result out2)] @@ -1108,6 +1111,168 @@ (t/is (= 1 (:deleted res)))) (t/is (= 0 (:count (th/db-exec-one! ["select count(*) from storage_object"])))))) +(t/deftest upload-session-chunk-frozen-while-referenced + ;; A chunk whose session mapping is still alive must be frozen, not + ;; marked for deletion: the reference scan owns the liveness decision. + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + _ (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "chunk" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] + (t/is (nil? (:error out))) + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" + session-id])))) + + ;; the session is neither consumed nor stalled: the mapping is alive + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 1 (:freeze res))) + (t/is (= 0 (:delete res)))) + + ;; frozen: untouched, not marked, mapping intact + (t/is (= 0 (:count (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"])))) + (t/is (= 0 (:count (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])))) + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" + session-id])))))) + +(t/deftest upload-session-freeze-reverify + ;; The freeze recheck runs under row locks: an object whose mapping + ;; disappeared (plus a concurrent touch) after the first check is + ;; reclassified to deletion instead of swallowing the touch. + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + _ (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "chunk" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" + session-id])) + check @#'gc-touched/has-upload-session-chunk-refs? + verify @#'gc-touched/verify-frozen] + (t/is (nil? (:error out))) + + ;; mapping alive: stays frozen + (let [[frozen deletable] (db/transact! th/*pool* (fn [conn] (verify conn check "upload-session" [{:id object-id}])))] + (t/is (= #{object-id} frozen)) + (t/is (= #{} deletable))) + + ;; simulate a purge that committed after the first check: mapping + ;; gone, touch bumped + (th/db-exec! ["delete from upload_session_chunk where session_id = ?" session-id]) + (th/db-exec! ["update storage_object set touched_at = ? where id = ?" (ct/now) object-id]) + + ;; recheck moves it to deletion + (let [[frozen deletable] (db/transact! th/*pool* (fn [conn] (verify conn check "upload-session" [{:id object-id}])))] + (t/is (= #{} frozen)) + (t/is (= #{object-id} deletable))))) + +(t/deftest upload-session-gc-deleted-skips-live-mapping + ;; A row marked for deletion while its mapping is still alive must NOT + ;; be reaped silently: the chunk is skipped loud (logged) and retried + ;; once objects-gc purges the session. This pins the mark-time + ;; invariant instead of breaking sessions quietly. + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + _ (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "chunk" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" + session-id]))] + (t/is (nil? (:error out))) + + ;; force the anomaly: mark deleted while the mapping is alive + (th/db-exec! ["update storage_object set deleted_at = ? where id = ?" (ct/now) object-id]) + + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 0 (:deleted res)))) + + ;; row and mapping both intact for the retry + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from storage_object where id = ?" object-id])))) + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" + session-id])))))) + +(t/deftest upload-session-deferred-keeps-mapping + ;; A physical-deletion failure defers the row; the session mapping is + ;; kept so the session survives the backend outage and the blob is + ;; retried the next day. + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + _ (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "chunk" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" + session-id]))] + (t/is (nil? (:error out))) + (th/db-exec! ["update storage_object set deleted_at = ? where id = ?" (ct/now) object-id]) + + (with-mocks [_mock {:target 'app.storage.impl/del-objects-in-bulk + :return (fn [_ ids] (set ids))}] + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 0 (:deleted res))))) + + ;; row kept and deferred one day + (let [row (th/db-exec-one! ["select deletion_attempts, touched_at from storage_object where id = ?" object-id])] + (t/is (= 1 (:deletion-attempts row))) + (t/is (some? (:touched-at row)))) + ;; mapping kept: the session is intact for the retry + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" + session-id])))))) + (t/deftest upload-session-consumed-purge ;; An assembled session is marked as consumed and objects-gc purges it ;; right away, without waiting for the stalled threshold. From d57cdb45881a8ab72cd694de22f62507f10dff7e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 12:14:32 +0200 Subject: [PATCH 020/167] :rewind: Backport persistence related fixes from staging (#12306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Add async test helpers and the with-mocks* macro Backport of the test scaffolding that the persistence retry work needs, taken from staging. Purely additive: no existing helper changes behavior, so the suite result is unchanged (740 tests, 0 failures). - frontend-tests.helpers.async: ->promise, await-response, settle, observe and wait-for. wait-for checks immediately and then polls one macrotask at a time, bounded, so it fails instead of hanging. - mock/with-mocks*: installs mocks with set! and evaluates its body in a generated ^:async fn, so the test reads sequentially and there is no done to thread. Evaluates to a promise. - mock/asap and mock/run-mocked: run-mocked awaits the body and restores the originals always, in that order, reporting a rejection or a non-promise return as an :error. The matching change that made with-mocks defer its body via asap is NOT backported: it would force all 58 existing uses across 14 files into done-chained contexts, which is a separate refactor. AI-assisted-by: space-bunny-free * :sparkles: Add an error report governor with compact payloads One browser session could emit an unbounded number of identical error reports, and a dropped connection was reported as an application defect with the full payload. Both are fixed here, in one place, because the persistence retry work that follows depends on the new submit-report contract. - A report governor in app.main.errors. Each report carries a fingerprint (report-name|type|code|hint|first stack frame); the first occurrence is always emitted, repeats inside a 2 minute window are counted, and the next emitted report carries them as :occurrences. The cache is bounded by evicting the oldest entry, so memory stays fixed. - flash reserves the report before generating it, so a suppressed occurrence never pays the report-building cost. Its whole body now runs inside one ts/schedule callback, a reporting or notification failure is logged instead of propagating, and it returns a total promise so tests can await it. - generate-report accepts :format :compact|:full. The compact payload keeps the context header plus type, code and uri, and skips the stack, the ex-data dump (it can carry request headers) and the last-events list. - environment-error-types is derived from rp/retryable-types, so the transport classification has a single source of truth. Environment failures are audit-only handled-exceptions and never reach the exception page or the alerts. :offline gets its own handler instead of falling through to :default. :occurrences travels inside the audit event :props, which the backend schema accepts as a free-form map, so no backend change is needed. The dedup governor test file is new: 7 tests over the fingerprint, the pure reservation decision, and the flash pipeline. Relates to #11726, #11743 AI-assisted-by: space-bunny-free * :recycle: Answer commit refusals with an empty stream commit-changes used to return nil when it refused to build a commit, which forced every caller that wanted to observe the outcome to special-case nil. It now returns an empty stream, so a refusal and an empty result look the same and callers can subscribe uniformly. Only the refusal branch changes: the accepted branch already answered with (rx/of commit). Potok treats a nil watch result as "nothing", so both spellings were already correct at the store level. The 57 call sites that wrap this event in rx/of are unaffected. They hold the ::commit-changes event itself, not the value its watch returns, so they keep processing exactly as before. AI-assisted-by: space-bunny-free * :sparkles: Retry transient saves with backoff and a reconnect notice Any failed update-file landed on the same path: the queue halted in :error, the retained changes were flushed away, and the user was told to reload. A dropped connection and a revoked permission are not the same thing, so they now take different paths. transient-error? splits them, checking :type and :cause-type because persistence-failed wraps the original cause. Transient failures enter a :retrying episode: the head commit stays queued and resends with backoff at 2s, 8s and 20s, then falls through to today's terminal path. Everything else is terminal and keeps the exact :error path. - Resends rotate the ::request-id stamp only once the old request has left active-requests, carry the same :commit-id, and never double send while one request is in flight. - Retry timers carry an episode token; a timer whose episode was superseded stays silent instead of sending or failing. - One tagged reconnect notice per episode, hidden by tag on save and on terminal failure. Recovery is silent. - The browser online event re-enters the runner, but only for a live :retrying episode, guarded by exists? js/window. - wait-persisted-or-error waits through :retrying instead of cutting the wait short. The episode keeps its :run-id, so a new local edit only joins the queue. Re-entering the runner on every edit would bypass the backoff and spend one attempt per batched commit. The retry test namespace is new: 13 tests over the classification, the backoff schedule, stamp rotation, stale tokens and the notice lifecycle. Relates to #11724 AI-assisted-by: space-bunny-free * :bug: Fix stalled saves that never answer and edits lost on leave Two holes the retry work did not close. An update-file request that never gets an answer kept its attempt stamp forever, so the queue could not move and the user saw a saving state that never resolved. The request now has a 7 minute timeout that fails it as a transient network error, which hands it to the retry path instead of blocking. The timeout is longer than the 5 minute stall threshold on purpose: the stalled report must fire before the retry, not instead of it. The leave-page warning also had gaps. It now tracks unsaved edits directly, through sync-unload-guard, which holds :persistence in st/ongoing-tasks while edits sit in the buffer or the queue. Edits kept in the queue after a failed save keep the guard on too, so closing the tab warns instead of dropping them. store.cljs guards its ongoing-tasks watcher with exists? js/window, because the new tests exercise it without a window. Four new tests: three for the guard across buffering, an in-flight save and a retained edit, and one for a hung request that must resend and save. Closes #12223 AI-assisted-by: space-bunny-free * :bug: Skip the save when a missing font cannot be remapped fix-deleted-fonts emitted text and typography changes even when no replacement font existed, so opening a page that uses a deleted custom font rewrote identical content and cleared position-data on every visit. Every visit became a save with nothing to save. Both loops now compare the remapped value with the original and emit nothing when they match. The remapped path is unchanged. Two new tests cover the no-replacement case and the remapped case. Closes #12151 AI-assisted-by: space-bunny-free * :sparkles: Add a debug helper that simulates an intermediary response A save that never reaches the backend is hard to reproduce: you need a proxy, a captive portal or a CDN in the way. This wraps fetch and answers the named API method with a status and a body that carries no Penpot error code, leaving every other request alone. Without a duration the simulation is spent after answering one call. With one it answers every matching call for that long, which is how to watch the save retry, warn, keep retrying and finally recover. debug.simulateHttpError('update-file') debug.simulateHttpError('get-comment-threads', 403) debug.simulateHttpError('update-file', 524, 120) debug.clearHttpErrorSimulation() Dev-only and self-contained: no production path reads it. AI-assisted-by: space-bunny-free * :globe_with_meridians: Add the save retry and connection error strings Three strings the new persistence and error paths need: - errors.save-retrying, shown once per retry episode while the queue resends a queued save after a connection loss. - workspace.header.retrying, the save indicator state for an episode in progress. - errors.connection-error, shown by the network and offline handlers, which no longer fall through to the generic message. Added to en.po only. The translations script also normalizes every other locale and drops 439 strings that were already stale before this work; that belongs in its own maintenance pass, not in a backport. AI-assisted-by: space-bunny-free * :bug: Report save failures as unhandled when they are our defect flash-persistence reported every failure it did not delegate as handled-exception, including the ones that are plainly our fault. A validation failure means the client sent something the server refused, yet it landed in the audit log with a name that skips the internal reports and the alerts. Nobody was paged. The report type now follows the cause. :validation, :server-error and :internal report as unhandled-exception, so they alert. The rest are unchanged. Two exclusions are deliberate: - Environment failures stay handled. They reach this point after the retry budget is spent, and the user's connection is not our bug. - :assertion stays handled, because its own handler already reports it that way and the two paths should not disagree. The cause type is read from :cause-type first, since persistence-failed wraps the original under :type :persistence. Reading :type alone would match the wrapper every time and change nothing. The user still gets the same sticky toast and report link; only the audit event name moves. The test that asserted a validation failure is "still notified" now asserts which event name it gets, across the bare and wrapped shapes, the environment types that must stay handled, and an unclassified one. AI-assisted-by: space-bunny-free * :bug: Unblock persistence when opening a file after a terminal failure A terminal save failure set the state to :error and left the queue alone. recover-persistence only resumed for :save-permission-denied, so for every other terminal code both of its branches were false and it emitted nothing. Leaving and reopening the same file therefore changed nothing: the editor stayed halted until a reload, which rebuilds the whole store and loses the queue anyway. The path had two jobs under one name. initialize-persistence fires when the workspace view mounts and tears down the previous subscription; recover-persistence fires when a file is opened or the role changes. They are now separate: initialization picks up a queue left behind by a previous session, and deliberately does not lift a halt, so undeliverable edits are released when the user acts rather than when the view mounts. Recovery lifts the halt by releasing what can never be saved. release-failed-file drops every queued commit of the file that failed, then resume-persistence clears the error and settles an empty queue to :saved. Retaining them until that point is deliberate: it is what keeps wait-persisted-or-error rejecting, which is what stops workspace/versions.cljs from building a snapshot out of unsaved edits. Releasing at failure time would make waiters resolve instead. Permission loss is treated as permanent too, which the previous exception got wrong. The backend raises :not-found for a missing edit permission, so a resend does not succeed — it routes the user to the exception page. The old branch made that reachable: it resumed the save whenever the cached can-edit flag said yes, and the server disagreed. The only case where the flag can return to true is a team switch, where the queue is gone anyway, so nothing real is lost by releasing. Because the queue is shared, dropping the failed file is also what lets the other files' pending edits drain. One file that cannot be saved no longer blocks everything else. Tests: the failure retains the queue and notifies once, recovery releases it without replaying, a failed file does not block a second one, permission loss is released rather than resumed, and initialization leaves a halt alone. AI-assisted-by: space-bunny-free * :sparkles: Offer a reload on the save failure toast A terminal save failure stops autosave for the whole session, but the toast asked the user to reload without offering the reload. It listed five implied actions, none of them reachable from the banner, and it never said that the changes could not be saved. flash now takes a :links vector of actions, appended after the download link that :report-link? produces, so a caller can add recovery actions without reaching into the report handling. flash-persistence passes a reload built from labels.reload-page and location.reload, the pattern already used elsewhere in the app for a full restart. The message now says what happened, what is lost, and what to do about it: autosave has stopped for this session, the changes since the last successful save cannot be saved, and either reloading or leaving the file and opening it again restarts it. Naming both matters, because the toast links are easy to miss. Saying the edits are lost is not optional: recovery releases them, so without it the user finds out when they come back to the file. Translated in en.po and es.po only. The translations script would also normalize all 40 locales and drop entries that were already stale, which is a maintenance pass of its own. The reload callback cannot be invoked under the test runner, since js/location does not exist there, so it is covered by its label and by being callable. Whether the reload works is a manual check. AI-assisted-by: space-bunny-free * :bug: Classify the responses the backend never wrote A reply written by a proxy instead of the backend carries no Penpot error code, so handle-response raised it as :internal. A 504 on a save skipped the retry cycle and stopped autosave at once, and because :internal reports as unhandled, it raised an alert. In a standard deployment this is how a slow save ends: nginx answers 504 after 300s, before the client's 7 minute timeout fires. The status now decides the type: - 504 and the Cloudflare 520-524 family raise :gateway-error, which joins retryable-types. Saves retry with backoff like 502 and 503, GETs retry, and a save that spends its retries reports as handled, because the report rules derive from the same set. - 429 raises :rate-limit, and any other error status :unexpected-response. Neither retries; both show a toast instead of the error page. - :internal is kept for a response below 400 that does not decode. Bodies we cannot read are cut to 500 characters, since proxy error pages run to tens of kilobytes. Tests: classification and retries per status, the transport handlers keep the page, a 504 and a 524 built by the real handle-response go through the save path and land on a retry, and the new types stay handled once the retries are spent. AI-assisted-by: claude-opus-5-5 * :sparkles: Keep retrying failed saves on a slow cycle Once the three backoff retries were spent, a save that failed on the connection stopped autosave like any terminal failure, and opening a file again dropped its edits. A connection that comes back after a minute lost the work all the same. A transport failure that outlasts the backoff now arms a slow cycle: one silent resend every 30s, until a save lands or 24h have passed since the failure started. The cycle stops when persistence restarts, the queue resumes another way, or the workspace closes. A save that keeps failing slowly is not resent for a day. A gateway that answers 504 after its own timeout may leave the backend applying the save, so each resend can cost the whole save again, and the failing file holds back every other file's queued edits. An attempt that fails after 90s or more counts as a slow failure; at three, the slow cycle stops sending the commit and the save fails as before. Fast failures, such as being offline, never count. - The first failure shows a toast that says the changes are kept and keep being sent, with no reload link, since a reload loses them. A save that lands hides it. - Opening a file sends a queue still inside its retry window once more instead of releasing it. Failures outside the window, and every non-transport failure, are still released. - A freshly loaded file gets back the queued edits of its version, so reopening it shows the changes that are still waiting to be saved. An edit that no longer applies to the loaded data leaves the file as the server sent it, so the file still opens; the edits stay queued. - A commit whose earlier request is no longer in flight is simply sent again under the same commit id: changes are idempotent, so there is no unknown-outcome guard any more. A request still in flight still blocks a parallel send. flash takes a :tag so the save failure toast can be hidden by tag. Tests: the spent backoff arms the slow cycle, a 504 arms it, a terminal failure does not, the cycle stops when the window closes or at the slow failure limit, recovery resends instead of releasing, reopening restores only the loaded file's edits and survives one that no longer applies, and the retrying toast offers no reload. * :bug: Turn off the leave-page warning when failed edits are released The leave-page warning only followed the queue on appends and discards. Opening a file again releases the edits of a file that failed for good, but that release changed the queue without either event, so the warning stayed on with nothing left to save. Leaving the page asked about unsaved changes until the next save went through. The warning now also follows the release. Tests: releasing the last failed edit turns the warning off. * :bug: Stop retrying failed saves outside the workspace Leaving the workspace was meant to stop the slow retry cycle, but only a cycle already armed stopped. A backoff timer kept firing after the user left, and a backoff spent outside the workspace armed a slow cycle that kept sending for up to a day from the dashboard. Those sends check the edit permission of whatever team is open, so a switch to a team where the user only views failed the queued edits for good, and the next file they opened dropped them. Leaving the workspace now suspends retries. A pending backoff timer goes stale, and while suspended a transport failure schedules no backoff, a spent backoff arms no slow cycle and the online event sends nothing. The edits stay queued and a request in flight still lands. Opening a file lifts the suspension and resends a retrying save whose backoff was cut short; a role change does not lift it. Tests: leaving stops a pending backoff, a failure after leaving schedules no retry and ignores the online event, a backoff spent outside the workspace arms no slow cycle, and opening a file saves the held edit in each case. --------- Co-authored-by: alonso.torres --- .serena/memories/clojure/idioms.md | 10 + .../frontend/routing-app-shell-subtleties.md | 8 + .serena/memories/frontend/testing.md | 13 + .../workspace-state-persistence-subtleties.md | 14 +- .../ui/specs/text-editor-v3.spec.js | 7 +- frontend/src/app/main/data/changes.cljs | 40 +- frontend/src/app/main/data/persistence.cljs | 559 ++++++-- frontend/src/app/main/data/workspace.cljs | 24 +- .../data/workspace/fix_deleted_fonts.cljs | 28 +- frontend/src/app/main/errors.cljs | 501 ++++++- frontend/src/app/main/repo.cljs | 61 +- frontend/src/app/main/store.cljs | 9 +- frontend/src/app/main/ui/static.cljs | 20 +- .../app/main/ui/workspace/left_header.cljs | 3 + .../app/main/ui/workspace/left_header.scss | 4 + frontend/src/debug.cljs | 81 ++ .../data/fix_deleted_fonts_test.cljs | 75 ++ .../data/persistence_retry_test.cljs | 884 +++++++++++++ .../frontend_tests/data/persistence_test.cljs | 1178 +++++++++++++---- .../test/frontend_tests/data/repo_test.cljs | 128 +- .../frontend_tests/errors_governor_test.cljs | 403 ++++++ .../test/frontend_tests/helpers/async.cljs | 92 ++ .../test/frontend_tests/helpers/mock.cljc | 86 +- .../test/frontend_tests/main_errors_test.cljs | 869 ++++++++---- frontend/test/frontend_tests/runner.cljs | 6 + frontend/translations/en.po | 19 +- frontend/translations/es.po | 7 +- 27 files changed, 4396 insertions(+), 733 deletions(-) create mode 100644 frontend/test/frontend_tests/data/fix_deleted_fonts_test.cljs create mode 100644 frontend/test/frontend_tests/data/persistence_retry_test.cljs create mode 100644 frontend/test/frontend_tests/errors_governor_test.cljs create mode 100644 frontend/test/frontend_tests/helpers/async.cljs diff --git a/.serena/memories/clojure/idioms.md b/.serena/memories/clojure/idioms.md index c8672aa647..26db315487 100644 --- a/.serena/memories/clojure/idioms.md +++ b/.serena/memories/clojure/idioms.md @@ -8,3 +8,13 @@ assumption; a wrong assumption here already cost a review round. `Long`, so `(int? 5000)` is true. - `integer?` is the general integer predicate; prefer it when any integer kind must match, `int?` only when fixed precision is meant. +- `await` is a `cljs.core` macro asserting `(:async &env)`: it fails at + compile time outside an `^:async` context, never silently. +- The analyzer reads `:async` only from the fn name meta and the `fn` + operator meta; list-level meta is ignored (would make a MetaFn). + `(fn ^:async [] …)` puts the meta on argv (pre/post only, NOT async). +- Valid: `(defn ^:async f)`, `(defn- ^:async f)`, `(^:async fn [] …)` + (the latter is what stock `t/async` generates itself). +- `^:async` fns never throw synchronously (rejected promises instead); + `try/catch/finally` supported; continuations are microtasks, timers and + RxJS schedulers are macrotasks (FIFO). diff --git a/.serena/memories/frontend/routing-app-shell-subtleties.md b/.serena/memories/frontend/routing-app-shell-subtleties.md index cbdc75fcef..d62690ec72 100644 --- a/.serena/memories/frontend/routing-app-shell-subtleties.md +++ b/.serena/memories/frontend/routing-app-shell-subtleties.md @@ -8,6 +8,14 @@ - The root app renders an exception page from `:exception` state before the normal error boundary. `rt/navigated` clears `:exception`. - Frontend error handling treats stale cross-build JS chunk failures specially: messages containing `$cljs$cst$` or `$cljs$core$I` plus undefined/null/not-a-function signatures trigger throttled reload. - Plugin-originated uncaught errors are identified through the plugin runtime hook and logged rather than turning into the global exception page. +- `app.main.errors/submit-report` is governed by a dedup governor: each report carries a fingerprint (`report-name|type|code|hint|first stack frame`; the report name is part of it so a handled report never coalesces with an unhandled/exception-page one), the first occurrence is always emitted, repeats within 2 minutes are counted and included in the next emitted report as `:occurrences`, and the fingerprint cache is bounded (first-inserted entry evicted, FIFO, via `:order` queue) so memory stays fixed. It applies to `handled-exception`, `unhandled-exception` and `exception-page`; a report without an exception cause is ignored and does not consume a reservation. +- Errors caused by the environment (`environment-error-types`: `:network`, `:offline`, `:bad-gateway`, `:service-unavailable`, `:gateway-error`, `:nitrate-unavailable`, `:nitrate-not-configured`) are not application defects: they are reported as audit-only `handled-exception` (never `unhandled-exception`/`exception-page`, so they skip internal reports and alerts), with a compact report and a fingerprint that drops the stack frame. `:offline` has its own handler and no longer falls through to `:default`; `:network`/`:offline`/`:gateway-error` show the `errors.connection-error` toast. +- `repo/handle-response` classifies responses the backend never wrote (body is not a transit map) by status: 504 and Cloudflare 520–524 → `:gateway-error` (retryable, so GETs and saves retry); 429 → `:rate-limit`; any other ≥400 → `:unexpected-response` (both toast-only via `handle-transport-error`, never the exception page). `:internal :unable-to-process-repository-response` is left for a <400 response that does not decode. Uninterpretable bodies are capped at 500 chars in `:data`. +- `generate-report` accepts an explicit `{:format :compact|:full}` (default `:full`) chosen by its caller (`flash`, `exception-section*`): `:compact` keeps the context header plus type/code/uri, and skips the stack, the `ex-data` dump and the last-events list. It is total: if formatting fails it returns a minimal fallback string instead of nil, so an already reserved emission is never dropped. +- `flash` runs its whole body (report pipeline first, toast after) inside a single `ts/schedule` callback: nothing executes synchronously on the error handler's stack. The report is reserved before being generated, so suppressed occurrences do not pay the `generate-report` cost; a reporting or notification failure is logged to the console and never propagates, and the toast is still attempted. `flash` returns a total promise (never rejects) resolving with the generated report, or nil when nothing is emitted, once the callback completes; production callers ignore it, tests await it. `flash` derives only the payload format from the cause (`environment-error?` → `:compact`); the audit event name is the canonical one requested by `:type` (`handled-exception`/`unhandled-exception`) and is never reclassified, because external tools filter on those names. `exception-section*` picks `handled-exception`/`exception-page` explicitly per cause; `flash-persistence` only adds the toast hint. +- `flash` accepts a `:links` vector of `{:label :callback}` actions, appended after the download link that `:report-link?` produces. `flash-persistence` passes a reload link, so a terminal save failure hands the user both the report and the only action that restarts persistence for the session. `labels.reload-page` plus `js/location.reload` is the established full-restart pattern; the link cannot be invoked under the test runner because `js/location` does not exist there, so it is covered by its label and by being callable. +- `flash-persistence` only delegates to another handler for `:authentication`, `:not-found` and `:code :vern-conflict`. Everything else it reports itself, and the report type is derived from the cause: `#{:validation :server-error :internal}` are application defects and report as `unhandled-exception` so they reach the internal reports and the alerts; the rest stay `handled-exception`. `:cause-type` wins over `:type` when classifying, because `persistence-failed` wraps the original cause under `:type :persistence`. Environment failures are deliberately kept handled: they land here after the retry budget is spent and the user's connection is not our bug. `:assertion` is also kept handled, matching its own handler. +- The report governor is global and stateful across tests: two reports with the same fingerprint inside `report-window-ms` collapse. Call `errors/reset-report-governor!` between assertions that expect an emission. ## Store and websocket diff --git a/.serena/memories/frontend/testing.md b/.serena/memories/frontend/testing.md index 1065d48659..d61be3a1bf 100644 --- a/.serena/memories/frontend/testing.md +++ b/.serena/memories/frontend/testing.md @@ -8,6 +8,19 @@ READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee Frontend unit tests live under `frontend/test/frontend_tests/` and use `cljs.test`. They should be deterministic, avoid DOM/UI integration where possible, and mock side effects such as RPC, storage, timers, or network access. +### Async-first stance + +Frontend testing is async-first: everything essentially asynchronous is modeled with a test reproducing the asynchrony, even when the test could be written "synchronously". Sync-passing tests prove nothing about async behavior and rot as soon as an async boundary appears downstream. Consequences: mock through `frontend-tests.helpers.mock`, never `with-redefs`, except unit tests of purely synchronous functions; transport doubles deliver asynchronously (`observe-on :async`) while the test keeps scenario timing (explicit pushes); assertions always follow quiescence (`wait-for` on presence, bare `settle` tick for absence-only blocks), never a trigger. + +### Mock primitives + +- `mock/with-mocks` (callback style, the default): installs with `set!` so mocks survive async boundaries. Body runs immediately and `done'` restores and completes — so the call site must be inside `t/async`, and `done'` is called exactly once (twice only warns; never calling it stalls the run and leaks the mocks). +- `mock/with-mocks*` (preferred for new tests): body forms are wrapped in a generated `^:async` fn and it evaluates to a promise — `await` it, `await` nested scopes too, no `done` in test code. A rejection or a non-promise return is reported as an `:error` by `run-mocked`, which restores the originals in either case. +- `mock/asap` runs `f` on the next macrotask; used to make a scheduled callback fire under test control. +- `mock/stub` wraps fns for arities 0-6 (the `:esm` test build dispatches multi-arity vars as `cljs$core$IFn$_invoke$arity$N`); when the mocked var is variadic-defined (or called with more than 6 args), the call compiles to variadic dispatch, so use a plain variadic `fn` — the stub does not forward variadic. A mock must not call the mocked var again (self-delegation inside a multi-arity function recurses). +- Helpers in `frontend-tests.helpers.async`: `->promise` (single-value observable → promise; beicon has no `to-promise`), `await-response` (subscribe→push→await, atomic), `settle`, `wait-for` (immediate check + bounded poll, fails instead of hanging), `observe` (stream → termination promise; asserts provided, timeout rejects). +- Valid `^:async` placements: `mem:clojure/idioms`. + From `frontend/`: - Full unit test run (always builds, suppressed output): `pnpm run test:quiet`. - Full unit test run (always builds, build output visible): `pnpm run test`. diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md index 70f0be8f86..57094e1994 100644 --- a/.serena/memories/frontend/workspace-state-persistence-subtleties.md +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -11,17 +11,27 @@ ## Repo calls - `app.main.repo/send!` uses GET only when the RPC name starts with `get-`, when all params are query params, or for configured special cases. Only GET requests are retried. -- GET retry is limited to transient `:network`, `:bad-gateway`, `:service-unavailable`, and `:offline` errors with exponential backoff. Mutations are not retried. +- `repo/retryable-types` is public and is the single source of truth for the transient transport classification. `persistence/transient-error?` and `errors/environment-error-types` both derive from it; extend that set, never copy it. +- GET retry is limited to transient `:network`, `:bad-gateway`, `:service-unavailable`, `:gateway-error` (504, Cloudflare 52x), and `:offline` errors with exponential backoff. Mutations are not retried. - A server SSE response is only accepted when the command is configured `:stream?`; otherwise it raises an unexpected-response assertion. ## Commits, undo, persistence -- `commit-changes` refuses to create commits unless `:permissions :can-edit` is true. It captures file revn/vern, selected-before, features, tags, undo group, and translation flag into a `::commit` event. +- `commit-changes` refuses to create commits unless `:permissions :can-edit` is true. It captures file revn/vern, selected-before, features, tags, undo group, and translation flag into a `::commit` event. A refusal answers with an empty stream, never nil, so callers subscribe uniformly instead of branching on nil. - Applying a remote commit first rolls back pending local commits, applies the remote changes, then replays pending local redo changes. Index updates are emitted for undo, remote redo, and replayed redo paths. - Local commits are independently consumed by undo, persistence, WASM model updates, thumbnail/library watchers, and text position-data recalculation. - Persistence buffers local commits: status becomes pending after about 200ms, commits are flushed after about 3s or `::force-persist`, and buffered commits are merged per file before `:update-file`. - Persistence sends revn as the max of the commit revn and locally tracked latest revn; remote commits update that revn tracker. - Persistence is skipped in version preview/read-only mode or without edit permission. +- A terminal failure halts the queue on purpose: the edits stay queued so `wait-persisted-or-error` keeps rejecting, and `workspace/versions.cljs` relies on that to avoid snapshotting a file whose edits were never saved. `recover-persistence` lifts the halt when a file is opened again or the role changes. A transport failure still inside its retry window (`slow-retry-pending?`) is resent once (`resume-persistence true`), never released; any other halt emits `release-failed-file` (drops every queued commit of the file that failed) then `resume-persistence`. Opening a file also replays the queued edits of its version onto the loaded data (`pending-file-changes` → `restore-pending-changes` in `bundle-fetched`); a replay that throws keeps the server data and the queue. That is what unblocks the editor without a reload; because the queue is shared, dropping the failed file is also what lets other files' pending edits drain. `release-failed-file` runs at recovery time, never at failure time — clearing the error eagerly would make waiters resolve. +- The split between `initialize-persistence-state` and `recover-persistence` is deliberate: initialization deliberately does NOT lift a halt, so undeliverable edits are released when the user acts, not when the view mounts. +- **Every non-transport terminal save failure is permanent.** There is no "stale but valid" category: permission lost, file gone, and server-rejected changes all fail again on a resend. Losing edit permission is not temporary — the backend raises `:not-found` for it (`check-edition-permissions!` → `perms/make-check-fn`), and `:not-found` routes to the exception page. So `:save-permission-denied` (a client-side `can-edit` check) must never be resumed: replaying the save to discover that routes the user to the exception page. Release it instead. +- Save failures split transient vs terminal (`transient-error?`: the repo retryable types `:network`/`:offline`/`:bad-gateway`/`:service-unavailable`/`:gateway-error` plus `:invalid-save-response`; everything else is terminal). Terminal keeps the `:error` halt + `flash-persistence` path; transient enters a `:retrying` episode: the head commit stays queued and resends with backoff 2s/8s/20s (3 retries, then the `:error` path). A transient failure that outlasts the backoff arms `slow-retry-cycle`: one silent resend (`:recovering`) every `slow-retry-delay-ms` (30s) until a save lands, `retry-give-up-ms` (24h since `:failing-since`) passes, or the head commit collects `max-slow-failures` (3) attempts that failed after `slow-failure-ms` (90s) or more (`:slow-failures`, counted in `persistence-transient-failure` from the attempt's elapsed time): a gateway timeout may leave the backend applying the save, so a save that keeps failing slowly must not be replayed for a day, while fast failures (offline) never count; the cycle stops on `::resume-persistence`, `::initialize-persistence` and `finalize-workspace`. Leaving the workspace also emits `suspend-retries` (`:suspended` in `:persistence`, drops `:retry-token` so a pending backoff timer goes stale): no backoff, slow cycle or `online` resend runs outside the workspace; only `workspace-initialized` (not a role change) lifts it via `recover-persistence` (`opened-file?`), which also resends a `:retrying` episode whose backoff was cut short. Its toast is `errors.save-still-retrying` without a reload link; a landing save hides the failure toast (`errors/persistence-failed-tag`). Replaying a save is safe because changes are idempotent, so there is no unknown-outcome guard: a commit whose earlier request left `active-requests` is simply sent again under the same `:commit-id`; only a request still in flight blocks a resend (`:in-flight` stays silent). Tests hold the slow cycle by stubbing `rx/timer` to never fire for `slow-retry-delay-ms`, or instant timers loop forever. Retry timers carry the episode token; a superseded token stays silent. Status stays `:retrying` through re-entries (`next-status` refuses `:pending`/`:saving` from it); waiters (`wait-persisted-or-error`) wait through it and reject only on `:error`. +- One reconnect notice per episode: sticky toast tagged `:persistence-reconnecting` (single-toast store, re-show replaces), hidden by tag on drain (`:saved`) and on terminal failure; recovery is silent. Header indicator has a `:retrying` state (`workspace.header.retrying`). +- Resume triggers: backoff timer and the browser `online` event (guarded by `exists? js/window`; re-enters the runner only for a live `:retrying` episode). New local edits during `:retrying` only join the queue: the episode keeps its `:run-id`, so `append-commit` does not re-enter, and the live runner (still waiting on the head's `commit-persisted`) sends them after the head. Re-entering on edits would bypass the backoff and spend an attempt per batched commit. +- Tests instant-trigger retries by stubbing `rx/timer` (recording delays to assert the schedule); dynamic bindings do not survive `await` continuations, so no dynamic var for delays. +- `save-request-timeout-ms` is 7 minutes and wraps the `update-file` request in `rx/timeout`, failing it as a transient `:network` error so the retry path resends instead of blocking the queue forever. It is deliberately longer than `saving-stall-timeout-ms` (5 min) so the stalled report fires before the retry. +- `sync-unload-guard` keeps `:persistence` in `st/ongoing-tasks` while edits are buffered or queued, which is what holds the browser leave-page warning on. It syncs on `::append-commit`, `::discard-commit` and `::release-failed-file` (every queue change). It is the one effect that must stay in `store.cljs`'s `omitset`, and `store.cljs`'s `ongoing-tasks` watcher is guarded by `exists? js/window` because tests run without a window. - Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50. - Undo/redo are ignored while a normal editor/drawing interaction is active, except grid-layout edition handles undo through this path. - After local commits and when render-wasm is active, text shapes get derived `:position-data` recomputed in a separate commit tagged `#{:position-data}`; that tag is excluded from the position-data watcher to avoid loops. diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index e266224d8c..b9f4dcdeba 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -168,10 +168,13 @@ test.describe("BUG 10934 - Double-clicking a text side handle sets auto-size", ( await workspace.goToWorkspace(); await workspace.waitForFirstRender(); - // Select the text and zoom to fit, so it is fully visible in the viewport + // Select the text and zoom to fit, so it is fully visible in the viewport. + // Wait for the zoom label to change so the handles are at their final spot. await workspace.clickLeafLayer("Fixed text"); + const zoomLabel = page.getByTitle("Zoom", { exact: true }); + const initialZoom = await zoomLabel.innerText(); await page.keyboard.press("Shift+1"); - await workspace.waitForIdle(); + await expect(zoomLabel).not.toHaveText(initialZoom); return workspace; } diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index d849fb95ed..6b902c669b 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -246,21 +246,25 @@ ;; Historical previews must not create edits to the live file. Check ;; this when creating commits so previously queued edits can still save. - (when (and (:can-edit permissions) - (not (dm/get-in state [:workspace-global :preview-id]))) - (log/trace :hint "commit-changes" :redo-changes redo-changes) - (let [selected (dm/get-in state [:workspace-local :selected])] - (rx/of (-> params - (assoc :undo-group undo-group) - (assoc :features features) - (assoc :tags tags) - (assoc :stack-undo? stack-undo?) - (assoc :save-undo? save-undo?) - (assoc :file-id file-id) - (assoc :file-revn (resolve-file-revn state file-id)) - (assoc :file-vern (resolve-file-vern state file-id)) - (assoc :undo-changes uchg) - (assoc :redo-changes rchg) - (assoc :selected-before selected) - (assoc :translation? translation?) - (commit))))))))) + ;; Refusals answer with an empty stream (never nil) so callers can + ;; uniformly subscribe and observe termination. + (if (and (:can-edit permissions) + (not (dm/get-in state [:workspace-global :preview-id]))) + (do + (log/trace :hint "commit-changes" :redo-changes redo-changes) + (let [selected (dm/get-in state [:workspace-local :selected])] + (rx/of (-> params + (assoc :undo-group undo-group) + (assoc :features features) + (assoc :tags tags) + (assoc :stack-undo? stack-undo?) + (assoc :save-undo? save-undo?) + (assoc :file-id file-id) + (assoc :file-revn (resolve-file-revn state file-id)) + (assoc :file-vern (resolve-file-vern state file-id)) + (assoc :undo-changes uchg) + (assoc :redo-changes rchg) + (assoc :selected-before selected) + (assoc :translation? translation?) + (commit))))) + (rx/empty)))))) diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index adb9f10945..76031e7c85 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -8,20 +8,27 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.logging :as log] [app.common.time :as ct] [app.common.uuid :as uuid] [app.main.data.changes :as dch] [app.main.data.common :as-alias dc] [app.main.data.helpers :as dsh] + [app.main.data.notifications :as ntf] [app.main.data.workspace :as-alias dw] [app.main.errors :as errors] [app.main.refs :as refs] [app.main.repo :as rp] + [app.main.store :as st] + [app.util.i18n :refer [tr]] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) (declare ^:private run-persistence-task) +(declare ^:private persist-commit) +(declare ^:private resume-persistence) +(declare ^:private slow-retry-cycle) (log/set-level! :warn) @@ -31,9 +38,40 @@ (def force-persist? #(= % ::force-persist)) +(def slow-retry-delay-ms + "Pause between the attempts of a queue whose backoff is spent: one attempt + per cycle, until it saves or the retry window closes." + 30000) + +(def retry-give-up-ms + "How long a queue that keeps failing on transport errors is sent again." + (* 24 60 60 1000)) + +(def slow-failure-ms + "An attempt that fails after this long may have kept the backend busy, + as a gateway timeout does, so it counts towards `max-slow-failures`." + (* 90 1000)) + +(def max-slow-failures + "Slow failures a queued commit may collect before the slow cycle stops + sending it. Fast failures, such as being offline, never count." + 3) + (def ^:private saving-stall-timeout-ms (* 5 60 1000)) (def ^:private saving-check-interval-ms 30000) (def ^:private save-wait-timeout-ms (* 2 60 1000)) +;; Longer than the stall threshold so a hung save is reported before it is retried. +(def ^:private save-request-timeout-ms (* 7 60 1000)) + +(defn terminal-status? + "True when a persistence snapshot releases waiters: a failed save, or + a settled (`nil` / `:saved`) empty queue. Anything else — `:pending`, + `:saving` and the `:retrying` episode — keeps waiting." + [{:keys [status queue]}] + (boolean + (or (= status :error) + (and (empty? queue) + (or (nil? status) (= status :saved)))))) (defn wait-persisted-or-error "Returns an observable that emits the first terminal persistence status @@ -42,10 +80,7 @@ ([] (wait-persisted-or-error save-wait-timeout-ms)) ([timeout-ms] (let [base (->> (rx/from-atom refs/persistence {:emit-current-value? true}) - (rx/filter (fn [{:keys [status queue]}] - (or (= status :error) - (and (empty? queue) - (or (nil? status) (= status :saved)))))) + (rx/filter terminal-status?) (rx/take 1) (rx/mapcat (fn [{:keys [status error]}] (if (= status :error) @@ -74,12 +109,15 @@ (rx/concat (rx/of ::force-persist) (wait-persisted timeout-ms)))) (defn- next-status - "Refuses downgrades: a save in progress stays :saving, and a failed save - stays :error until persistence is resumed." + "Refuses downgrades: a save in progress stays :saving, a failed save + stays :error until persistence is resumed, and a retrying episode stays + :retrying until it saves or errors (re-entries send under the episode + instead of resetting it)." [from to] (cond (and (= to :pending) (= from :saving)) from (and (= from :error) (#{:pending :saving} to)) from + (and (= from :retrying) (#{:pending :saving} to)) from :else to)) (defn- update-status @@ -98,14 +136,56 @@ (update :last-progress-at d/nilv (inst-ms (ct/now))) (#{:error :saved} status) - (dissoc :run-id :last-progress-at :stall-reported?)))))))) + (dissoc :run-id :last-progress-at :stall-reported)))))))) + +(defn transient-error? + "True when a save failure is worth retrying with backoff: a transient + transport failure (from the shared `repo/retryable-types` set, which says + nothing about the edits themselves) or an unusable save response. + Everything else (auth, validation, state) is terminal and keeps the + `:error` path. Checked against both `:type` and `:cause-type` because + `persistence-failed` wraps the original cause under `:type :persistence`." + [{:keys [type cause-type code]}] + (boolean + (or (contains? rp/retryable-types type) + (contains? rp/retryable-types cause-type) + (= :invalid-save-response code)))) + +(def ^:private retry-delays-ms + "Backoff delays (ms) between save retries; the count is the retry budget. + A plain value (not a dynamic var): dynamic bindings do not survive `await` + continuations, so tests instant-trigger retries by stubbing `rx/timer`." + [2000 8000 20000]) + +(def ^:private reconnecting-tag + "Tag of the single reconnect notice: re-showing replaces it by + construction (the store holds one toast), and it is hidden by tag on + save or on terminal failure." + :persistence-reconnecting) + +(defn- retry-window-open? + "True while a failing queue may still be sent again." + [pstate] + (let [since (:failing-since pstate)] + (or (nil? since) + (< (- (inst-ms (ct/now)) since) retry-give-up-ms)))) + +(defn- slow-retry-pending? + "True when the queue is halted by a transport failure that the slow retry + cycle keeps sending. A commit whose attempts keep failing slowly is not + sent again: each attempt may cost the backend the whole save." + [{:keys [status error slow-failures] :as pstate}] + (and (= :error status) + (transient-error? error) + (retry-window-open? pstate) + (< (or slow-failures 0) max-slow-failures))) (defn- report-stalled-persistence [now] (ptk/reify ::report-stalled-persistence ptk/UpdateEvent (update [_ state] - (assoc-in state [:persistence :stall-reported?] true)) + (assoc-in state [:persistence :stall-reported] true)) ptk/EffectEvent (effect [_ state _] @@ -113,8 +193,8 @@ commit-id (peek queue) commit (get index commit-id) hint "File saving has made no progress for more than five minutes" - cause (ex-info hint - {:type :persistence + cause (ex/error :type :persistence + :hint hint :code :saving-stalled :file-id (or (:file-id commit) (:current-file-id state)) :commit-id commit-id @@ -123,23 +203,25 @@ :queued-commits (count queue) :elapsed-ms (- now last-progress-at) :can-edit (dm/get-in state [:permissions :can-edit]) - :read-only? (dm/get-in state [:workspace-global :read-only?]) + :read-only (dm/get-in state [:workspace-global :read-only?]) :preview-id (dm/get-in state [:workspace-global :preview-id]) - :render-context-lost? (dm/get-in state [:render-state :lost])})] + :render-context-lost? (dm/get-in state [:render-state :lost]))] (errors/submit-report :event-name "handled-exception" :hint hint - :report (errors/generate-report cause)))))) + :report (fn [] (errors/generate-report cause)) + :cause cause))))) (defn- check-persistence [] (ptk/reify ::check-persistence ptk/WatchEvent (watch [_ state _] - (let [{:keys [status last-progress-at stall-reported?]} (:persistence state) + (let [{:keys [status queue last-progress-at stall-reported]} (:persistence state) now (inst-ms (ct/now))] - (when (and (#{:pending :saving} status) + (when (and (#{:pending :saving :retrying} status) + (seq queue) last-progress-at - (not stall-reported?) + (not stall-reported) (> (- now last-progress-at) saving-stall-timeout-ms)) (rx/of (report-stalled-persistence now))))))) @@ -168,7 +250,9 @@ (throw (ex-info "invalid state" {}))))) (update :index dissoc commit-id) (assoc :last-progress-at (inst-ms (ct/now))) - (dissoc :stall-reported?))))))) + (dissoc :stall-reported :failing-since :recovering + :slow-failures :attempts :retry-token + :retry-for))))))) (defn- append-commit "Event used internally to append the current change to the @@ -190,11 +274,31 @@ ptk/WatchEvent (watch [_ state _] (let [pstate (:persistence state)] + ;; A `:retrying` episode keeps its run id, so a new edit only + ;; joins the queue: the live runner sends it after the head, and + ;; resends stay on the backoff schedule and its attempt budget. (when (and (not= :error (:status pstate)) (= run-id (:run-id pstate))) (rx/of (update-status :saving) (run-persistence-task)))))))) +(defn- drop-file-commits + "Removes every queued commit belonging to `file-id`, together with its + index entry." + [pstate file-id] + (if-not file-id + pstate + (let [dropped (into #{} + (keep (fn [commit-id] + (when (= file-id (get-in pstate [:index commit-id :file-id])) + commit-id))) + (:queue pstate))] + (if (empty? dropped) + pstate + (-> pstate + (update :queue #(into (empty %) (remove dropped) %)) + (update :index #(apply dissoc % dropped))))))) + (defn- persistence-failed [commit-id cause] (ptk/reify ::persistence-failed @@ -212,17 +316,87 @@ :commit-id commit-id :hint (ex-message cause) ::errors/handled? true)) - (dissoc :run-id :last-progress-at :stall-reported?)))))) + (update :failing-since d/nilv (inst-ms (ct/now))) + (dissoc :run-id :last-progress-at :stall-reported + :attempts :retry-token :retry-for)))))) ptk/WatchEvent - (watch [_ _ _] - (rx/of (ptk/data-event ::error cause))) + (watch [_ state stream] + (rx/merge + ;; The terminal toast supersedes the reconnect notice; hide it + ;; explicitly instead of relying on the single-toast replacement. + (rx/of (ptk/data-event ::error cause) + (ntf/hide :tag reconnecting-tag)) + ;; A transport failure that outlasts the backoff can still pass, so + ;; the queue keeps trying at a slow pace. + (let [pstate (:persistence state)] + (if (and (not (:suspended pstate)) + (slow-retry-pending? pstate)) + (slow-retry-cycle stream) + (rx/empty))))) ptk/EffectEvent - (effect [_ _ _] - ;; Report without invoking global handlers that may reload the file or - ;; navigate away before the user can recover the retained changes. - (errors/flash-persistence cause)))) + (effect [_ state _] + ;; A failed slow attempt repeats a failure the user was already warned + ;; about. Report without invoking global handlers that may reload the + ;; file or navigate away before the user can recover the retained + ;; changes. + (let [pstate (:persistence state)] + (when-not (and (:recovering pstate) + (slow-retry-pending? pstate)) + (errors/flash-persistence cause :retrying (slow-retry-pending? pstate))))))) + +(defn- persistence-transient-failure + "Transient save failure: the head commit stays queued and a retry is + scheduled with backoff instead of parking the save in `:error`. Once the + budget (`retry-delays-ms`) is exhausted, or when the failed attempt was + one of the slow cycle, the failure falls through to the `persistence-failed` + path, which schedules the next slow attempt. While retries are suspended + nothing is scheduled: the episode waits for a file to be opened again. + `elapsed-ms` is how long the failed attempt took." + [commit-id cause elapsed-ms] + (ptk/reify ::persistence-transient-failure + ptk/UpdateEvent + (update [_ state] + ;; Always counts (even past the budget): the watch routes on the + ;; stored count, so it must read — never recompute — attempts. + (let [attempts (inc (dm/get-in state [:persistence :attempts] 0))] + (update state :persistence + (fn [pstate] + (-> pstate + (assoc :status :retrying + :attempts attempts + :retry-token (uuid/next) + :retry-for commit-id + :last-progress-at (inst-ms (ct/now))) + (update :failing-since d/nilv (inst-ms (ct/now))) + (cond-> (>= elapsed-ms slow-failure-ms) + (update :slow-failures (fnil inc 0))) + (dissoc :stall-reported)))))) + + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [attempts recovering suspended]} (:persistence state)] + (cond + (or recovering (> attempts (count retry-delays-ms))) + (rx/of (persistence-failed commit-id cause)) + + suspended + (rx/empty) + + :else + (rx/merge + ;; One notice per episode: shown on the first attempt, + ;; re-showing would only replace the identical toast. + (when (= 1 attempts) + (rx/of (ntf/show {:content (tr "errors.save-retrying") + :type :toast + :level :warning + :tag reconnecting-tag}))) + (let [delay-ms (nth retry-delays-ms (dec attempts)) + token (dm/get-in state [:persistence :retry-token])] + (->> (rx/timer delay-ms) + (rx/map (fn [_] (persist-commit commit-id {:token token}))))))))))) (defn- commit-persisted [commit] @@ -234,7 +408,7 @@ (update [_ state] ;; Keep the acknowledgment even if the queue runner has stopped. (d/update-in-when state [:persistence :index (:id commit)] - assoc ::acknowledged? true)))) + assoc ::acknowledged true)))) (defn- update-file-request "Issues the `update-file` request, tracked as active for its lifetime." @@ -255,32 +429,38 @@ "Classifies what should happen with a queued commit before sending it. The attempt stamp and the send decision both read this, so a commit is only ever stamped with a request that is actually going to be sent." - [state commit-id request-id] + [state commit-id] (let [commit (dm/get-in state [:persistence :index commit-id])] (cond (= :error (dm/get-in state [:persistence :status])) :halted (nil? commit) :missing-commit - (::acknowledged? commit) :acknowledged + (::acknowledged commit) :acknowledged (contains? @active-requests (::request-id commit)) :in-flight - (and (::request-id commit) - (not= request-id (::request-id commit))) :unknown-outcome (not (dm/get-in state [:permissions :can-edit])) :permission-denied :else :ready))) (defn- send-queued-commit "Sends one queued commit and maps its outcome to persistence events." [request-id session-id {:keys [id file-id file-revn file-vern changes features] :as commit}] - (let [params {:id file-id - :revn (max file-revn (get @revn-data file-id 0)) - :vern file-vern - :session-id session-id - :origin (:origin commit) - :created-at (:created-at commit) - :commit-id id - :changes (vec changes) - :features features}] + (let [sent-at (inst-ms (ct/now)) + params {:id file-id + :revn (max file-revn (get @revn-data file-id 0)) + :vern file-vern + :session-id session-id + :origin (:origin commit) + :created-at (:created-at commit) + :commit-id id + :changes (vec changes) + :features features}] ;; UI read-only mode does not invalidate already queued edits. (->> (update-file-request request-id params) + ;; Fails an unanswered request as a transient network error, so the + ;; retry path resends it instead of blocking the queue. + (rx/timeout save-request-timeout-ms + (rx/throw (ex-info "The save request timed out" + {:type :network + :code :save-request-timeout + :file-id file-id}))) (rx/take 1) ;; A response that carries no revision, including one that never ;; arrived, is treated as a failed save rather than a saved file. @@ -294,19 +474,31 @@ :code :invalid-save-response :file-id file-id}))))) (rx/catch (fn [cause] - (rx/of (persistence-failed id cause))))))) + (rx/of (if (transient-error? (ex-data cause)) + (persistence-transient-failure + id cause (- (inst-ms (ct/now)) sent-at)) + (persistence-failed id cause)))))))) (defn- persist-commit - [commit-id] + "Sends the queued commit, stamping the attempt first. A commit whose + earlier request is no longer in flight is sent again: changes are + idempotent, so a save the backend already applied can be applied again. + Carries an optional `:token`: retry timers pass the episode token, and a + stale token (superseded episode) stays silent instead of sending or + failing." + [commit-id & {:keys [token]}] (let [request-id (uuid/next)] (ptk/reify ::persist-commit ptk/UpdateEvent (update [_ state] - (if (= :ready (attempt-state state commit-id request-id)) - ;; Record the attempt before starting I/O. An interrupted request - ;; may have reached the server and must not be replayed blindly. - (assoc-in state [:persistence :index commit-id ::request-id] request-id) - state)) + (let [token-ok? (or (nil? token) + (= token (dm/get-in state [:persistence :retry-token])))] + (if (and token-ok? + (= :ready (attempt-state state commit-id))) + ;; Stamp the attempt before any I/O, so a request in flight is + ;; never sent twice in parallel. + (assoc-in state [:persistence :index commit-id ::request-id] request-id) + state))) ptk/WatchEvent (watch [_ state _] @@ -317,17 +509,18 @@ :code code :commit-id commit-id :file-id (:file-id commit)}))))] - (case (attempt-state state commit-id request-id) - :halted (rx/empty) - :missing-commit (fail :missing-commit "A queued save has no change data") - :acknowledged (rx/of (commit-persisted commit)) - ;; The replacement runner listens for the original request's result. - :in-flight (rx/empty) - ;; Even :network and :offline do not prove that the server - ;; skipped the write. Keep the attempt stamp to prevent replay. - :unknown-outcome (fail :save-outcome-unknown "An interrupted save has an unknown outcome") - :permission-denied (fail :save-permission-denied "Edit permission was lost before changes could be saved") - :ready (send-queued-commit request-id (:session-id state) commit))))))) + (if (and (some? token) + (not= token (dm/get-in state [:persistence :retry-token]))) + ;; Stale retry timer: its episode was superseded. Stay silent. + (rx/empty) + (case (attempt-state state commit-id) + :halted (rx/empty) + :missing-commit (fail :missing-commit "A queued save has no change data") + :acknowledged (rx/of (commit-persisted commit)) + ;; The replacement runner listens for the original request's result. + :in-flight (rx/empty) + :permission-denied (fail :save-permission-denied "Edit permission was lost before changes could be saved") + :ready (send-queued-commit request-id (:session-id state) commit)))))))) (defn- run-persistence-task @@ -354,49 +547,199 @@ (rx/filter #(= commit-id (:id %))) (rx/take 1) (rx/mapcat (fn [_] + ;; A save that lands shows the connection + ;; is back: the failure warning goes away. (rx/of (discard-commit commit-id) + (ntf/hide :tag errors/persistence-failed-tag) (run-persistence-task))))) (rx/of (persist-commit commit-id))) (rx/take-until stoper-s))) :else - (rx/of (update-status :saved))))))) + (rx/of (update-status :saved) + (ntf/hide :tag reconnecting-tag))))))) (defn- resume-persistence + "Starts the queue again. A `recovering` resume is an attempt of the slow + cycle, which sends once instead of starting a backoff episode." + ([] (resume-persistence false)) + ([recovering] + (ptk/reify ::resume-persistence + ptk/UpdateEvent + (update [_ state] + (update state :persistence + (fn [pstate] + (-> pstate + (dissoc :error :attempts :retry-token :retry-for) + (assoc :run-id (uuid/next) + :status :saving + :recovering recovering) + (update :last-progress-at d/nilv (inst-ms (ct/now))))))) + ptk/WatchEvent + (watch [_ _ _] + (rx/of (run-persistence-task)))))) + +(defn- slow-retry-cycle + "Resumes a failed queue after `slow-retry-delay-ms`, unless the queue is + resumed some other way, persistence restarts or the workspace closes." + [stream] + (let [stopper-s (rx/merge + (rx/filter (ptk/type? ::resume-persistence) stream) + (rx/filter (ptk/type? ::initialize-persistence) stream) + (rx/filter (ptk/type? ::dw/finalize-workspace) stream))] + (->> (rx/timer slow-retry-delay-ms) + (rx/map (fn [_] (resume-persistence true))) + (rx/take-until stopper-s)))) + +(defn- suspend-retries + "Stops retrying when the workspace closes. A pending backoff timer goes + stale, and no backoff or slow cycle is armed until a file is opened + again. Queued edits stay queued and a request in flight still lands." [] - (ptk/reify ::resume-persistence + (ptk/reify ::suspend-retries ptk/UpdateEvent (update [_ state] (update state :persistence - (fn [pstate] - (-> pstate - (dissoc :error) - (assoc :run-id (uuid/next) :status :saving) - (update :last-progress-at d/nilv (inst-ms (ct/now))))))) - ptk/WatchEvent - (watch [_ _ _] - (rx/of (run-persistence-task))))) + #(-> % + (assoc :suspended true) + (dissoc :retry-token)))))) -(defn- recover-persistence +(defn- restore-retries + "Lets failed saves be retried again once a file is opened." [] - (ptk/reify ::recover-persistence + (ptk/reify ::restore-retries + ptk/UpdateEvent + (update [_ state] + (update state :persistence dissoc :suspended)))) + +(defn- release-failed-file + "Drops the queued commits of the file whose save failed terminally. + + This runs when the user opens a file again, not at failure time. The + failure itself stays visible, because `wait-persisted-or-error` must keep + rejecting: callers use it to avoid snapshotting a file whose edits were + never saved. What must not survive is the halt. Left in place, the failed + commits sit at the head of the queue forever and, because the queue is + shared, they also block the pending edits of every other file." + [] + (ptk/reify ::release-failed-file + ptk/UpdateEvent + (update [_ state] + (let [commit-id (get-in state [:persistence :error :commit-id]) + file-id (get-in state [:persistence :index commit-id :file-id])] + (update state :persistence + #(-> % + (drop-file-commits file-id) + (dissoc :failing-since :slow-failures))))))) + +(defn- queue-needs-a-runner? + "True when the queue still holds commits that nobody is sending. + + The runner does not survive a subscription teardown, so both + initialization and recovery hit this. A halted queue is excluded: the + halt is lifted by `recover-persistence`, which has its own triggers. A + retrying episode is excluded too: its backoff timer owns the recovery, + or `recover-persistence` resumes it when retries were suspended." + [{:keys [queue status]}] + (and (seq queue) + (not= :error status) + (not= :retrying status))) + +(defn pending-file-changes + "Queued edits to restore into a freshly loaded file, in queue order. + Only edits of the loaded version belong in its data. A permanently + failed file is excluded because recovery releases its edits." + [{:keys [persistence]} {:keys [id vern]}] + (let [{:keys [queue index status error]} persistence + failed-file-id (get-in index [(:commit-id error) :file-id])] + (if (and (= :error status) + (= id failed-file-id) + (not (slow-retry-pending? persistence))) + [] + (into [] + (comp (map #(get index %)) + (filter #(and (= id (:file-id %)) + (= vern (:file-vern %)))) + (mapcat :redo-changes)) + queue)))) + +(defn- initialize-persistence-state + "Brings persistence to a coherent starting shape when the workspace + mounts. + + Deliberately leaves a halted or retrying queue alone. Lifting a halt is + `recover-persistence`'s job, and it waits for the user to open a file, so + doing it here would release edits the moment the view mounts rather than + when the user acts on them." + [] + (ptk/reify ::initialize-persistence-state ptk/WatchEvent (watch [_ state _] - (let [{:keys [queue index status error run-id]} (:persistence state) - commit (get index (peek queue))] + (let [{:keys [queue status run-id]} (:persistence state)] (cond - (and (seq queue) - (or (not= status :error) - (and (= :save-permission-denied (:code error)) - (not (::request-id commit)) - (= (:file-id commit) (:current-file-id state)) - (dm/get-in state [:permissions :can-edit])))) + ;; A commit left queued by a previous session, with no runner. + (queue-needs-a-runner? (:persistence state)) (rx/of (resume-persistence)) + ;; Nothing queued and nothing in flight: there is nothing to save. (and (empty? queue) (not= status :error) (or run-id (#{:pending :saving} status))) - (rx/of (update-status :saved))))))) + (rx/of (update-status :saved)) + + :else + (rx/empty)))))) + +(defn- recover-persistence + "Lifts a halted queue when a file is opened again or the role changes. + + Leaving the workspace suspends retries (`suspend-retries`), so opening a + file (`opened-file?`) restores them, resends a retrying episode whose + backoff was cut short, and sends a failure the slow cycle still covers + (`slow-retry-pending?`) once more instead of dropping it. + + Every other terminal failure is permanent for that file: permission lost, + file gone, or changes the server refuses. None of them can succeed on a + resend, so opening a file releases them instead of replaying them. + Retaining them until here is deliberate: it is what keeps + `wait-persisted-or-error` rejecting, which is what stops + `workspace/versions.cljs` from building a snapshot out of unsaved edits. + + A lost permission is not a temporary condition. The backend raises + `:not-found` for it, and replaying the save to find that out routes the + user to the exception page." + [opened-file?] + (ptk/reify ::recover-persistence + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [queue status run-id suspended]} (:persistence state)] + (rx/concat + (if (and opened-file? suspended) + (rx/of (restore-retries)) + (rx/empty)) + (cond + ;; A live retry episode owns its recovery through the backoff + ;; scheduler; resuming here would bypass the attempt budget. + ;; A suspended one has no scheduler left. + (= :retrying status) + (if (and opened-file? suspended) + (rx/of (resume-persistence)) + (rx/empty)) + + (slow-retry-pending? (:persistence state)) + (rx/of (resume-persistence true)) + + (= :error status) + (rx/of (release-failed-file) (resume-persistence)) + + (queue-needs-a-runner? (:persistence state)) + (rx/of (resume-persistence)) + + (and (empty? queue) (or run-id (#{:pending :saving} status))) + (rx/of (update-status :saved)) + + :else + (rx/empty))))))) (def ^:private xf-mapcat-undo (mapcat :undo-changes)) @@ -415,6 +758,31 @@ (assoc :redo-changes rchg) (assoc :changes rchg))))))) +(defn- resume-on-online + "Re-enters the runner for a retrying episode when the browser reports + connectivity back, instead of waiting out the backoff. Terminal failures + stay terminal: only a live `:retrying` episode resumes, never one + suspended outside the workspace." + [] + (ptk/reify ::resume-on-online + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [status queue suspended]} (:persistence state)] + (if (and (seq queue) (= :retrying status) (not suspended)) + (rx/of (run-persistence-task)) + (rx/empty)))))) + +(defn- sync-unload-guard + "Turns the browser's leave-page warning on while edits are unsaved: + buffered, queued, or retained after a failed save." + [buffered?] + (ptk/reify ::sync-unload-guard + ptk/EffectEvent + (effect [_ state _] + (if (or buffered? (seq (dm/get-in state [:persistence :queue]))) + (swap! st/ongoing-tasks conj :persistence) + (swap! st/ongoing-tasks disj :persistence))))) + (defn initialize-persistence [] (ptk/reify ::initialize-persistence @@ -423,6 +791,9 @@ (log/debug :hint "initialize persistence") (let [stoper-s (rx/filter (ptk/type? ::initialize-persistence) stream) + ;; True while local commits wait in the buffer for the next flush. + buffered? (volatile! false) + local-commits-s (->> stream (rx/filter dch/commit?) @@ -440,22 +811,51 @@ (rx/filter #(= % ::force-persist))))] (rx/merge - (rx/of (recover-persistence)) + (rx/of (initialize-persistence-state)) + + (->> stream + (rx/filter (ptk/type? ::dw/finalize-workspace)) + (rx/map (fn [_] (suspend-retries))) + (rx/take-until stoper-s)) (->> stream (rx/filter #(or (ptk/type? ::dc/change-team-role %) (ptk/type? ::dw/workspace-initialized %))) - (rx/map (fn [_] (recover-persistence))) + (rx/map #(recover-persistence (ptk/type? ::dw/workspace-initialized %))) (rx/take-until stoper-s)) (->> (rx/interval saving-check-interval-ms) (rx/map (fn [_] (check-persistence))) (rx/take-until stoper-s)) + ;; The browser knows when connectivity returns: re-enter the + ;; runner for a retrying episode instead of waiting out the + ;; backoff. No `window` outside the browser (tests, SSR). + (or (when (exists? js/window) + (->> (rx/from-event js/window "online") + (rx/map (fn [_] (resume-on-online))) + (rx/take-until stoper-s))) + (rx/empty)) + (->> notifier-s (rx/map #(ptk/data-event ::persistence-notification)) (rx/take-until stoper-s)) + ;; Syncs the leave-page warning on every local commit and on every + ;; queue change (append after a flush, discard after a save, + ;; release of a failed file). + (->> (rx/merge + (rx/of false) + (->> local-commits-s + (rx/map (fn [_] (vreset! buffered? true)))) + (->> stream + (rx/filter #(or (ptk/type? ::append-commit %) + (ptk/type? ::discard-commit %) + (ptk/type? ::release-failed-file %))) + (rx/map (fn [_] @buffered?)))) + (rx/map sync-unload-guard) + (rx/take-until stoper-s)) + (->> local-commits-s (rx/debounce 200) (rx/map (fn [_] @@ -468,6 +868,7 @@ (->> local-commits-s (rx/take-until stoper-s) (rx/buffer-until notifier-s) + (rx/tap (fn [_] (vreset! buffered? false))) (rx/mapcat merge-commit) (rx/map append-commit) (rx/finalize (fn [] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index bfa0276ce9..128fee32b3 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -9,6 +9,7 @@ [app.common.attrs :as attrs] [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.changes :as cpc] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.variant :as cfv] @@ -321,6 +322,22 @@ :file-id file-id :team-id team-id))))))) +(defn- restore-pending-changes + "Applies the edits still waiting to be saved to a freshly loaded `file`. + An edit that no longer applies to the loaded data leaves the file as the + server sent it; the save queue keeps the edits either way." + [state file] + (let [changes (dps/pending-file-changes state file)] + (if (empty? changes) + file + (try + (update file :data cpc/process-changes changes false) + (catch :default cause + (log/wrn :hint "unable to restore pending changes" + :file-id (dm/str (:id file)) + :cause cause) + file))))) + (defn- bundle-fetched [{:keys [file file-id thumbnails] :as bundle}] (ptk/reify ::bundle-fetched @@ -330,9 +347,10 @@ ptk/UpdateEvent (update [_ state] - (-> state - (assoc :thumbnails (d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil}))) - (update :files assoc file-id file))))) + (let [file (restore-pending-changes state file)] + (-> state + (assoc :thumbnails (d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil}))) + (update :files assoc file-id file)))))) (defn zoom-to-frame [] diff --git a/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs b/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs index 374c163938..fb43c3005f 100644 --- a/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs +++ b/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs @@ -65,15 +65,18 @@ [{:keys [objects id]}] (reduce-kv (fn [changes shape-id shape] (if (shape-has-invalid-font-family?? shape) - (conj changes {:type :mod-obj - :id shape-id - :page-id id - :operations [{:type :set - :attr :content - :val (fix-shape-content shape)} - {:type :set - :attr :position-data - :val nil}]}) + (let [fixed-content (fix-shape-content shape)] + (if (not= fixed-content (:content shape)) + (conj changes {:type :mod-obj + :id shape-id + :page-id id + :operations [{:type :set + :attr :content + :val fixed-content} + {:type :set + :attr :position-data + :val nil}]}) + changes)) changes)) [] objects)) @@ -82,8 +85,11 @@ [fdata] (reduce-kv (fn [changes _ typography] (if (has-invalid-font-family? typography) - (conj changes {:type :mod-typography - :typography (fix-typography typography)}) + (let [fixed-typography (fix-typography typography)] + (if (not= fixed-typography typography) + (conj changes {:type :mod-typography + :typography fixed-typography}) + changes)) changes)) [] (:typographies fdata))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index c2689420de..28e6e54a9b 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -16,6 +16,7 @@ [app.main.data.nitrate :as dnt] [app.main.data.notifications :as ntf] [app.main.data.workspace :as-alias dw] + [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] [app.main.worker] @@ -127,50 +128,269 @@ ;; Set the main potok error handler (reset! st/on-error on-error) -(defn generate-report +;; --- Environment failures +;; +;; Failures caused by the user's environment (connectivity, a degraded or +;; misconfigured service) are not application defects. They are reported to the +;; audit log as compact events and must never reach the internal error reports +;; (`unhandled-exception`/`exception-page`), which trigger alerts. + +(def environment-error-types + "Error types produced by the environment rather than by an application + defect: the shared transient transport set (`repo/retryable-types`) plus + the nitrate-specific failures." + (into rp/retryable-types #{:nitrate-unavailable + :nitrate-not-configured})) + +(defn environment-error? [cause] + (contains? environment-error-types (:type (ex-data cause)))) + +(defn- report-context + "Common context header for every report format." + [cause] + (let [team-id (:current-team-id @st/state) + file-id (:current-file-id @st/state) + profile-id (:profile-id @st/state) + data (ex-data cause)] + (with-out-str + (println "Context:") + (println "--------------------") + (println "Timestamp:" (ct/format-inst (ct/now) :rfc1123)) + (println "Hint: " (or (:hint data) (ex-message cause) "--")) + (println "Prof ID: " (str (or profile-id "--"))) + (println "Team ID: " (str (or team-id "--"))) + (when-let [file-id (or (:file-id data) file-id)] + (println "File ID: " (str file-id))) + (println "Version: " (:full cf/version)) + (println "HREF: " (rt/get-current-href))))) + +(defn- generate-full-report + "Complete report: context, formatted throwable (including `ex-data`) and the + last events." + [cause] + (with-out-str + (print (report-context cause)) + (println) + + (println + (ex/format-throwable cause)) + (println) + + (println "Last events:") + (println "--------------------") + (println (st/format-last-events)) + (println))) + +(defn- generate-compact-report + "Reduced report for environment failures: context plus the error type, code + and uri. It skips the stack trace, the `ex-data` dump (which may contain + request headers) and the last-events list." + [cause] + (let [data (ex-data cause)] + (with-out-str + (print (report-context cause)) + (println) + (println "Error:") + (println "--------------------") + (println "Type: " (or (:type data) "--")) + (println "Code: " (or (:code data) "--")) + (when-let [uri (:uri data)] + (println "URI: " uri))))) + +(defn generate-report + "Build the report string for `cause`. + + `:format` selects the payload: `:full` (default) includes the formatted + throwable and the last events; `:compact` keeps only the context and the + error type/code/uri, for environment failures. The option is accepted both + as keyword arguments and as a trailing map." + [cause & {:keys [format] :or {format :full}}] (try - (let [team-id (:current-team-id @st/state) - file-id (:current-file-id @st/state) - profile-id (:profile-id @st/state) - data (ex-data cause)] + (case format + :compact (generate-compact-report cause) + (generate-full-report cause)) + (catch :default err + (.error js/console "error on generating report" err) + ;; Keep this function total: `flash` reserves a report slot before + ;; generating it, so returning nil here would consume the slot + ;; without emitting anything. + (str "Report generation failed: " (or (ex-message err) "--") + "\nOriginal hint: " (or (ex/get-hint cause) "--"))))) - (with-out-str - (println "Context:") - (println "--------------------") - (println "Timestamp:" (ct/format-inst (ct/now) :rfc1123)) - (println "Hint: " (or (:hint data) (ex-message cause) "--")) - (println "Prof ID: " (str (or profile-id "--"))) - (println "Team ID: " (str (or team-id "--"))) - (when-let [file-id (or (:file-id data) file-id)] - (println "File ID: " (str file-id))) - (println "Version: " (:full cf/version)) - (println "HREF: " (rt/get-current-href)) - (println) +;; --- Error report governor +;; +;; Bounds the volume of reports emitted by a single browser session. Each +;; report carries a fingerprint; the first occurrence is always emitted and +;; repeated occurrences of the same fingerprint within `report-window-ms` +;; are counted but not emitted. The next emitted report carries the number +;; of occurrences since the previous one as `:occurrences`. The report name +;; is part of the fingerprint, so a handled report never coalesces with an +;; unhandled/exception-page report of the same cause. +;; +;; The fingerprint cache is bounded: when it is full, the fingerprint +;; inserted first is evicted (FIFO order), so memory cannot grow without +;; limit. Eviction drops the evicted fingerprint's pending counter with +;; it, so its next occurrence emits as a fresh report (`:occurrences` +;; 1): a bounded precision trade-off, not an accounting bug. - (println - (ex/format-throwable cause)) - (println) +(def report-window-ms + "Minimum time between two reports with the same fingerprint." + (* 2 60 1000)) - (println "Last events:") - (println "--------------------") - (println (st/format-last-events)) - (println))) - (catch :default cause - (.error js/console "error on generating report" cause) - nil))) +(def max-tracked-fingerprints + "Maximum number of fingerprints kept in the governor cache." + 2000) + +(defn initial-report-state + [] + {:entries {} + :order #queue []}) + +(defonce ^:private report-governor + (atom (initial-report-state))) + +(defn reset-report-governor! + "Testing helper: clear the governor state." + [] + (reset! report-governor (initial-report-state))) + +(defn- label + [v] + (cond + (nil? v) "" + (keyword? v) (name v) + (string? v) v + :else (str v))) + +(defn error-fingerprint + "Stable identity of an error, used to group repeated reports. + + The report name is part of the identity, so a `handled-exception` report + never coalesces with an `unhandled-exception`/`exception-page` report of + the same cause (those two do reach the error reports and alerts). + + Environment failures drop the stack frame: their internal call site is an + implementation detail, and keeping it would fragment the grouping." + [event-name cause] + (let [data (ex-data cause) + ftype (or (:type data) :unknown) + code (or (:code data) :unknown) + hint (or (ex/get-hint cause) "") + base (str (label event-name) "|" (label ftype) "|" (label code) "|" + (str/prune hint 120))] + (if (environment-error? cause) + base + (let [;; A JS stack string starts with "Error: ". The first + ;; actual frame is the first subsequent line shaped like a + ;; frame: skipping the message line and matching on `(` tolerates + ;; wrapper-prepended stacks instead of trusting the position. + frame (or (some->> (.-stack cause) + (str/lines) + (drop 1) + (filter #(str/includes? % "(")) + (first)) + "")] + (str base "|" (str/prune frame 120)))))) + +(defn- evict-oldest + "Drops the fingerprint inserted first. `:order` mirrors the insertion + order of `:entries`, so this is O(1)." + [state] + (let [fingerprint (peek (:order state))] + (-> state + (update :entries dissoc fingerprint) + (update :order pop)))) + +(defn reserve-report* + "Pure decision step of the report governor. + + Given the governor `state`, an error `fingerprint` and the current time in + milliseconds, returns the next governor state with this occurrence's + decision attached: `::emit` tells whether it must be emitted and + `::occurrences` carries the counter (present only when `::emit` is true)." + [state fingerprint now] + (let [entry (get-in state [:entries fingerprint]) + emit? (or (nil? entry) + (>= (- now (:emitted-at entry)) report-window-ms)) + pending (or (:pending entry) 0)] + (cond + ;; New fingerprint: insert it, evicting the oldest when the cache + ;; is full. + (and emit? (nil? entry)) + (let [state (cond-> state + (>= (count (:entries state)) max-tracked-fingerprints) + (evict-oldest))] + (-> state + (assoc-in [:entries fingerprint] {:emitted-at now :pending 0}) + (update :order conj fingerprint) + (assoc ::emit true) + (assoc ::occurrences (inc pending)))) + + ;; Known fingerprint re-emitted after the window: keep its position. + emit? + (-> state + (assoc-in [:entries fingerprint] {:emitted-at now :pending 0}) + (assoc ::emit true) + (assoc ::occurrences (inc pending))) + + ;; Suppressed occurrence: only the counter moves. + :else + (-> state + (update-in [:entries fingerprint :pending] inc) + (assoc ::emit false) + (dissoc ::occurrences))))) + +(defn reserve-report! + "Reserve a slot for a report. Returns the updated governor state, whose + `::emit`/`::occurrences` describe the decision for this occurrence." + [fingerprint now] + (swap! report-governor reserve-report* fingerprint now)) + +(defn- reserve! + "Common reservation step shared by `submit-report` and the `flash` + pipeline: fingerprint the cause and ask the governor. Returns the + occurrence count when this report must be emitted, nil when the + governor suppresses it." + [event-name cause] + (let [state (reserve-report! (error-fingerprint event-name cause) + (inst-ms (ct/now)))] + (when (::emit state) + {:occurrences (::occurrences state)}))) + +(defn- emit-report! + "Emit the audit event for a report that is already reserved by the + governor." + [event-name report hint occurrences] + (st/emit! + (ev/event {::ev/name event-name + :hint hint + :href (rt/get-current-href) + :report report + :occurrences occurrences}))) (defn submit-report - "Report the error report to the audit log subsystem" - [& {:keys [event-name report hint] :or {event-name "unhandled-exception"}}] - (when (and (not (str/empty? hint)) - (string? report) + "Report the error report to the audit log subsystem, subject to the + report governor. + + `cause` must be the exception the report describes: a report without a + cause is ignored (and does not consume a governor reservation), so every + report shares the same fingerprint format. + + `report` is either the report string or a zero-arg function building + it: the function runs only when the governor grants emission, so + suppressed occurrences never pay the report-building cost." + [& {:keys [event-name report hint cause] + :or {event-name "unhandled-exception"}}] + (when (and (ex/exception? cause) + (not (str/empty? hint)) + (or (string? report) (fn? report)) (string? event-name)) - (st/emit! - (ev/event {::ev/name event-name - :hint hint - :href (rt/get-current-href) - :report report})))) + (when-let [{:keys [occurrences]} (reserve! event-name cause)] + (emit-report! event-name + (if (fn? report) (report) report) + hint + occurrences)))) (defn- download-report! [report event] @@ -180,47 +400,148 @@ (dom/trigger-download-uri "report" "text/plain" uri) (ts/schedule-on-idle #(wapi/revoke-uri uri)))) +(defn- report-format + "Payload format for `cause`: compact for environment failures, full + otherwise." + [cause] + (if (environment-error? cause) :compact :full)) + +(defn- emit-flash-report! + "Reserves, generates and emits the flash report. Returns the generated + report string, or nil when nothing is emitted (non-exception cause, + `:silent` type, empty hint or denied governor reservation)." + [type cause] + (when (ex/exception? cause) + (when-let [event-name (case type + :handled "handled-exception" + :unhandled "unhandled-exception" + :silent nil)] + (let [format (report-format cause) + report-hint (ex/get-hint cause)] + (when (and (string? report-hint) (not (str/empty? report-hint))) + (when-let [{:keys [occurrences]} (reserve! event-name cause)] + (let [generated (generate-report cause {:format format})] + (emit-report! event-name + generated + report-hint + occurrences) + generated))))))) + (defn flash "Show error notification banner and emit error report. A nil timeout keeps the notification visible until dismissed or replaced. + A `:tag` lets the notification be hidden later by that tag. - The notification is scheduled asynchronously (via tm/schedule) to + The payload format is derived from the cause: environment failures get a + compact report. The audit event name is the canonical one requested by + `:type` (`handled-exception`/`unhandled-exception`); it is an external + contract, so flash never reclassifies it. + + The report is reserved before being generated, so repeated errors that + fall inside the governor window do not pay the report-building cost. + With `:report-link?` the toast always carries a download link: when no + report is emitted (suppressed repeat, empty hint, `:silent`), one is + generated for the link alone, without emitting it. + + The whole body (report pipeline first, toast after) runs inside a single + `ts/schedule` callback: nothing report- or toast-related executes + synchronously on the error handler's stack. A failure while reporting or + notifying is logged to the console and never propagates; the toast is + still attempted. + + Returns a promise resolving with the generated report (or nil when + nothing is emitted) once the scheduled callback completes. The promise + is total: it never rejects. Production callers ignore it + (fire-and-forget); it exists so tests can await completion instead of + reasoning about timer order. + + The notification is scheduled asynchronously (via `ts/schedule`) to avoid pushing a new event into the potok store while the store's own error-handling pipeline is still on the call stack. Emitting synchronously from inside an error handler creates a re-entrant event-processing cycle that can exhaust the JS call stack (RangeError: Maximum call stack size exceeded)." - [& {:keys [type hint cause timeout report-link?] + [& {:keys [type hint cause timeout tag report-link? links] :or {type :handled timeout 5000}}] - (let [report (when (ex/exception? cause) (generate-report cause))] - (when report - (when-let [event-name (case type - :handled "handled-exception" - :unhandled "unhandled-exception" - :silent nil)] - (submit-report :event-name event-name - :report report - :hint (ex/get-hint cause)))) + (js/Promise. + (fn [resolve _reject] + (ts/schedule + (fn [] + (let [report (try (emit-flash-report! type cause) + (catch :default err + (.error js/console "error on emitting report" err) + nil)) + link-report (when report-link? + (or report + (when (ex/exception? cause) + (generate-report cause {:format (report-format cause)})))) + ;; The download link comes first because `:report-link?` is + ;; what puts the report on the toast at all; caller links are + ;; the recovery actions and follow it. + all-links (cond-> [] + link-report + (conj {:label (tr "labels.download" "report.txt") + :callback (partial download-report! link-report)}) + (seq links) (into links))] + (try (st/emit! + (ntf/show + (cond-> {:content (or ^boolean hint (tr "errors.generic")) + :type :toast + :level :error + :timeout timeout} + (some? tag) + (assoc :tag tag) - (ts/schedule - #(st/emit! - (ntf/show - (cond-> {:content (or ^boolean hint (tr "errors.generic")) - :type :toast - :level :error - :timeout timeout} - (and report-link? report) - (assoc :links [{:label (tr "labels.download" "report.txt") - :callback (partial download-report! report)}]))))))) + (seq all-links) + (assoc :links all-links)))) + (catch :default err + (.error js/console "error on emitting toast" err))) + (resolve report))))))) + +(defn- handle-connectivity-error + "Report a failure caused by the user's connectivity. These are audit-only + telemetry with a compact payload: they never reach the internal error + reports and a stack trace adds nothing for a network condition." + [error prefix] + (when-let [cause (::instance error)] + (ex/print-throwable cause :prefix prefix)) + (flash :cause (::instance error) + :type :handled + :hint (tr "errors.connection-error"))) + +(defn- handle-transport-error + "Reports a failure whose answer did not come from the backend. These affect + one request and pass on their own, so the user keeps the page and gets a + toast." + [error prefix] + (when-let [cause (::instance error)] + (ex/print-throwable cause :prefix prefix)) + (flash :cause (::instance error) :type :handled)) (defmethod ptk/handle-error :network [error] ;; Transient network errors (e.g. lost connectivity, DNS failure) ;; should not replace the entire page with an error screen. Show a ;; non-intrusive toast instead and let the user continue working. - (when-let [cause (::instance error)] - (ex/print-throwable cause :prefix "Network Error")) - (flash :cause (::instance error) :type :handled)) + (handle-connectivity-error error "Network Error")) + +(defmethod ptk/handle-error :offline + [error] + ;; Status 0 (browser offline) must not fall through to `:default`: + ;; that would report it as an unhandled application error. + (handle-connectivity-error error "Offline Error")) + +(defmethod ptk/handle-error :gateway-error + [error] + (handle-connectivity-error error "Gateway Error")) + +(defmethod ptk/handle-error :rate-limit + [error] + (handle-transport-error error "Rate Limit Error")) + +(defmethod ptk/handle-error :unexpected-response + [error] + (handle-transport-error error "Unexpected Response")) (def ^:private delegated-persistence-types "Save failure causes routed to their own error handler: retaining the @@ -234,18 +555,72 @@ ;; The retained changes no longer apply to the restored version. (= :vern-conflict code))) +(def ^:private unhandled-persistence-types + "Save failure causes that are application defects, not expected + outcomes: the client asked for something the server refused, or the + server or our own code broke. They must reach the internal reports and + the alerts, so they are reported as unhandled. + Environment failures are deliberately absent: the user's connection is + not our bug, and they land here once the retry budget is spent. + `:assertion` is absent on purpose too, because its own handler already + reports it as handled." + #{:validation :server-error :internal}) + +(defn- persistence-report-type + "The audit event name a save failure gets. `persistence-failed` wraps the + original cause under `:type :persistence` and keeps the real type in + `:cause-type`, so `:cause-type` wins: reading `:type` alone would match the + wrapper and never the cause." + [{:keys [type cause-type]}] + (if (contains? unhandled-persistence-types (or cause-type type)) + :unhandled + :handled)) + +(defn- reload-page-link + "Recovery action for a save failure: reload the window. + + Persistence stops for the session once a save fails terminally, and + reloading is the only way to get a fresh one without the user having to + know they can also leave the file and come back. `location.reload` is the + pattern used elsewhere in the app for a full restart." + [] + {:label (tr "labels.reload-page") + :callback (fn [event] + (dom/prevent-default event) + (js/location.reload))}) + +(def persistence-failed-tag + "Tag of the save failure notification, hidden once a save lands." + :persistence-failed) + (defn flash-persistence - [cause] + "Notifies a save failure. With `:retrying` the failure is transient and + the queue keeps trying in the background, so the toast says the changes + are kept and offers no reload, which would lose them." + [cause & {:keys [retrying]}] (let [data (ex-data cause)] - (if (delegated-persistence-failure? data) + (cond + (delegated-persistence-failure? data) ;; The persistence state wraps the failure and records the original ;; type under :cause-type; dispatch on it to reach the cause's handler. (on-error (-> (exception->error-data cause) (assoc :type (or (:cause-type data) (:type data))))) + + retrying (flash :cause cause - :type :handled + :type (persistence-report-type data) :timeout nil + :tag persistence-failed-tag :report-link? true + :hint (tr "errors.save-still-retrying")) + + :else + (flash :cause cause + :type (persistence-report-type data) + :timeout nil + :tag persistence-failed-tag + :report-link? true + :links [(reload-page-link)] :hint (tr "errors.save-failed"))))) (defmethod ptk/handle-error :persistence diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index d4fc1da492..1593692d22 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -23,12 +23,16 @@ ;; -- Retry helpers ----------------------------------------------------------- -(def ^:private retryable-types +(def retryable-types "Set of error types that are considered transient and safe to retry - for idempotent (GET) requests." + for idempotent (GET) requests. Also the single source of truth for the + transient transport classification consumed by `persistence/transient-error?` + and `errors/environment-error-types`: extend this set (never a copy) + when a new retryable transport failure appears." #{:network ; js/fetch network-level failure :bad-gateway ; 502 :service-unavailable ; 503 + :gateway-error ; 504 and the Cloudflare 52x family :offline}) ; status 0 (browser offline) (defn retryable-error? @@ -76,6 +80,28 @@ ;; -- Response handling ------------------------------------------------------- +(def ^:private gateway-statuses + "Statuses raised by an edge proxy when the request never reached the + backend or its answer never came back." + #{504 ; gateway timeout + 520 ; cloudflare: unknown origin error + 521 ; cloudflare: origin is down + 522 ; cloudflare: connection timed out + 523 ; cloudflare: origin unreachable + 524}) ; cloudflare: origin timed out + +(def ^:private max-body-excerpt + "Number of characters kept from a body that we could not interpret." + 500) + +(defn- body-excerpt + "Keeps the head of an uninterpretable body. Error pages from proxies run + into tens of kilobytes and the excerpt is enough to recognize them." + [body] + (if (and (string? body) (> (count body) max-body-excerpt)) + (str (subs body 0 max-body-excerpt) "...") + body)) + (defn handle-response [{:keys [status body headers uri] :as response}] (cond @@ -118,6 +144,35 @@ (and (>= status 400) (map? body)) (rx/throw (ex-info "http error" (assoc body :uri uri :status status))) + ;; The branches below handle responses the backend never wrote: the + ;; body carries no error code, so the status is all we can read. + + (contains? gateway-statuses status) + (rx/throw + (ex/error :type :gateway-error + :code :gateway-error + :hint "the request did not reach the backend" + :uri uri + :status status)) + + (= 429 status) + (rx/throw + (ex/error :type :rate-limit + :code :rate-limited + :hint "too many requests" + :uri uri + :status status)) + + (>= status 400) + (rx/throw + (ex/error :type :unexpected-response + :code :unexpected-error-response + :hint "unexpected error response" + :uri uri + :status status + :headers headers + :data (body-excerpt body))) + :else (rx/throw (ex/error :type :internal @@ -126,7 +181,7 @@ :uri uri :status status :headers headers - :data body)))) + :data (body-excerpt body))))) (def default-options {:update-file {:query-params [:id]} diff --git a/frontend/src/app/main/store.cljs b/frontend/src/app/main/store.cljs index 8bdff34a65..c7548400ec 100644 --- a/frontend/src/app/main/store.cljs +++ b/frontend/src/app/main/store.cljs @@ -84,6 +84,7 @@ (let [buffer (atom []) omitset #{:potok.v2.core/undefined :app.main.data.workspace.persistence/update-persistence-status + :app.main.data.persistence/sync-unload-guard :app.main.data.websocket/send-message :app.main.data.workspace.notifications/handle-pointer-send :app.main.router/assign-exception}] @@ -145,6 +146,8 @@ (add-watch ongoing-tasks ::ongoing-tasks (fn [_ _ _ events] - (if (empty? events) - (obj/set! js/window "onbeforeunload" nil) - (obj/set! js/window "onbeforeunload" (constantly false))))) + ;; No `window` outside the browser. + (when (exists? js/window) + (if (empty? events) + (obj/set! js/window "onbeforeunload" nil) + (obj/set! js/window "onbeforeunload" (constantly false)))))) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index b9174d7172..a681944a80 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -560,21 +560,25 @@ (mf/defc exception-section* {::mf/private true} [{:keys [data] :as props}] - (let [type (get data :type) - cause (get data ::errors/instance) + (let [type (get data :type) + cause (get data ::errors/instance) + environment (errors/environment-error? cause) - report (mf/with-memo [cause] - (when (ex/exception? cause) - (errors/generate-report cause))) + report (mf/with-memo [cause] + (when (ex/exception? cause) + (errors/generate-report cause {:format (if environment :compact :full)}))) - props (mf/spread-props props {:report report})] + props (mf/spread-props props {:report report})] (mf/with-effect [report type cause] (when (and (ex/exception? cause) (not (contains? #{:not-found :authentication} type))) - (errors/submit-report :event-name "exception-page" + ;; Environment pages are audit-only: they use the canonical + ;; `handled-exception` event instead of `exception-page`. + (errors/submit-report :event-name (if environment "handled-exception" "exception-page") :report report - :hint (ex/get-hint cause)))) + :hint (ex/get-hint cause) + :cause cause))) (case type :not-found diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 244135c959..34b3ea6161 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -115,18 +115,21 @@ :pending (stl/css :status-notification :pending-status) :saving (stl/css :status-notification :saving-status) :saved (stl/css :status-notification :saved-status) + :retrying (stl/css :status-notification :retrying-status) :error (stl/css :status-notification :error-status) (stl/css :status-notification)) :title (case persistence-status :pending (tr "workspace.header.saving") :saving (tr "workspace.header.saving") :saved (tr "workspace.header.saved") + :retrying (tr "workspace.header.retrying") :error (tr "workspace.header.save-error") nil)} (case persistence-status :pending deprecated-icon/status-alert :saving deprecated-icon/status-alert :saved deprecated-icon/status-tick + :retrying deprecated-icon/status-alert :error deprecated-icon/status-wrong nil)] [:div {:class (stl/css :file-name-label)} file-name]])] diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss index 9127ec1270..1275313fcb 100644 --- a/frontend/src/app/main/ui/workspace/left_header.scss +++ b/frontend/src/app/main/ui/workspace/left_header.scss @@ -117,6 +117,10 @@ animation: jump 0.3s ease-out; } + &.retrying-status { + background-color: var(--status-widget-background-color-warning); + } + &.error-status { background-color: var(--status-widget-background-color-error); } diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index fb5cd5b5fe..826c399603 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -374,6 +374,87 @@ (.text response)) (p/then apply-changes))) +(def ^:private simulated-error-body + "Stands in for the error page a proxy or CDN writes when it answers instead + of the backend." + "Simulated errorsimulated intermediary response") + +(defonce ^:private pending-http-error (atom nil)) + +(defn- simulated-status + "Status to answer `uri` with, or nil to let the request through. A + simulation without a deadline answers one call; one with a deadline answers + every matching call until it runs out." + [uri] + (let [{:keys [method status until]} @pending-http-error] + (when (and (some? method) + (str/includes? uri (str "/api/main/methods/" method))) + (cond + (nil? until) (do (reset! pending-http-error nil) status) + (< (js/Date.now) until) status + :else (do (reset! pending-http-error nil) nil))))) + +;; The original fetch and the wrapper standing in for it, or nil when unhooked. +(defonce ^:private http-error-hook (atom nil)) + +(defn- install-http-error-hook! + [] + (when (nil? @http-error-hook) + (let [original (unchecked-get js/globalThis "fetch") + wrapper (fn [input params] + (let [uri (if (string? input) input (unchecked-get input "url"))] + (if-let [status (simulated-status uri)] + (p/resolved (js/Response. simulated-error-body + #js {:status status + :headers #js {"content-type" "text/html"}})) + (.call original js/globalThis input params))))] + (reset! http-error-hook {:original original :wrapper wrapper}) + (unchecked-set js/globalThis "fetch" wrapper)))) + +(defn- remove-http-error-hook! + [] + (when-let [{:keys [original wrapper]} @http-error-hook] + ;; Restore only while the wrapper is still installed, so a later + ;; wrapper survives. + (when (identical? wrapper (unchecked-get js/globalThis "fetch")) + (unchecked-set js/globalThis "fetch" original)) + (reset! http-error-hook nil))) + +(defn ^:export simulateHttpError + "Answers calls to an API method with `status` and a body that carries no + Penpot error code, the way a CDN or a corporate proxy does. Every other + request goes through untouched. + + Without `seconds` the simulation is spent once it answers one call. With + `seconds` it answers every matching call for that long, which is how to + watch a save retry, warn, keep retrying and finally recover. + + debug.simulateHttpError('update-file') + debug.simulateHttpError('get-comment-threads', 403) + debug.simulateHttpError('update-file', 524, 120)" + ([method] + (simulateHttpError method 524 nil)) + ([method status] + (simulateHttpError method status nil)) + ([method status seconds] + (install-http-error-hook!) + (let [until (when (and (number? seconds) (pos? seconds)) + (+ (js/Date.now) (* 1000 seconds)))] + (reset! pending-http-error {:method method + :status status + :until until}) + (if (some? until) + (str "every " method " call will be answered with " status + " for the next " seconds " seconds") + (str "the next " method " call will be answered with " status))))) + +(defn ^:export clearHttpErrorSimulation + "Stops a running simulation and takes the wrapper off `fetch`." + [] + (reset! pending-http-error nil) + (remove-http-error-hook!) + "requests reach the backend again") + (defn ^:export reset-viewport [] (st/emit! diff --git a/frontend/test/frontend_tests/data/fix_deleted_fonts_test.cljs b/frontend/test/frontend_tests/data/fix_deleted_fonts_test.cljs new file mode 100644 index 0000000000..b2b5c2e083 --- /dev/null +++ b/frontend/test/frontend_tests/data/fix_deleted_fonts_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 INC Sucursal en España SL + +(ns frontend-tests.data.fix-deleted-fonts-test + (:require + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.types.shape :as cts] + [app.common.types.text :as txt] + [app.main.data.changes :as dwc] + [app.main.data.workspace.fix-deleted-fonts :as fdf] + [app.main.fonts :as fonts] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each + {:before cthi/reset-idmap!}) + +(def ^:private missing-font-id + "custom-missing-font-id") + +(defn- text-content-with-font + [text font-id font-family] + (-> (cts/setup-shape {:type :text :x 0 :y 0 :grow-type :auto-width}) + :content + (txt/change-text text :font-id font-id :font-family font-family))) + +(defn- file-with-text-shape + [font-id font-family] + (-> (cthf/sample-file :file1 :page-label :page1) + (cths/add-sample-shape :text + {:type :text + :content (text-content-with-font "TEST" + font-id + font-family)}))) + +(defn- watch-fix-deleted-fonts-events + [file] + (let [events (atom []) + store (ths/setup-store file) + state @store] + (when-let [result (ptk/watch (fdf/fix-deleted-fonts-for-page (:id file) + (cthf/current-page-id file)) + state + (rx/empty))] + (->> result (rx/subs! #(swap! events conj %)))) + @events)) + +(t/deftest fix-deleted-fonts-skips-unfixable-missing-font + (let [file (file-with-text-shape missing-font-id "Gibson") + events (watch-fix-deleted-fonts-events file)] + (t/is (empty? events) + "missing custom fonts with no replacement must not commit changes"))) + +(t/deftest fix-deleted-fonts-remaps-when-replacement-exists + (let [replacement-id "custom-gibson-replacement" + cleanup #(swap! fonts/fontsdb dissoc replacement-id)] + (try + (swap! fonts/fontsdb assoc replacement-id + {:id replacement-id + :family "Gibson" + :name "Gibson" + :variants []}) + (let [file (file-with-text-shape missing-font-id "Gibson") + events (watch-fix-deleted-fonts-events file)] + (t/is (= 1 (count events))) + (t/is (ptk/type? ::dwc/commit-changes (first events)))) + (finally + (cleanup))))) diff --git a/frontend/test/frontend_tests/data/persistence_retry_test.cljs b/frontend/test/frontend_tests/data/persistence_retry_test.cljs new file mode 100644 index 0000000000..6bbfbedf47 --- /dev/null +++ b/frontend/test/frontend_tests/data/persistence_retry_test.cljs @@ -0,0 +1,884 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.data.persistence-retry-test + "Retry-episode tests for the file persistence save pipeline + (`app.main.data.persistence`). + + Covers the transient-failure retry contract: transient failures resend + the head commit with backoff, the budget bounds the episode, guards + (in-flight, stale token, online resume) prevent double-sends, and hung + resends across governor windows still earn their own stall report." + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.main.data.changes :as dch] + [app.main.data.event :as ev] + [app.main.data.persistence :as dps] + [app.main.errors :as errors] + [app.main.repo :as rp] + [app.main.router :as rt] + [app.main.store :as st] + [app.util.i18n :as i18n] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) + +(defn- local-commit + "Builds a synthetic local commit event for `file-id`: a page edit as redo, + no undo, from the `:local` source." + [file-id] + (ptk/data-event ::dch/commit + {:id (uuid/next) + :file-id file-id + :file-revn 0 + :file-vern 0 + :source :local + :features #{} + :redo-changes [{:type :mod-page :id (uuid/next) :name "Edited"}] + :undo-changes []})) + +(defn- with-persistence + "Async fixture for the persistence tests: mocks the transport, flash and + the leave-page task set (`:tasks`), and runs `f` with persistence + initialized. + + Evaluates to a promise resolving once `f` settles and teardown completes; + `await` it from an `^:async` test. + + By default the transport responds through the `:response` subject. Pass a + `respond` function (`(fn [cmd params] observable)`) to drive the transport + response directly instead. Delivery through the subject is asynchronous + (`observe-on :async`): await each effect — via `wait-for` — before + asserting it." + [f & [respond]] + (let [file-id (uuid/next) + response (rx/subject) + failures (atom []) + requests (atom []) + tasks (atom #{}) + store (ptk/store {:state {:current-file-id file-id + :permissions {:can-edit true} + :files {file-id {:id file-id :revn 0}}} + :on-error #(t/is false (str %))})] + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [cmd params] + (swap! requests conj [cmd params]) + (if respond + (respond cmd params) + (->> response (rx/take 1) (rx/observe-on :async))))) + errors/flash (fn [& {:keys [cause]}] + (swap! failures conj cause)) + st/ongoing-tasks tasks} + (try + (ptk/emit! store (dps/initialize-persistence)) + (await (f {:file-id file-id :response response :failures failures + :requests requests :store store :tasks tasks})) + (finally + (rx/dispose! store) + (rx/end! response)))))) + +(defn- instant-backoff + "Timer double: the backoff delays fire at once and the slow cycle never + does, so a failure that outlasts the backoff settles in `:error`." + [ms] + (if (= ms dps/slow-retry-delay-ms) + (rx/subject) + (rx/of :tick))) + +(defn- check-failed-save-response + "Feeds `result` as the transport response and asserts the commit stays + queued as a failed save instead of being treated as persisted. Retry + timers fire instantly (stubbed `rx/timer`, slow cycle held), so a + permanently bad answer exhausts the 3-retry budget and lands terminal: `:error` carrying + `:invalid-save-response`, queue intact, exactly 4 sends." + [result] + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub instant-backoff)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (= :invalid-save-response + (get-in @store [:persistence :error :code])) + (= 1 (count (get-in @store [:persistence :queue])))) + "invalid response fails the save")) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= :invalid-save-response (get-in @store [:persistence :error :code]))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + (t/is (= 4 (count @requests)) "initial send plus 3 retries")))) + (fn [_ _] result))) + +;; Variant: an empty answer. +(t/deftest ^:async empty-save-response-preserves-the-queue-as-failed + (await (check-failed-save-response (rx/empty)))) + +;; Variant: a nil answer. +(t/deftest ^:async nil-save-response-preserves-the-queue-as-failed + (await (check-failed-save-response (rx/of nil)))) + +;; Variant: a negative revision answer. +(t/deftest ^:async invalid-revision-save-response-preserves-the-queue-as-failed + (await (check-failed-save-response (rx/of {:revn -1})))) + +;; Retry tests. +;; +;; Production contract under test: a transient save failure keeps the head +;; commit queued under `:retrying` and resends it with backoff (2s / 8s / +;; 20s, then terminal). Retry timers are stubbed — instant when the test +;; drives completion, manually fired when it scripts the race — and every +;; stub records its delays so the schedule itself is asserted. +;; +;; Same async pattern as above: triggers stay bare, every assert block is +;; preceded by `wait-for` on its leading signal. + +;; Scenario: the first send fails transiently, the retry succeeds. Both +;; sends carry the same `:commit-id`; the save lands with an empty queue +;; and the episode metadata is cleared. Proves: one transient failure +;; retries the same commit instead of erroring. +(t/deftest ^:async transient-failure-retries-and-saves + (let [calls (atom 0)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub instant-backoff)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "retry saves the file")) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue]))) + (t/is (= 2 (count @requests)) "failed send plus one retry") + (t/is (apply = (map (comp :commit-id second) @requests)) + "both sends carry the same commit id") + (t/is (nil? (get-in @store [:persistence :attempts])) + "the episode metadata is cleared on success")))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/of {:revn 1}))))))) + +(defn- check-gateway-response-retries + "Answers the first send with a proxy error page for `status`, built by the + real `rp/handle-response`, and the retry with a valid revision. The save + must take the retry path and land, never halt in `:error`." + [status] + (let [calls (atom 0)] + (with-persistence + (^:async fn [{:keys [file-id failures requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub instant-backoff)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + (str "a " status " is retried and the save lands"))) + (t/is (= 2 (count @requests)) "the proxy error plus one retry") + (t/is (empty? @failures) "a retried proxy error never reaches the failure toast")))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rp/handle-response {:status status + :headers {"content-type" "text/html"} + :body "Gateway Timeout" + :uri "https://penpot.example.com/api/main/methods/update-file"}) + (rx/of {:revn 1})))))) + +;; Variant: the proxy in front of the backend timed out. +(t/deftest ^:async gateway-timeout-save-is-retried + (await (check-gateway-response-retries 504))) + +;; Variant: Cloudflare gave up waiting for the origin. +(t/deftest ^:async cloudflare-origin-timeout-save-is-retried + (await (check-gateway-response-retries 524))) + +;; Scenario: every send fails transiently. The 2s / 8s / 20s retries fire +;; and then the failure falls through to the exact terminal path: `:error` +;; carrying the cause, queue intact, one flash. Proves: the budget bounds +;; the episode (4 sends) and exhaustion is today's terminal behavior. +(t/deftest ^:async retry-exhaustion-goes-terminal + (let [delays (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id failures requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [ms] (swap! delays conj ms) (instant-backoff ms)))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (= 4 (count @requests))) + "retries exhaust into terminal error")) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= :save-failed (get-in @store [:persistence :error :code]))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + (t/is (= [2000 8000 20000 dps/slow-retry-delay-ms] @delays) + "the backoff schedule fires in order, then the slow cycle is armed") + (t/is (= 1 (count @failures)) "exhaustion flashes exactly once")))) + (fn [_ _] (rx/throw (ex-info "offline" {:type :offline}))))))) + +;; Scenario: a retry timer fires while the replacement request is still in +;; flight. The timer is driven by hand: fail the first send, let the +;; online signal resend the head (hanging), then fire the pending timer. +;; Proves: the firing is skipped instead of double-sending the same commit. +(t/deftest ^:async retry-skips-resend-while-previous-request-is-in-flight + (let [calls (atom 0) + delays (atom []) + timer-s (rx/subject)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [ms] (swap! delays conj ms) timer-s))} + ;; Phase 1 — first send fails transiently; the retry pends on + ;; the hand-fired timer. + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :retrying (get-in @store [:persistence :status])) + "transient failure retries")) + (t/is (= 1 (count @requests))) + ;; Phase 2 — the online signal resends the head under the live + ;; episode (status stays :retrying), hanging in flight. + (ptk/emit! store (#'dps/resume-on-online)) + (await (async/wait-for #(= 2 (count @requests)) "online resends")) + (t/is (= :retrying (get-in @store [:persistence :status]))) + (t/is (= 1 (get-in @store [:persistence :attempts]))) + ;; Phase 3 — the pending timer fires into the in-flight request: + ;; skipped, never a third send. + (rx/push! timer-s :tick) + (await (async/settle)) + (t/is (= 2 (count @requests)) "no double-send while in flight") + (t/is (= :retrying (get-in @store [:persistence :status]))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + (rx/end! timer-s)))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/subject))))))) + +;; Scenario: a persist-commit arrives with a superseded episode token after +;; a transient failure. Without the token guard it would send outside the +;; backoff schedule; with it, nothing happens. Proves: stale retry timers +;; stay silent. +(t/deftest ^:async stale-retry-token-stays-silent + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [_] (rx/subject)))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :retrying (get-in @store [:persistence :status])) + "transient failure retries")) + (t/is (= 1 (count @requests))) + (ptk/emit! store (#'dps/persist-commit + (peek (get-in @store [:persistence :queue])) + {:token (uuid/next)})) + (await (async/settle)) + (t/is (= 1 (count @requests)) "stale token sends nothing") + (t/is (= :retrying (get-in @store [:persistence :status]))) + (t/is (nil? (get-in @store [:persistence :error])))))) + (fn [_ _] (rx/throw (ex-info "offline" {:type :offline})))))) + +;; Scenario: a new edit lands while the episode waits on its backoff timer. +;; The edit only joins the queue: nothing is sent and no attempt is spent +;; until the timer fires. The retry then saves the head and the runner +;; sends the new edit after it. Proves: edits during an episode never +;; bypass the backoff nor consume the retry budget. +(t/deftest ^:async new-edit-during-retry-waits-for-the-backoff + (let [calls (atom 0) + delays (atom []) + timer-s (rx/subject)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [ms] (swap! delays conj ms) timer-s))} + ;; Phase 1 — first send fails transiently; the retry pends on + ;; the hand-fired timer. + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :retrying (get-in @store [:persistence :status])) + "transient failure retries")) + (t/is (= 1 (count @requests))) + ;; Phase 2 — a new edit joins the queue without resending. + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 2 (count (get-in @store [:persistence :queue]))) + "second edit is queued")) + (await (async/settle)) + (t/is (= 1 (count @requests)) "the edit does not resend the head") + (t/is (= 1 (get-in @store [:persistence :attempts])) "no attempt is spent") + (t/is (= [2000] @delays) "the pending backoff is kept") + (t/is (= :retrying (get-in @store [:persistence :status]))) + ;; Phase 3 — the timer fires: the head saves, then the new edit. + (rx/push! timer-s :tick) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "both edits save")) + (t/is (= 3 (count @requests)) "failed send, retry, then the new edit") + (let [[first-id retry-id edit-id] (map (comp :commit-id second) @requests)] + (t/is (= first-id retry-id) "the retry resends the head") + (t/is (not= retry-id edit-id) "the new edit is sent after the head")) + (rx/end! timer-s)))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/of {:revn @calls}))))))) + +;; Scenario: a transient failure raises the reconnect notice; the retry +;; then succeeds. The timer is driven by hand so the test observes the +;; episode mid-flight: one visible notice (the store holds a single toast, +;; so episodes never stack), gone once the save lands. Proves: exactly one +;; notice per episode, hidden on recovery. +(t/deftest ^:async retry-shows-a-single-reconnect-notice-until-saved + (let [calls (atom 0) + timer-s (rx/subject)] + (await + (with-persistence + (^:async fn [{:keys [file-id store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [_] timer-s)) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (get-in @store [:persistence :attempts])) + "first attempt fails into retrying")) + (let [notice (get @store :notification)] + (t/is (map? notice) "a single notice is visible, never stacked")) + (t/is (= :persistence-reconnecting (get-in @store [:notification :tag]))) + (t/is (= "translated:errors.save-retrying" + (get-in @store [:notification :content]))) + (rx/push! timer-s :tick) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (nil? (get @store :notification))) + "save hides the notice")) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (nil? (get @store :notification)) "recovery is silent") + (rx/end! timer-s)))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/of {:revn 1}))))))) + +;; Scenario: every send fails transiently with instant timers. Exhaustion +;; takes the terminal path, which hides the reconnect notice explicitly +;; before flashing. Proves: no stale notice survives a terminal failure. +(t/deftest ^:async retry-exhaustion-hides-the-reconnect-notice + (await + (with-persistence + (^:async fn [{:keys [file-id store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub instant-backoff)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "retries exhaust into terminal error")) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (nil? (get @store :notification)) + "the terminal path hides the reconnect notice")))) + (fn [_ _] (rx/throw (ex-info "offline" {:type :offline})))))) + +;; Scenario: a retrying episode waits on its backoff timer when the browser +;; reports connectivity back. The pending timer never fires, so only the +;; online signal can resume: the head resends under the live episode and, +;; once answered, the file saves. A second online signal with an empty +;; queue sends nothing. Proves: reconnect resumes promptly without +;; terminal failures staying terminal. +(t/deftest ^:async online-event-resumes-a-retrying-episode + (let [calls (atom 0) + timer-s (rx/subject) + second-s (atom nil)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [_] timer-s))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :retrying (get-in @store [:persistence :status])) + "transient failure retries")) + (t/is (= 1 (count @requests))) + (ptk/emit! store (#'dps/resume-on-online)) + (await (async/wait-for #(= 2 (count @requests)) "online resends")) + (t/is (= 2 (count @requests))) + (t/is (= :retrying (get-in @store [:persistence :status]))) + (t/is (= 1 (get-in @store [:persistence :attempts]))) + (rx/push! @second-s {:revn 1}) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "resumed save lands")) + (t/is (= :saved (get-in @store [:persistence :status]))) + (ptk/emit! store (#'dps/resume-on-online)) + (await (async/settle)) + (t/is (= 2 (count @requests)) "online with an empty queue sends nothing") + (rx/end! timer-s)))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rx/throw (ex-info "offline" {:type :offline})) + (let [s (rx/subject)] (reset! second-s s) s))))))) + +(defn- audit-events + "The audit events (report emissions) out of everything collected through + the `st/emit!` double. Discriminates by `ptk/type`, which is total (never + throws)." + [events] + (filter #(= ::ev/event (ptk/type %)) events)) + +;; Scenario: the `online` event arrives while a retry request is already in +;; flight. The first send fails transiently, the retry timer resends +;; (hanging), then connectivity reports back mid-flight. Proves: the online +;; entry point honors the in-flight guard instead of double-sending — the +;; same guard as the retry-timer path, through `resume-on-online` -> +;; `run-persistence-task`. +(t/deftest ^:async online-event-does-not-resend-an-in-flight-request + (let [calls (atom 0) + timer-s (rx/subject)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [_] timer-s))} + ;; Phase 1 — first send fails transiently; the retry pends on + ;; the hand-fired timer. + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :retrying (get-in @store [:persistence :status])) + "transient failure retries")) + (t/is (= 1 (count @requests))) + ;; Phase 2 — the retry timer fires and resends the head, + ;; hanging in flight. + (rx/push! timer-s :tick) + (await (async/wait-for #(= 2 (count @requests)) "retry resends")) + (t/is (= :retrying (get-in @store [:persistence :status]))) + ;; Phase 3 — online arrives mid-flight: silent, never a third send. + (ptk/emit! store (#'dps/resume-on-online)) + (await (async/settle)) + (t/is (= 2 (count @requests)) "online sends nothing while in flight") + (t/is (= :retrying (get-in @store [:persistence :status]))) + (rx/end! timer-s)))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/subject))))))) + +;; Scenario: the same file stalls twice, more than a governor window apart +;; (scripted clock). Each stall emits its own report: the second is a new +;; episode, not a suppressed duplicate of the first. Proves: identical +;; stall causes coalesce only inside the window. +;; +;; Note: unlike `with-watchdog`, the real `submit-report` runs here (only +;; `generate-report` stays doubled), so the test exercises the governor, +;; the thunk contract and the emit path end to end. +(t/deftest ^:async repeated-stalls-across-windows-emit-each-report + (let [clock (atom 0) + ticks (rx/subject) + response (rx/subject) + requests (atom []) + causes (atom []) + events (atom []) + file-id (uuid/next) + store (ptk/store {:state {:current-file-id file-id + :permissions {:can-edit true} + :files {file-id {:id file-id :revn 0}}} + :on-error #(t/is false (str %))})] + (errors/reset-report-governor!) + (await + (mock/with-mocks* + {ct/now (mock/stub #(ct/inst @clock)) + rx/interval (mock/stub (fn [_] ticks)) + rp/cmd! (mock/stub (fn [cmd params] + (swap! requests conj {:cmd cmd :params params}) + (->> response (rx/take 1) (rx/observe-on :async)))) + st/state store + errors/generate-report (fn [cause & _] + (swap! causes conj cause) + "report") + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + st/ongoing-tasks (atom #{})} + (try + (ptk/emit! store (dps/initialize-persistence)) + ;; Phase 1 — the first stall reports once. + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (reset! clock 300001) + (rx/push! ticks :tick) + (await (async/wait-for #(= 1 (count (audit-events @events))) + "first stall reports")) + (t/is (= 1 (count @requests))) + ;; Phase 2 — the save lands, resetting the stall clock; a new + ;; edit stalls again, over a governor window later. + (rx/push! response {:revn 1}) + (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) + "first save lands")) + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 2 (count @requests)) "second edit is sent")) + (reset! clock (+ 300001 errors/report-window-ms 300001)) + (rx/push! ticks :tick) + (await (async/wait-for #(= 2 (count (audit-events @events))) + "second stall reports")) + (let [audits (audit-events @events)] + (t/is (= 2 (count audits))) + (t/is (= 2 (count @causes)) "each granted stall builds its report") + (t/is (every? #(= 1 (:occurrences (deref %))) audits) + "each stall is a fresh emission, never a coalesced repeat")) + (finally + (rx/dispose! store) + (rx/end! ticks) + (rx/end! response))))))) + +;; Scenario: the first send never answers. The request timeout fails it as +;; a transient error, and the retry resends the same commit and saves. The +;; timeout is shortened by wrapping the real operator. Proves: a hung save +;; does not block the queue. +(t/deftest ^:async hung-save-request-times-out-and-retries + (let [calls (atom 0) + timeouts (atom []) + timeout rx/timeout] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub instant-backoff) + rx/timeout (mock/stub (fn [ms with ob] + (swap! timeouts conj ms) + (timeout 0 with ob)))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "the retry saves the file")) + (t/is (= 2 (count @requests)) "hung send plus one retry") + (t/is (apply = (map (comp :commit-id second) @requests)) + "both sends carry the same commit id") + (t/is (= [420000 420000] @timeouts) "every send gets the 7 minute timeout")))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rx/subject) + (rx/of {:revn 1}))))))) + +;; --------------------------------------------------------------------------- +;; slow cycle +;; --------------------------------------------------------------------------- + +(defn- slow-cycle-timer + "Timer double: the backoff delays fire at once, and every slow cycle waits + on a subject collected in `cycles`, so the test fires them one by one." + [cycles] + (fn [ms] + (if (= ms dps/slow-retry-delay-ms) + (let [cycle-s (rx/subject)] + (swap! cycles conj cycle-s) + cycle-s) + (rx/of :tick)))) + +;; Scenario: the backoff is spent while the network is down. The queue keeps +;; trying on the slow cycle, one send per cycle, without warning the user +;; again, and saves once the network is back. Proves: a session outlasting +;; the backoff still recovers on its own. +(t/deftest ^:async a-spent-backoff-keeps-trying-on-a-slow-cycle + (let [calls (atom 0) + cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id failures requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (= 1 (count @cycles))) + "the spent backoff arms the slow cycle")) + (t/is (= 4 (count @requests))) + (t/is (= 1 (count @failures))) + + (rx/push! (last @cycles) :tick) + (await (async/wait-for #(= 2 (count @cycles)) + "a failed slow attempt arms the next cycle")) + (t/is (= 5 (count @requests)) "a slow cycle sends once") + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= 1 (count @failures)) "the user is not warned again") + + ;; The warning the first failure left on screen. + (ptk/emit! store #(assoc % :notification {:tag errors/persistence-failed-tag + :level :error})) + (rx/push! (last @cycles) :tick) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "the slow attempt saves")) + (t/is (= 6 (count @requests))) + (t/is (apply = (map (comp :commit-id second) @requests)) + "every attempt carries the same commit id") + (t/is (nil? (get-in @store [:persistence :recovering]))) + (t/is (nil? (get-in @store [:persistence :failing-since]))) + (t/is (nil? (get @store :notification)) + "the failure warning goes away once the save lands")))) + (fn [_ _] + (if (< (swap! calls inc) 6) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/of {:revn 1}))))))) + +;; Scenario: a gateway error outlasts the backoff, then the proxy recovers. +;; Proves: each gateway status takes the slow cycle and saves the held edit. +(t/deftest ^:async a-gateway-error-arms-the-slow-cycle + (doseq [status [504 520 521 522 523 524]] + (let [calls (atom 0) + cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @cycles)) + (str status " arms the slow cycle"))) + (t/is (= 4 (count @requests))) + (t/is (= :gateway-error (get-in @store [:persistence :error :cause-type]))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + + (rx/push! (last @cycles) :tick) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + (str status " recovers on the slow cycle"))) + (t/is (= 5 (count @requests))) + (t/is (apply = (map (comp :commit-id second) @requests)) + "the slow cycle saves the held commit")))) + (fn [_ _] + (if (<= (swap! calls inc) 4) + (rp/handle-response {:status status :body "Gateway error" :uri "/api"}) + (rx/of {:revn 1})))))))) + +;; Scenario: a save fails for a reason no attempt can get past. Proves: only +;; transport failures arm the slow cycle. +(t/deftest ^:async a-terminal-failure-does-not-arm-the-slow-cycle + (let [cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the save fails")) + (await (async/settle)) + (t/is (= 1 (count @requests))) + (t/is (empty? @cycles))))) + (fn [_ _] (rx/throw (ex-info "Validation failed" {:type :validation}))))))) + +;; Scenario: a gateway answers 504 only after its own timeout, while the +;; backend may still be applying the save. Proves: slow failures below the +;; limit still arm the slow cycle; at the limit the save stops being sent, +;; and opening a file releases it instead of sending it again. +(t/deftest ^:async slow-failures-stop-the-slow-cycle-at-the-limit + (doseq [[slow-count armed?] [[(dec dps/max-slow-failures) true] + [dps/max-slow-failures false]]] + (let [clock (atom 0) + calls (atom 0) + cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles)) + ct/now (mock/stub #(ct/inst @clock))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + (str slow-count " slow failures spend the backoff"))) + (await (async/settle)) + (t/is (= 4 (count @requests))) + (t/is (= slow-count (get-in @store [:persistence :slow-failures]))) + (t/is (= (if armed? 1 0) (count @cycles)) + (str slow-count " slow failures " (if armed? "arm" "do not arm") " the slow cycle")) + + (when-not armed? + (ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(empty? (get-in @store [:persistence :queue])) + "recovery releases the held edit")) + (t/is (= 4 (count @requests)) "the released edit is not sent again"))))) + (fn [_ _] + (when (<= (swap! calls inc) slow-count) + (swap! clock + dps/slow-failure-ms)) + (rp/handle-response {:status 504 :body "Gateway timeout" :uri "/api"}))))))) + +;; Scenario: the slow cycle is still failing when the retry window closes. +;; Proves: the cycle gives up, keeping the edits queued. +(t/deftest ^:async the-slow-cycle-stops-when-the-retry-window-closes + (let [clock (atom 0) + cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles)) + ct/now (mock/stub #(ct/inst @clock))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @cycles)) "the slow cycle is armed")) + + (reset! clock (+ dps/retry-give-up-ms 1000)) + (rx/push! (last @cycles) :tick) + (await (async/wait-for #(= 5 (count @requests)) "the last slow attempt")) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the attempt fails")) + (await (async/settle)) + (t/is (= 1 (count @cycles)) "no further cycle is armed") + (t/is (= 1 (count (get-in @store [:persistence :queue]))))))) + (fn [_ _] (rx/throw (ex-info "offline" {:type :offline}))))))) + +;; Scenario: the backoff is spent and the user leaves the workspace, which +;; stops the slow cycle, then opens a file again. Recovery sends the queue +;; once more instead of releasing it, and it saves. Proves: edits held by a +;; transport failure are never dropped by recovery. +(t/deftest ^:async recovery-resends-a-transport-failure-instead-of-releasing-it + (let [calls (atom 0) + cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @cycles)) "the slow cycle is armed")) + (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "recovery saves the held edit")) + (t/is (= 5 (count @requests))) + (t/is (apply = (map (comp :commit-id second) @requests)) + "recovery sends the same commit, not a released queue")))) + (fn [_ _] + (if (< (swap! calls inc) 5) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/of {:revn 1}))))))) + +;; Leaving the workspace tests. + +;; Scenario: a save fails on a transport error and the user leaves the +;; workspace before the backoff fires. The pending retry stays silent, and +;; opening a file sends the held edit again. Proves: the backoff does not +;; run outside the workspace and resumes once a file is opened. +(t/deftest ^:async leaving-the-workspace-stops-the-backoff + (let [calls (atom 0) + timers (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [_] + (let [timer-s (rx/subject)] + (swap! timers conj timer-s) + timer-s)))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @timers)) "the failure schedules a retry")) + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (await (async/wait-for #(get-in @store [:persistence :suspended]) + "leaving the workspace suspends retries")) + (rx/push! (first @timers) :tick) + (await (async/settle)) + (t/is (= 1 (count @requests)) "the pending retry stays silent") + (t/is (= :retrying (get-in @store [:persistence :status]))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/workspace-initialized)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "opening a file saves the held edit")) + (t/is (= 2 (count @requests))) + (t/is (apply = (map (comp :commit-id second) @requests)) + "the same commit is sent again") + (t/is (nil? (get-in @store [:persistence :suspended])))))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/of {:revn 1}))))))) + +;; Scenario: the user leaves the workspace while a save is in flight, and the +;; save then fails on a transport error. No backoff is scheduled, the online +;; event does not resend, and opening a file does. Proves: a failure that +;; lands outside the workspace waits for the user instead of retrying there. +(t/deftest ^:async a-failure-after-leaving-the-workspace-arms-no-retry + (let [calls (atom 0) + timers (atom []) + first-s (rx/subject)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [ms] (swap! timers conj ms) (rx/subject)))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @requests)) "the edit is sent")) + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (await (async/wait-for #(get-in @store [:persistence :suspended]) + "leaving the workspace suspends retries")) + (rx/error! first-s (ex-info "offline" {:type :offline})) + (await (async/wait-for #(= :retrying (get-in @store [:persistence :status])) + "the failure keeps the edit queued")) + (await (async/settle)) + (t/is (empty? @timers) "no backoff is scheduled") + + (ptk/emit! store (#'dps/resume-on-online)) + (await (async/settle)) + (t/is (= 1 (count @requests)) "the online event does not resend") + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/workspace-initialized)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "opening a file saves the held edit")) + (t/is (= 2 (count @requests)))))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + first-s + (rx/of {:revn 1}))))))) + +;; Scenario: the last backoff attempt is in flight when the user leaves the +;; workspace, and it fails. The queue halts without arming the slow cycle, +;; and opening a file sends it once more. Proves: the slow cycle never +;; starts outside the workspace. +(t/deftest ^:async a-spent-backoff-outside-the-workspace-arms-no-slow-cycle + (let [calls (atom 0) + cycles (atom []) + last-s (rx/subject)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 4 (count @requests)) "the last retry is sent")) + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (await (async/wait-for #(get-in @store [:persistence :suspended]) + "leaving the workspace suspends retries")) + (rx/error! last-s (ex-info "offline" {:type :offline})) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the spent backoff halts the queue")) + (await (async/settle)) + (t/is (empty? @cycles) "no slow cycle is armed") + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/workspace-initialized)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "opening a file saves the held edit")) + (t/is (= 5 (count @requests)))))) + (fn [_ _] + (let [n (swap! calls inc)] + (cond + (< n 4) (rx/throw (ex-info "offline" {:type :offline})) + (= n 4) last-s + :else (rx/of {:revn 1})))))))) diff --git a/frontend/test/frontend_tests/data/persistence_test.cljs b/frontend/test/frontend_tests/data/persistence_test.cljs index 8cdb42789c..9735771305 100644 --- a/frontend/test/frontend_tests/data/persistence_test.cljs +++ b/frontend/test/frontend_tests/data/persistence_test.cljs @@ -5,22 +5,106 @@ ;; Copyright (c) KALEIDOS INC Sucursal en España SL (ns frontend-tests.data.persistence-test + "Tests for the file persistence save pipeline (`app.main.data.persistence`): + read-only saves, the stalled-save watchdog, the core save pipeline + (queueing, failures, recovery), and the transient/terminal failure + classification. Retry-episode behavior lives in + `frontend-tests.data.persistence-retry-test`. + + The watchdog and read-only tests are fully async: `mock/with-mocks*` + installs the doubles, `^:async` bodies `await` observable effects (never + assert straight after a trigger), and completion propagates through + promises — test code threads no `done`. The remaining tests still use + `mock/with-mocks` with an explicit `done` chain." (:require [app.common.time :as ct] + [app.common.types.shape :as cts] [app.common.uuid :as uuid] [app.main.data.changes :as dch] [app.main.data.persistence :as dps] [app.main.data.render-wasm :as drw] + [app.main.data.workspace :as dw] [app.main.errors :as errors] [app.main.repo :as rp] [app.main.store :as st] [app.util.i18n :as i18n] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) +(t/deftest reopening-restores-only-the-retained-edits-of-the-loaded-file + (doseq [[status cause-type vern restored?] + [[:retrying nil 0 true] + [:saving nil 0 true] + [:error :offline 0 true] + [:error :validation 0 false] + [:retrying nil 1 false]]] + (t/testing (str status " / " cause-type " / version " vern) + (let [file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + first-id (uuid/next) + next-id (uuid/next) + other-id (uuid/next) + shape (cts/setup-shape {:id shape-id :type :rect :x 0 :y 0}) + file {:id file-id + :vern vern + :data {:pages [page-id] + :pages-index {page-id {:id page-id :name "Page" + :objects {uuid/zero + (cts/setup-shape {:id uuid/zero :type :frame})}}}}} + rename (fn [name] + {:type :mod-obj :id shape-id :page-id page-id + :operations [{:type :set :attr :name :val name}]}) + pstate {:status status + :error {:cause-type cause-type :commit-id first-id} + :queue (into #queue [] [first-id next-id other-id]) + :index {first-id {:file-id file-id :file-vern 0 + :redo-changes [{:type :add-obj :id shape-id :obj shape + :page-id page-id :parent-id uuid/zero + :frame-id uuid/zero}]} + next-id {:file-id file-id :file-vern 0 + :redo-changes [(rename "Retained rectangle")]} + other-id {:file-id (uuid/next) :file-vern 0 + :redo-changes [(rename "Another file")]}}} + state {:persistence pstate} + loaded (ptk/update (#'dw/bundle-fetched {:file file :file-id file-id :thumbnails {}}) + state) + restored (get-in loaded [:files file-id :data :pages-index page-id :objects shape-id])] + (t/is (= restored? (some? restored))) + (when restored? + (t/is (= "Retained rectangle" (:name restored)) + "queued edits apply in order and exclude other files")) + (t/is (identical? pstate (:persistence loaded)) + "restoring the editor must preserve the save queue"))))) + +;; Scenario: a queued edit no longer applies to the data the server sent, so +;; replaying it throws. Proves: the file still opens, as the server sent it, +;; and the edits stay queued. +(t/deftest reopening-with-an-edit-that-no-longer-applies-keeps-the-server-file + (let [file-id (uuid/next) + page-id (uuid/next) + commit-id (uuid/next) + data {:pages [page-id] + :pages-index {page-id {:id page-id :name "Page" + :objects {uuid/zero + (cts/setup-shape {:id uuid/zero :type :frame})}}}} + file {:id file-id :vern 0 :data data} + pstate {:status :saving + :queue (into #queue [] [commit-id]) + :index {commit-id {:file-id file-id :file-vern 0 + :redo-changes [{:type :mod-obj :id uuid/zero :page-id page-id + :operations [{:type :no-longer-supported}]}]}}} + loaded (ptk/update (#'dw/bundle-fetched {:file file :file-id file-id :thumbnails {}}) + {:persistence pstate})] + (t/is (identical? data (get-in loaded [:files file-id :data]))) + (t/is (identical? pstate (:persistence loaded))))) + (defn- local-commit + "Builds a synthetic local commit event for `file-id`: a page edit as redo, + no undo, from the `:local` source." [file-id] (ptk/data-event ::dch/commit {:id (uuid/next) @@ -32,40 +116,78 @@ :redo-changes [{:type :mod-page :id (uuid/next) :name "Edited"}] :undo-changes []})) -(t/deftest queued-edits-save-during-temporary-read-only-mode - (doseq [read-only-event [(drw/context-lost) - #(assoc-in % [:workspace-global :read-only?] true) - #(assoc % :workspace-global {:read-only? true - :preview-id (uuid/next)})]] - (let [file-id (uuid/next) - response (rx/subject) - errors (atom []) - store (ptk/store {:state {:permissions {:can-edit true} - :files {file-id {:id file-id :revn 0}}} - :on-error #(swap! errors conj %)})] - (with-redefs [rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response)))] - (try - (ptk/emit! store (dps/initialize-persistence) - (local-commit file-id) - read-only-event - ::dps/force-persist) - (rx/push! response {:revn 1}) - (t/is (= :saved (get-in @store [:persistence :status]))) - (t/is (empty? (get-in @store [:persistence :queue]))) +(defn- ^:async run-read-only-phases + "Drives the read-only save scenario presuming asynchronous APIs: every + phase emits its triggers, awaits the transport round-trip, and only + then asserts the observed state." + [store response requests file-id read-only-event errors] + (ptk/emit! store (dps/initialize-persistence) + (local-commit file-id) + read-only-event + ::dps/force-persist) + (await (async/await-response requests response {:revn 1})) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue]))) - (ptk/emit! store (drw/context-restored) - #(assoc-in % [:workspace-global :read-only?] false) - (local-commit file-id) - ::dps/force-persist) - (rx/push! response {:revn 2}) - (t/is (= :saved (get-in @store [:persistence :status]))) - (t/is (empty? (get-in @store [:persistence :queue]))) - (t/is (empty? @errors)) - (finally - (rx/dispose! store) - (rx/end! response))))))) + (ptk/emit! store (drw/context-restored) + #(assoc-in % [:workspace-global :read-only?] false) + (local-commit file-id) + ::dps/force-persist) + (await (async/await-response requests response {:revn 2})) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue]))) + (t/is (empty? @errors))) -(t/deftest historical-preview-cannot-create-local-commits +(defn- ^:async check-read-only-save + "Saves a queued edit while the file is read-only and verifies that the edit + is persisted once the restriction is lifted. + + Scenario: with persistence initialized, queue an edit, switch the file to + read-only (`read-only-event`), and force persistence — the edit goes out + and is acknowledged. Then restore an editable context, queue another edit + and persist again. Both saves land in order with no reported errors. + Proves: read-only defers writes without dropping them, and resumption + picks up where it left off." + [read-only-event] + (let [file-id (uuid/next) + response (rx/subject) + requests (atom []) + errors (atom []) + store (ptk/store {:state {:permissions {:can-edit true} + :files {file-id {:id file-id :revn 0}}} + :on-error #(swap! errors conj %)})] + + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [cmd params] + (let [req (->> response (rx/take 1) (rx/observe-on :async))] + (swap! requests conj {:cmd cmd :params params :req req}) + req))) + st/ongoing-tasks (atom #{})} + (try + (await (run-read-only-phases store response requests file-id read-only-event errors)) + (finally + (rx/dispose! store) + (rx/end! response)))))) + +;; Variant: the file goes read-only through render context loss. +(t/deftest ^:async queued-edits-save-after-context-loss + (await (check-read-only-save (drw/context-lost)))) + +;; Variant: the file goes read-only through the workspace flag. +(t/deftest ^:async queued-edits-save-in-read-only-mode + (await (check-read-only-save #(assoc-in % [:workspace-global :read-only?] true)))) + +;; Variant: the file goes read-only through preview mode. +(t/deftest ^:async queued-edits-save-in-read-only-preview-mode + (await (check-read-only-save #(assoc % :workspace-global {:read-only? true + :preview-id (uuid/next)})))) + +;; Scenario: the open file is a historical preview (read-only with a preview +;; id). Attempting a commit there must produce nothing observable: the gate +;; answers refusals with an empty stream (never nil), so the test observes +;; termination instead of branching on nil. Previews cannot create local +;; commits. Proves: the read-only preview gate holds at the watch level. +(t/deftest ^:async historical-preview-cannot-create-local-commits (let [file-id (uuid/next) output (atom []) state {:current-file-id file-id @@ -73,306 +195,796 @@ :files {file-id {:id file-id :revn 0 :vern 0}} :workspace-global {:read-only? true :preview-id (uuid/next)}} event (dch/commit-changes {:redo-changes [] :undo-changes []})] - (when-let [result (ptk/watch event state (rx/empty))] - (->> result (rx/subs! #(swap! output conj %)))) + (await (async/observe (ptk/watch event state (rx/empty)) + :on-next #(swap! output conj %))) (t/is (empty? @output)))) +;; Watchdog: stalled-save detection. +;; +;; Production contract under test (`app.main.data.persistence`, +;; `saving-stall-timeout-ms` = 5 minutes): +;; - A request unanswered past the deadline reports once as +;; `:saving-stalled` (a `handled-exception` audit event) and never +;; repeats for the same stall (`:stall-reported`). +;; - Any completed save resets the clock: a later stall reports again. +;; - Edits queued behind a stall are preserved; an already failed save is +;; not re-reported as a stall. +;; +;; Emulation model — the fixture freezes the three inputs the watchdog +;; reads, so each test scripts time by hand: +;; - clock (`ct/now`): an atom. The test sets the time; time never flows. +;; - ticks (`rx/interval`): a subject. Each push runs one watchdog pass. +;; - network (`rp/cmd!`): answered when the test pushes into `:response` +;; (scenario timing stays in test hands); delivery is asynchronous, so +;; every phase awaits its effects before asserting them. +;; - reports (`errors/generate-report`/`submit-report`): recorded into +;; atoms for assertions; nothing leaves the test. Thunk reports are +;; evaluated through the `generate-report` double, mirroring production +;; (granted reports build, suppressed ones never exist here). +;; +;; Reading the tests below: every assert block is preceded by quiescence — +;; `wait-for` on its presence-conditions, or a bare `settle` tick when it +;; asserts only absence. (defn- with-watchdog + "Async fixture for the persistence watchdog tests: mocks the clock, timers + and RPC transport, and runs `f` with persistence initialized. + + Evaluates to a promise resolving once `f` settles and teardown completes; + `await` it from an `^:async` test. The transport answers when the test + pushes into `:response` (scenario timing stays in test hands); delivery + is asynchronous, so await each effect — via `wait-for` — before asserting + it. `f` is awaited (usually an `^:async` fn)." [f] (let [clock (atom 0) ticks (rx/subject) response (rx/subject) + requests (atom []) reports (atom []) causes (atom []) - render errors/generate-report file-id (uuid/next) store (ptk/store {:state {:current-file-id file-id :permissions {:can-edit true} :files {file-id {:id file-id :revn 0}}} :on-error #(t/is false (str %))})] - (with-redefs [ct/now (mock/stub #(ct/inst @clock)) - rx/interval (mock/stub (fn [_] ticks)) - rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response))) - st/state store - errors/generate-report (fn [cause] - (swap! causes conj cause) - (render cause)) - errors/submit-report (fn [& params] - (swap! reports conj (apply hash-map params)))] + (mock/with-mocks* + {ct/now (mock/stub #(ct/inst @clock)) + rx/interval (mock/stub (fn [_] ticks)) + rp/cmd! (mock/stub (fn [cmd params] + (swap! requests conj {:cmd cmd :params params}) + (->> response (rx/take 1) (rx/observe-on :async)))) + st/state store + errors/generate-report (fn [cause & _] + (swap! causes conj cause) + "report") + errors/submit-report (fn [& params] + ;; Mirrors production: a granted report builds + ;; its payload through `generate-report`, so a + ;; thunk report is evaluated here while a + ;; string report is recorded as it arrives. + (let [m (apply hash-map params)] + (swap! reports conj m) + (when (fn? (:report m)) + ((:report m))))) + st/ongoing-tasks (atom #{})} (try (ptk/emit! store (dps/initialize-persistence)) - (f {:clock clock :ticks ticks :response response :causes causes - :reports reports :store store :file-id file-id}) + (await (f {:clock clock :ticks ticks :response response :requests requests + :causes causes :reports reports :store store :file-id file-id})) (finally (rx/dispose! store) (rx/end! ticks) (rx/end! response)))))) -(t/deftest stalled-request-is-reported-once-without-discarding-edits - (with-watchdog - (fn [{:keys [clock ticks reports causes store file-id]}] - (ptk/emit! store (local-commit file-id) ::dps/force-persist) - (reset! clock 300000) - (rx/push! ticks :tick) - (t/is (empty? @reports) "Five minutes must elapse before reporting") +;; Scenario: persistence sits in a non-terminal status with an empty +;; queue past the deadline (a status transition racing the queue drain). +;; The watchdog must stay silent: with nothing queued there is nothing +;; stalled to report. Proves: no false-positive stall reports on an +;; empty queue. +(t/deftest ^:async empty-queue-never-reports-a-stall + (await + (with-watchdog + (^:async fn [{:keys [clock ticks reports store]}] + (ptk/emit! store (#'dps/update-status :pending)) + (reset! clock 300001) + (rx/push! ticks :tick) + (await (async/settle)) + (t/is (empty? @reports) "an empty queue is never a stall"))))) - ;; More local edits must not reset the stalled request's clock. - (reset! clock 300001) - (ptk/emit! store (drw/context-lost) - (local-commit file-id) ::dps/force-persist) - (rx/push! ticks :tick) - (t/is (= 1 (count @reports))) - (t/is (= "handled-exception" (:event-name (first @reports)))) - (let [data (ex-data (first @causes))] - (t/is (= :saving-stalled (:code data))) - (t/is (= file-id (:file-id data))) - (t/is (true? (:render-context-lost? data)))) +;; Scenario: the network hangs. One save goes out and is never answered. +;; Five minutes pass with no report (the deadline is exclusive); one second +;; later, with more edits piled behind the stalled request, the tick reports +;; the stall exactly once — as `handled-exception`, carrying the file id and +;; the lost render context — and later ticks repeat nothing while both edits +;; stay queued. Proves: one report per stall, no lost edits. +(t/deftest ^:async stalled-request-is-reported-once-without-discarding-edits + (await + (with-watchdog + (^:async fn [{:keys [clock ticks reports causes store file-id]}] + ;; Phase 1 — at exactly five minutes the deadline has not elapsed: silence. + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (reset! clock 300000) + (rx/push! ticks :tick) + (await (async/settle)) + (t/is (empty? @reports) "Five minutes must elapse before reporting") - (reset! clock 900000) - (rx/push! ticks :tick) - (t/is (= 1 (count @reports)) "Do not repeat a report for the same stall") - (t/is (= :saving (get-in @store [:persistence :status]))) - (t/is (= 2 (count (get-in @store [:persistence :queue]))))))) + ;; Phase 2 — one second past the deadline the stall reports exactly once. + ;; More local edits must not reset the stalled request's clock. + (reset! clock 300001) + (ptk/emit! store (drw/context-lost) + (local-commit file-id) ::dps/force-persist) + (rx/push! ticks :tick) + (await (async/wait-for #(= 1 (count @reports)) "stall report")) + (t/is (= 1 (count @reports))) + (t/is (= "handled-exception" (:event-name (first @reports)))) + (let [data (ex-data (first @causes))] + (t/is (= :saving-stalled (:code data))) + (t/is (= file-id (:file-id data))) + (t/is (true? (:render-context-lost? data)))) -(t/deftest successful-saves-reset-the-stall-clock-and-allow-a-new-report - (with-watchdog - (fn [{:keys [clock ticks response reports store file-id]}] - (ptk/emit! store (local-commit file-id) ::dps/force-persist - (local-commit file-id) ::dps/force-persist) - (reset! clock 290000) - (rx/push! response {:revn 1}) - (reset! clock 300001) - (rx/push! ticks :tick) - (t/is (empty? @reports) "The queue is making progress") + ;; Phase 3 — long after: no repeat, both edits still queued. + (reset! clock 900000) + (rx/push! ticks :tick) + (await (async/settle)) + (t/is (= 1 (count @reports)) "Do not repeat a report for the same stall") + (t/is (= :saving (get-in @store [:persistence :status]))) + (t/is (= 2 (count (get-in @store [:persistence :queue])))))))) - (reset! clock 590001) - (rx/push! ticks :tick) - (t/is (= 1 (count @reports)) "The second request has now stalled") +;; Scenario: answers arrive, then stop. Two commits share the first request; +;; awaiting its landing advances the queue and starts the second request, so +;; the tick finds progress and stays silent. Left hanging past a fresh +;; deadline it reports once; answering it saves the file and silences the +;; watchdog; a later hang reports again. Proves: progress resets the clock, +;; and every new stall earns its own report. +;; +;; Note the awaits: a push only schedules delivery, so each tick must +;; observe the completed save — awaiting after the push is what separates +;; "answered" from "completed". +(t/deftest ^:async successful-saves-reset-the-stall-clock-and-allow-a-new-report + (await + (with-watchdog + (^:async fn [{:keys [clock ticks reports response store file-id]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist + (local-commit file-id) ::dps/force-persist) + ;; Phase 1 — two commits share the first request; its landing starts the second. + (reset! clock 290000) + (rx/push! response {:revn 1}) + (await (async/wait-for #(= 1 (count (get-in @store [:persistence :queue]))) + "first save lands, second request starts")) + (reset! clock 300001) + (rx/push! ticks :tick) + (await (async/settle)) + (t/is (empty? @reports) "The queue is making progress") - (rx/push! response {:revn 2}) - (t/is (= :saved (get-in @store [:persistence :status]))) - (reset! clock 1000000) - (rx/push! ticks :tick) - (t/is (= 1 (count @reports)) "A saved file must not be reported") + ;; Phase 2 — the second request hangs past its own fresh deadline. + (reset! clock 590001) + (rx/push! ticks :tick) + (await (async/wait-for #(= 1 (count @reports)) "second request stalls")) + (t/is (= 1 (count @reports)) "The second request has now stalled") - (ptk/emit! store (local-commit file-id) ::dps/force-persist) - (reset! clock 1300001) - (rx/push! ticks :tick) - (t/is (= 2 (count @reports)) "A later stall gets its own report")))) + (rx/push! response {:revn 2}) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "second save lands")) + (t/is (= :saved (get-in @store [:persistence :status]))) + ;; Phase 3 — answering saves the file: the tick stays silent. + (reset! clock 1000000) + (rx/push! ticks :tick) + (t/is (= 1 (count @reports)) "A saved file must not be reported") -(t/deftest pending-edits-are-monitored-without-extending-the-deadline - (with-watchdog - (fn [{:keys [clock ticks reports store]}] - (rx/push! ticks :tick) - (t/is (empty? @reports) "An idle file must not be reported") - (ptk/emit! store (#'dps/update-status :pending)) - (reset! clock 300001) - (ptk/emit! store (#'dps/update-status :pending)) - (rx/push! ticks :tick) - (t/is (= 1 (count @reports))) - (ptk/emit! store (#'dps/update-status :error)) - (reset! clock 900000) - (rx/push! ticks :tick) - (t/is (= 1 (count @reports)) "Do not report an already failed save")))) + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + ;; Phase 4 — a later hang is a new stall with its own report. + (reset! clock 1300001) + (rx/push! ticks :tick) + (await (async/wait-for #(= 2 (count @reports)) "later stall reports again")) + (t/is (= 2 (count @reports)) "A later stall gets its own report"))))) -(t/deftest reinitializing-persistence-replaces-the-watchdog - (let [active-timers (atom 0) - ticks (rx/subject) - store (ptk/store {:state {} :on-error #(t/is false (str %))})] - (with-redefs [rx/interval (mock/stub - (fn [_] - (rx/create - (fn [subscriber] - (swap! active-timers inc) - (let [subscription (.subscribe ticks subscriber)] - (fn [] - (rx/dispose! subscription) - (swap! active-timers dec)))))))] - (try - (ptk/emit! store (dps/initialize-persistence)) - (t/is (= 1 @active-timers)) - (ptk/emit! store (dps/initialize-persistence)) - (t/is (= 1 @active-timers)) - (finally - (rx/dispose! store) - (rx/end! ticks)))) - (t/is (zero? @active-timers)))) +;; Scenario: no network request exists at all — an edit sits `:pending` +;; locally without being sent. The watchdog still tracks it against the +;; original deadline (re-marking `:pending` does not push it): an idle +;; file never reports, an aged pending edit reports once, and once the +;; save fails outright the watchdog stands down — failures belong to the +;; save path, never twice. Proves: pending edits are watched without +;; extending deadlines, and failures are not re-reported as stalls. +(t/deftest ^:async pending-edits-are-monitored-without-extending-the-deadline + (await + (with-watchdog + (^:async fn [{:keys [clock ticks reports store file-id]}] + ;; Phase 1 — idle: nothing queued, nothing reported. + (rx/push! ticks :tick) + (await (async/settle)) + (t/is (empty? @reports) "An idle file must not be reported") + ;; Phase 2 — a queued (yet unsent) edit ages into one report, and + ;; re-marking pending does not extend its deadline. + (let [id (uuid/next)] + (ptk/emit! store + #(assoc % :persistence {:queue (conj #queue [] id) + :index {id {:id id :file-id file-id}}}))) + (ptk/emit! store (#'dps/update-status :pending)) + (reset! clock 300001) + (ptk/emit! store (#'dps/update-status :pending)) + (rx/push! ticks :tick) + (await (async/wait-for #(= 1 (count @reports)) "pending edit ages into a report")) + (t/is (= 1 (count @reports))) + ;; Phase 3 — an outright failure is not a stall: no second report. + (ptk/emit! store (#'dps/update-status :error)) + (reset! clock 900000) + (rx/push! ticks :tick) + (await (async/settle)) + (t/is (= 1 (count @reports)) "Do not report an already failed save"))))) +;; Scenario: initializing twice must replace the watchdog instead of stacking +;; it — a single active timer at all times, zero after teardown. Proves: +;; reinitialization swaps the timer subscription instead of leaking it. +;; (Fully synchronous bodies need no awaits; the promise shell still +;; guarantees restore and completion.) +(t/deftest ^:async reinitializing-persistence-replaces-the-watchdog + (await + (let [active-timers (atom 0) + ticks (rx/subject) + store (ptk/store {:state {} :on-error #(t/is false (str %))})] + (mock/with-mocks* + {rx/interval (mock/stub + (fn [_] + (rx/create + (fn [subscriber] + (swap! active-timers inc) + (let [subscription (.subscribe ticks subscriber)] + (fn [] + (rx/dispose! subscription) + (swap! active-timers dec))))))) + st/ongoing-tasks (atom #{})} + (try + (ptk/emit! store (dps/initialize-persistence)) + (t/is (= 1 @active-timers)) + (ptk/emit! store (dps/initialize-persistence)) + (t/is (= 1 @active-timers)) + (finally + (rx/dispose! store) + (rx/end! ticks) + (t/is (zero? @active-timers)))))))) + +;; Scenario: the first send fails transiently and the retry resend hangs +;; forever inside the `:retrying` episode. Past the 5-minute deadline the +;; watchdog must still report the stall exactly once — and never repeat +;; it — while the queued edit is preserved. Proves: the watchdog sees +;; hung resends; a `:retrying` episode cannot deadlock silently. +(t/deftest ^:async hung-retry-resend-is-reported-as-a-stall + (let [calls (atom 0) + timer-s (rx/subject)] + (await + (with-watchdog + (^:async fn [{:keys [clock ticks reports causes requests store file-id]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [_] timer-s)) + rp/cmd! (mock/stub (fn [cmd params] + (swap! requests conj {:cmd cmd :params params}) + (if (= 1 (swap! calls inc)) + (rx/throw (ex-info "offline" {:type :offline})) + ;; The resend hangs forever: the subject + ;; is never answered nor failed. + (rx/subject))))} + ;; Phase 1 — first send fails transiently; the episode parks + ;; on the hand-fired timer. + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :retrying (get-in @store [:persistence :status])) + "transient failure retries")) + (t/is (= 1 (count @requests))) + ;; Phase 2 — the timer fires and the resend goes out, hanging. + (rx/push! timer-s :tick) + (await (async/wait-for #(= 2 (count @requests)) "retry resends")) + (t/is (= :retrying (get-in @store [:persistence :status]))) + ;; Phase 3 — past the deadline the watchdog reports the stall + ;; once, keeping the queued edit. + (reset! clock 300001) + (rx/push! ticks :tick) + (await (async/wait-for #(= 1 (count @reports)) "hung resend stalls")) + (t/is (= 1 (count @reports))) + (t/is (= "handled-exception" (:event-name (first @reports)))) + (t/is (= :saving-stalled (:code (ex-data (first @causes))))) + (t/is (= :retrying (get-in @store [:persistence :status]))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + ;; Phase 4 — later ticks repeat nothing for the same stall. + (reset! clock 900000) + (rx/push! ticks :tick) + (await (async/settle)) + (t/is (= 1 (count @reports)) "Do not repeat a report for the same stall") + (rx/end! timer-s)))))))) + +;; Save pipeline tests. +;; +;; Production contract under test (`app.main.data.persistence`): +;; - Commits queue locally and flush in order; every request carries the +;; queued changes exactly once (no resends of active requests, no drops). +;; - Without edit permission nothing is sent and the failure flashes. +;; - Transport failures and malformed answers (`nil`, empty, bad revision) +;; fail the save but preserve the queue for retry. +;; - Initialization recovers gracefully: dangling runners error instead of +;; skipping, unsent commits are picked up, unknown outcomes are sent again +;; under the same commit id. +;; +;; Same async pattern as the watchdog tests: triggers stay bare, every +;; assert block is preceded by `wait-for` on its leading signal (or a bare +;; `settle` tick when it asserts only absence). (defn- with-persistence - [f] + "Async fixture for the persistence tests: mocks the transport, flash and + the leave-page task set (`:tasks`), and runs `f` with persistence + initialized. + + Evaluates to a promise resolving once `f` settles and teardown completes; + `await` it from an `^:async` test. + + By default the transport responds through the `:response` subject. Pass a + `respond` function (`(fn [cmd params] observable)`) to drive the transport + response directly instead. Delivery through the subject is asynchronous + (`observe-on :async`): await each effect — via `wait-for` — before + asserting it." + [f & [respond]] (let [file-id (uuid/next) response (rx/subject) failures (atom []) requests (atom []) + tasks (atom #{}) store (ptk/store {:state {:current-file-id file-id :permissions {:can-edit true} :files {file-id {:id file-id :revn 0}}} :on-error #(t/is false (str %))})] - (with-redefs [rp/cmd! (mock/stub (fn [cmd params] - (swap! requests conj [cmd params]) - (rx/take 1 response))) - errors/flash (fn [& {:keys [cause]}] - (swap! failures conj cause))] + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [cmd params] + (swap! requests conj [cmd params]) + (if respond + (respond cmd params) + (->> response (rx/take 1) (rx/observe-on :async))))) + errors/flash (fn [& {:keys [cause]}] + (swap! failures conj cause)) + st/ongoing-tasks tasks} (try (ptk/emit! store (dps/initialize-persistence)) - (f {:file-id file-id :response response :failures failures - :requests requests :store store}) + (await (f {:file-id file-id :response response :failures failures + :requests requests :store store :tasks tasks})) (finally (rx/dispose! store) (rx/end! response)))))) -(t/deftest permission-loss-fails-without-discarding-queued-edits - (with-persistence - (fn [{:keys [file-id requests failures store]}] - (ptk/emit! store (local-commit file-id) - #(assoc-in % [:permissions :can-edit] false) - ::dps/force-persist) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= 1 (count (get-in @store [:persistence :queue])))) - (t/is (empty? @requests)) - (t/is (= 1 (count @failures))) - (ptk/emit! store (local-commit file-id) ::dps/force-persist - (#'dps/update-status :pending)) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= 2 (count (get-in @store [:persistence :queue]))))))) +;; Scenario: edit permission is revoked with an edit queued. The save fails +;; without sending anything, the queue keeps the edit and the user is +;; notified once, so `wait-persisted-or-error` still rejects and nothing is +;; snapshotted from unsaved state. Proves: permission loss fails safe. +(t/deftest ^:async permission-loss-retains-the-queue-and-notifies-once + (await + (with-persistence + (^:async fn [{:keys [file-id requests failures store]}] + (ptk/emit! store (local-commit file-id) + #(assoc-in % [:permissions :can-edit] false) + ::dps/force-persist) + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (= 1 (count (get-in @store [:persistence :queue])))) + "permission loss fails the save")) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= 1 (count (get-in @store [:persistence :queue]))) + "the undeliverable edit is retained, so waiters keep rejecting") + (t/is (empty? @requests)) + (t/is (= 1 (count @failures))))))) -(t/deftest failed-request-retains-the-queue-and-is-not-retried-on-initialization +;; Scenario: the same terminal failure, then the user opens a file again. +;; Opening a file releases the edit that can never be saved and settles the +;; editor, instead of leaving it halted until a reload. Proves: the halt does +;; not outlive the visit that caused it. +(t/deftest ^:async opening-a-file-again-releases-a-terminal-failure + (await + (with-persistence + (^:async fn [{:keys [file-id requests failures store]}] + (ptk/emit! store (local-commit file-id) + #(assoc-in % [:permissions :can-edit] false) + ::dps/force-persist) + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (= 1 (count (get-in @store [:persistence :queue])))) + "the save fails terminally")) + ;; The user acts again: a team-role change is one of the two real + ;; recovery triggers (the other is opening a file, whose event does + ;; real work and is not usable as a bare signal here). + (ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "re-entering the file settles the queue")) + (t/is (nil? (get-in @store [:persistence :error])) + "the halt is cleared once the user comes back") + (t/is (empty? @requests) + "the edit that cannot be saved is not replayed on re-entry") + (t/is (= 1 (count @failures)) "the user is still notified exactly once"))))) + +;; Scenario: two files have pending edits and the first send fails +;; terminally. Opening a file again releases the failed file, the other file +;; still saves, and the editor settles saved instead of staying halted. +;; Proves: one file that cannot be saved does not block every other file. +(t/deftest ^:async terminal-failure-does-not-block-other-files + (let [files (into [] (repeatedly 2 uuid/next)) + sends (atom 0)] + (await + (with-persistence + (^:async fn [{:keys [requests store]}] + (ptk/emit! store + #(assoc % :files (into (:files %) + (map (fn [id] [id {:id id :revn 0}])) + files)) + (local-commit (nth files 0)) + (local-commit (nth files 1)) + ::dps/force-persist) + (await (async/wait-for #(seq @requests) "the first file is sent")) + (let [failed-file (get-in (second (first @requests)) [:id])] + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (not (empty? (get-in @store [:persistence :queue])))) + "the terminal failure retains the queue")) + ;; The user acts again: recovery releases the failed file and drains + ;; the rest. + (ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "the released file frees the rest of the queue")) + (t/is (= 2 (count @requests)) + "the other file was saved after the failed one was released") + (t/is (= 1 (count (filter #(= failed-file (get-in (second %) [:id])) @requests))) + "the failed file is sent once, never replayed"))) + (fn [_ _] + ;; Fail the first send terminally and answer the rest inline, so no + ;; hot-subject race can swallow the answer. + (if (= 1 (swap! sends inc)) + (rx/throw (ex-info "rejected" {:type :validation :code :invalid-data})) + (rx/of {:revn 1}))))))) + +(defn- check-save-failure-warning + "Fails a queued save with `cause-type` and asserts the hint shown to the + user; authentication failures have their own UI and must stay silent." + [cause-type] (with-persistence - (fn [{:keys [file-id response requests store]}] - (ptk/emit! store (local-commit file-id) ::dps/force-persist - (local-commit file-id) ::dps/force-persist) - (.error response (ex-info "Connection lost" {:type :network})) + (^:async fn [{:keys [file-id response store]}] + (let [notifications (atom [])] + (await + (mock/with-mocks* + {errors/flash (fn [& params] + (swap! notifications conj (apply hash-map params))) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (.error response (ex-info "Raw transport details" {:type cause-type})) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "failed request errors the save")) + (t/is (= :error (get-in @store [:persistence :status]))) + (let [data (get-in @store [:persistence :error])] + (ptk/handle-error (assoc data ::errors/instance (ex-info "Save failed" data)))) + (if (= cause-type :authentication) + (do (await (async/settle)) + (t/is (empty? @notifications))) + (do (await (async/wait-for #(seq @notifications) "save-failure toast")) + (t/is (= ["translated:errors.save-failed"] + (mapv :hint @notifications))))))))))) + +;; Variant: an authentication failure stays silent (own UI flow). +(t/deftest ^:async authentication-save-failure-shows-no-warning + (await (check-save-failure-warning :authentication))) + +;; NOTE: the `:network` and `:offline` warning variants lived here while +;; transport failures went straight to `:error`. Transient failures now +;; retry instead, so their coverage moved to the retry tests below (and to +;; the exhaustion test for the terminal toast). + +;; The transient/terminal classification is pure: transport failures and +;; unusable save responses retry with backoff, everything else stays +;; terminal. Note the wrapped shape: `persistence-failed` records the +;; original cause under `:cause-type` with `:type :persistence`. +(t/deftest transient-error-classification + (t/is (dps/transient-error? {:type :network})) + (t/is (dps/transient-error? {:type :offline})) + (t/is (dps/transient-error? {:type :bad-gateway})) + (t/is (dps/transient-error? {:type :service-unavailable})) + (t/is (dps/transient-error? {:type :persistence :cause-type :network})) + (t/is (dps/transient-error? {:type :persistence :cause-type :offline})) + (t/is (dps/transient-error? {:type :internal :cause-type :bad-gateway})) + (t/is (dps/transient-error? {:type :persistence :cause-type :service-unavailable})) + ;; The wrapped shape stays transient with an explicit non-retry code too: + ;; the cause-type carries the transport verdict, not the wrapper's code. + (t/is (dps/transient-error? {:type :persistence :cause-type :service-unavailable :code :save-failed})) + (t/is (dps/transient-error? {:type :persistence :cause-type :bad-gateway :code :save-failed})) + (t/is (dps/transient-error? {:type :persistence :code :invalid-save-response})) + (t/is (not (dps/transient-error? {:type :authentication}))) + (t/is (not (dps/transient-error? {:type :validation}))) + (t/is (not (dps/transient-error? {:type :internal}))) + (t/is (not (dps/transient-error? {:type :persistence :cause-type :authentication}))) + (t/is (not (dps/transient-error? {:type :persistence :code :missing-commit}))) + (t/is (not (dps/transient-error? {:type :persistence :code :save-permission-denied}))) + (t/is (not (dps/transient-error? {})))) + +;; The transient set is owned by `repo/retryable-types`: every transport +;; type retryable at the HTTP layer must also be transient for the save +;; pipeline, in both the direct and the wrapped (`persistence-failed`) +;; positions. Proves: the two classifications cannot drift apart. +(t/deftest transient-classification-follows-repo-retryable-types + (doseq [transport-type rp/retryable-types] + (t/testing (str "transport type " transport-type " is transient in both positions") + (t/is (dps/transient-error? {:type transport-type})) + (t/is (dps/transient-error? {:type :persistence :cause-type transport-type}))))) + +;; Scenario: a save fails transiently and the episode parks on its backoff +;; timer. A waiter started mid-episode must not resolve on the `:retrying` +;; transition — only the terminal save releases it. Proves: `:retrying` is +;; non-terminal for waiters. +(t/deftest waiter-release-rule + ;; The waiter release rule is pure: only a failed save or a settled + ;; (`nil` / `:saved`) empty queue releases waiters. In particular, the + ;; `:retrying` episode — queue non-empty, status non-terminal — never + ;; does. Proves: `:retrying` is non-terminal for waiters, a document of + ;; intent over the `wait-persisted-or-error` filter. + ;; + ;; Note: this intentionally avoids scripting the global store atom — it + ;; is shared with the whole suite, so any test reading it observes + ;; other namespaces' leftovers depending on run order. + (t/is (dps/terminal-status? {:status :error :queue [1]})) + (t/is (dps/terminal-status? {:status :saved :queue []})) + (t/is (dps/terminal-status? {:status nil :queue []})) + (t/is (dps/terminal-status? {:status nil :queue nil})) + (t/is (not (dps/terminal-status? {:status :retrying :queue [1]}))) + (t/is (not (dps/terminal-status? {:status :saving :queue [1]}))) + (t/is (not (dps/terminal-status? {:status :pending :queue [1]}))) + (t/is (not (dps/terminal-status? {:status :saved :queue [1]})))) + +;; Scenario: the queue references a commit id with no matching commit +;; (plus a dangling run id) when persistence initializes. It must error +;; instead of silently skipping the unknown entry, keeping it queued and +;; sending nothing. Proves: dangling queue entries fail loudly, never skip. +(t/deftest ^:async missing-commit-is-an-error-instead-of-skipping-changes + (await + (with-persistence + (^:async fn [{:keys [requests store]}] + (let [id (uuid/next)] + (ptk/emit! store + #(assoc % :persistence {:queue (conj #queue [] id) + :index {} :run-id (uuid/next) + :status :saving}) + (dps/initialize-persistence)) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "dangling commit errors instead of skipping")) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= [id] (vec (get-in @store [:persistence :queue])))) + (t/is (empty? @requests))))))) + +;; Scenario: a valid but unsent commit sits queued with a dangling run id +;; when persistence initializes. It is sent exactly once and, once answered, +;; the file saves with an empty queue. Proves: unsent commits survive a +;; stale runner — neither stuck nor duplicated. +(t/deftest ^:async initialization-recovers-an-unsent-commit-with-a-dangling-run-id + (await + (with-persistence + (^:async fn [{:keys [file-id response requests store]}] + (let [commit (assoc @(local-commit file-id) :changes []) + id (:id commit)] + (ptk/emit! store + #(assoc % :persistence {:queue (conj #queue [] id) + :index {id commit} :run-id (uuid/next) + :status :saving}) + (dps/initialize-persistence)) + (await (async/wait-for #(= 1 (count @requests)) "dangling commit is sent")) + (t/is (= 1 (count @requests))) + (rx/push! response {:revn 1}) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "recovered save lands")) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue])))))))) + +(defn- check-active-request-not-resent + "Sends a commit, interrupts the active request with `interrupt` and + reinitializes persistence: the pending request must not be sent again." + [interrupt] + (with-persistence + (^:async fn [{:keys [file-id response requests store]}] + (apply ptk/emit! store (local-commit file-id) ::dps/force-persist interrupt) (ptk/emit! store (dps/initialize-persistence)) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= 2 (count (get-in @store [:persistence :queue])))) - (t/is (= 1 (count @requests)))))) - -(t/deftest save-failures-use-a-translated-warning-except-for-authentication - (doseq [cause-type [:network :offline :authentication]] - (with-persistence - (fn [{:keys [file-id response store]}] - (let [notifications (atom [])] - (with-redefs [errors/flash (fn [& params] - (swap! notifications conj (apply hash-map params))) - i18n/tr (mock/stub #(str "translated:" %))] - (ptk/emit! store (local-commit file-id) ::dps/force-persist) - (.error response (ex-info "Raw transport details" {:type cause-type})) - (t/is (= :error (get-in @store [:persistence :status]))) - (let [data (get-in @store [:persistence :error])] - (ptk/handle-error (assoc data ::errors/instance (ex-info "Save failed" data)))) - (if (= cause-type :authentication) - (t/is (empty? @notifications)) - (t/is (= ["translated:errors.save-failed"] - (mapv :hint @notifications)))))))))) - -(t/deftest missing-commit-is-an-error-instead-of-skipping-changes - (with-persistence - (fn [{:keys [requests store]}] - (let [id (uuid/next)] - (ptk/emit! store - #(assoc % :persistence {:queue (conj #queue [] id) - :index {} :run-id (uuid/next) - :status :saving}) - (dps/initialize-persistence)) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= [id] (vec (get-in @store [:persistence :queue])))) - (t/is (empty? @requests)))))) - -(t/deftest initialization-recovers-an-unsent-commit-with-a-dangling-run-id - (with-persistence - (fn [{:keys [file-id response requests store]}] - (let [commit (assoc @(local-commit file-id) :changes []) - id (:id commit)] - (ptk/emit! store - #(assoc % :persistence {:queue (conj #queue [] id) - :index {id commit} :run-id (uuid/next) - :status :saving}) - (dps/initialize-persistence)) - (t/is (= 1 (count @requests))) - (rx/push! response {:revn 1}) - (t/is (= :saved (get-in @store [:persistence :status]))) - (t/is (empty? (get-in @store [:persistence :queue]))))))) - -(t/deftest an-active-request-is-never-sent-twice - (doseq [interrupt [[(dps/initialize-persistence)] - [(ptk/data-event ::dps/error)]]] - (with-persistence - (fn [{:keys [file-id response requests store]}] - (apply ptk/emit! store (local-commit file-id) ::dps/force-persist interrupt) - (ptk/emit! store (dps/initialize-persistence)) - (t/is (= 1 (count @requests))) - (rx/push! response {:revn 1}) - (t/is (= :saved (get-in @store [:persistence :status]))) - (t/is (empty? (get-in @store [:persistence :queue]))))))) - -(t/deftest permission-restoration-resumes-only-unsent-edits - (with-persistence - (fn [{:keys [file-id response requests store]}] - (ptk/emit! store (local-commit file-id) ::dps/force-persist - (local-commit file-id) ::dps/force-persist - #(assoc-in % [:permissions :can-edit] false)) - (rx/push! response {:revn 1}) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= 1 (count (get-in @store [:persistence :queue])))) - (ptk/emit! store - #(assoc-in % [:permissions :can-edit] true) - (ptk/data-event :app.main.data.common/change-team-role)) - (t/is (= 2 (count @requests))) - (rx/push! response {:revn 2}) - (t/is (= :saved (get-in @store [:persistence :status]))) - (t/is (empty? (get-in @store [:persistence :queue])))))) - -(t/deftest recovery-keeps-an-acknowledgment-received-without-a-runner - (with-persistence - (fn [{:keys [file-id response requests store]}] - (ptk/emit! store (local-commit file-id) ::dps/force-persist - (ptk/data-event ::dps/error)) - (rx/push! response {:revn 1}) - (t/is (= 1 (count (get-in @store [:persistence :queue])))) - (ptk/emit! store (dps/initialize-persistence)) - (t/is (= 1 (count @requests)) "The acknowledged changes must not be sent again") - (t/is (= :saved (get-in @store [:persistence :status]))) - (t/is (empty? (get-in @store [:persistence :queue])))))) - -(t/deftest recovery-reports-an-unknown-request-outcome-without-replaying-it - (with-persistence - (fn [{:keys [file-id requests store]}] - (let [commit (assoc @(local-commit file-id) ::dps/request-id (uuid/next)) - id (:id commit)] - (ptk/emit! store - #(assoc % :persistence {:queue (conj #queue [] id) - :index {id commit} :status :saving}) - (dps/initialize-persistence)) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= :save-outcome-unknown (get-in @store [:persistence :error :code]))) - (t/is (= [id] (vec (get-in @store [:persistence :queue])))) - (t/is (empty? @requests)))))) - -(t/deftest initialization-flushes-buffered-edits-without-duplicating-them - (with-persistence - (fn [{:keys [file-id response requests store]}] - (ptk/emit! store (local-commit file-id) - (dps/initialize-persistence) - ::dps/force-persist) + (await (async/wait-for #(= 1 (count @requests)) "only the active request is sent")) (t/is (= 1 (count @requests))) - (t/is (= 1 (count (get-in @store [:persistence :queue])))) (rx/push! response {:revn 1}) - (t/is (= :saved (get-in @store [:persistence :status])))))) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "active request completes")) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue])))))) -(t/deftest synchronous-save-results-do-not-leave-a-dangling-runner - (with-persistence - (fn [{:keys [file-id store]}] - (with-redefs [rp/cmd! (mock/stub (fn [_ _] (rx/of {:revn 1})))] - (ptk/emit! store (local-commit file-id) ::dps/force-persist) - (t/is (= :saved (get-in @store [:persistence :status]))) - (t/is (empty? (get-in @store [:persistence :queue]))))))) +;; Variant: the interrupt is a reinitialization. +(t/deftest ^:async reinitializing-does-not-resend-an-active-request + (await (check-active-request-not-resent [(dps/initialize-persistence)]))) -(t/deftest empty-or-invalid-save-responses-preserve-the-queue-as-failed - (doseq [result [(rx/empty) (rx/of nil) (rx/of {:revn -1})]] - (with-persistence - (fn [{:keys [file-id store]}] - (with-redefs [rp/cmd! (mock/stub (fn [_ _] result))] - (ptk/emit! store (local-commit file-id) ::dps/force-persist) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= :invalid-save-response (get-in @store [:persistence :error :code]))) - (t/is (= 1 (count (get-in @store [:persistence :queue]))))))))) +;; Variant: the interrupt is a save error event. +(t/deftest ^:async save-error-does-not-resend-an-active-request + (await (check-active-request-not-resent [(ptk/data-event ::dps/error)]))) + +;; Scenario: permission is lost mid-flight, so the save fails terminally. +;; Even if the permission comes back, recovery releases the undeliverable +;; edits instead of replaying them. The backend raises `:not-found` for a +;; lost edit permission, so a resend would only route the user to the +;; exception page. Proves: permission loss is treated as permanent, like +;; every other terminal failure. +(t/deftest ^:async permission-loss-is-released-not-resumed + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (ptk/emit! store (local-commit file-id) + #(assoc-in % [:permissions :can-edit] false) + ::dps/force-persist) + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (= 1 (count (get-in @store [:persistence :queue])))) + "permission loss fails the save")) + ;; Permission comes back and a file is opened again. + (ptk/emit! store #(assoc-in % [:permissions :can-edit] true) + (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "recovery releases the undeliverable edit")) + (t/is (empty? @requests) + "the undeliverable edit is never replayed, not even once the + permission is back") + (t/is (not= :error (get-in @store [:persistence :status])) + "the editor is not left halted"))))) + +;; Scenario: a terminal failure is waiting, and persistence is re-initialized. +;; Initialization must not lift the halt: only opening a file does. Otherwise +;; the edits would be released the moment the workspace view mounts, instead +;; of when the user acts on them. Proves: the init/recovery split holds. +(t/deftest ^:async initialization-does-not-lift-a-halt + (await + (with-persistence + (^:async fn [{:keys [file-id response store]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (.error response (ex-info "Validation failed" {:type :validation})) + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (= 1 (count (get-in @store [:persistence :queue])))) + "the save fails terminally")) + (ptk/emit! store (dps/initialize-persistence)) + (await (async/settle)) + (t/is (= :error (get-in @store [:persistence :status])) + "initialization leaves the halt alone") + (t/is (= 1 (count (get-in @store [:persistence :queue]))) + "and keeps the undeliverable edits queued until the user acts"))))) + +;; Scenario: the answer arrives but no runner is tracking the request +;; anymore. The acknowledged commit stays queued instead of being dropped; +;; on initialization it is settled as saved without resending. Proves: late +;; acknowledgments are neither lost nor replayed. +(t/deftest ^:async recovery-keeps-an-acknowledgment-received-without-a-runner + (await + (with-persistence + (^:async fn [{:keys [file-id response requests store]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist + (ptk/data-event ::dps/error)) + (rx/push! response {:revn 1}) + (await (async/wait-for #(= 1 (count (get-in @store [:persistence :queue]))) + "acknowledged commit stays queued")) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + (ptk/emit! store (dps/initialize-persistence)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "recovery completes without resending")) + (t/is (= 1 (count @requests)) "The acknowledged changes must not be sent again") + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue]))))))) + +;; Scenario: a queued commit carries a request id but persistence has no +;; run id for it when initializing, so its outcome is unknown. It is sent +;; again under the same commit id and saves. Proves: an unknown outcome is +;; resent, since changes are idempotent. +(t/deftest ^:async recovery-sends-again-a-save-with-an-unknown-outcome + (await + (with-persistence + (^:async fn [{:keys [file-id response requests store]}] + (let [commit (assoc @(local-commit file-id) ::dps/request-id (uuid/next)) + id (:id commit)] + (ptk/emit! store + #(assoc % :persistence {:queue (conj #queue [] id) + :index {id commit} :status :saving}) + (dps/initialize-persistence)) + (await (async/wait-for #(= 1 (count @requests)) "the commit is sent again")) + (t/is (= id (:commit-id (second (first @requests))))) + (rx/push! response {:revn 1}) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "the resent commit saves")) + (t/is (= 1 (count @requests)))))))) + +;; Scenario: an edit is queued before persistence even initializes. On +;; initialization plus force-persist it is sent exactly once (still queued +;; until answered); once answered, the file saves. Proves: pre-init buffered +;; edits flush exactly once, without duplicating. +(t/deftest ^:async initialization-flushes-buffered-edits-without-duplicating-them + (await + (with-persistence + (^:async fn [{:keys [file-id response requests store]}] + (ptk/emit! store (local-commit file-id) + (dps/initialize-persistence) + ::dps/force-persist) + (await (async/wait-for #(and (= 1 (count @requests)) + (= 1 (count (get-in @store [:persistence :queue])))) + "buffered edit is sent once")) + (t/is (= 1 (count @requests))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + (rx/push! response {:revn 1}) + (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) + "buffered edit saves")) + (t/is (= :saved (get-in @store [:persistence :status]))))))) + +;; Scenario: the transport answers synchronously (immediate observable). +;; The save completes, the queue empties, and no runner is left dangling. +;; Proves: synchronous transport responses settle cleanly. +(t/deftest ^:async synchronous-save-results-do-not-leave-a-dangling-runner + (await + (with-persistence + (^:async fn [{:keys [file-id store]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "synchronous save completes")) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue])))) + (fn [_ _] (rx/of {:revn 1}))))) + +;; Leave-page guard tests. + +;; Scenario: an edit is buffered, then sent, then saved. The leave-page +;; guard turns on with the buffered edit, stays on while the save is in +;; flight, and turns off once it lands. Proves: unsaved edits always keep +;; the browser's leave-page warning on. +(t/deftest ^:async unsaved-edits-keep-the-leave-page-guard + (await + (with-persistence + (^:async fn [{:keys [file-id response requests store tasks]}] + (ptk/emit! store (local-commit file-id)) + (await (async/wait-for #(contains? @tasks :persistence) + "a buffered edit turns the guard on")) + (ptk/emit! store ::dps/force-persist) + (await (async/wait-for #(seq @requests) "the edit is sent")) + (t/is (contains? @tasks :persistence) "the guard stays on while the save is in flight") + (rx/push! response {:revn 1}) + (await (async/wait-for #(not (contains? @tasks :persistence)) + "the save turns the guard off")) + (t/is (= :saved (get-in @store [:persistence :status]))))))) + +;; Scenario: a second edit arrives while the first is being saved, and the +;; first save lands while the second edit is still in the buffer. The queue +;; is empty at that point, yet the guard stays on until the second edit is +;; saved. Proves: edits waiting in the buffer keep the leave-page warning on. +(t/deftest ^:async edits-buffered-during-a-save-keep-the-leave-page-guard + (await + (with-persistence + (^:async fn [{:keys [file-id response requests store tasks]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @requests)) "the first edit is sent")) + (ptk/emit! store (local-commit file-id)) + (rx/push! response {:revn 1}) + (await (async/wait-for #(empty? (get-in @store [:persistence :queue])) + "the first save lands")) + (await (async/settle)) + (t/is (contains? @tasks :persistence) "the buffered edit keeps the guard on") + (ptk/emit! store ::dps/force-persist) + (await (async/wait-for #(= 2 (count @requests)) "the buffered edit is sent")) + (rx/push! response {:revn 2}) + (await (async/wait-for #(not (contains? @tasks :persistence)) + "the second save turns the guard off")) + (t/is (empty? (get-in @store [:persistence :queue]))))))) + +;; Scenario: a save fails terminally, then recovery releases the edit. The +;; guard stays on while the failed edit is retained, and turns off once it +;; is released. Proves: a release that empties the queue also turns the +;; leave-page warning off. +(t/deftest ^:async releasing-a-failed-edit-turns-the-leave-page-guard-off + (await + (with-persistence + (^:async fn [{:keys [file-id store tasks]}] + (ptk/emit! store (local-commit file-id) + #(assoc-in % [:permissions :can-edit] false) + ::dps/force-persist) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the save fails terminally")) + (await (async/settle)) + (t/is (contains? @tasks :persistence) "the retained edit keeps the guard on") + (ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(empty? (get-in @store [:persistence :queue])) + "recovery releases the edit")) + (await (async/wait-for #(not (contains? @tasks :persistence)) + "the release turns the guard off")) + (t/is (= :saved (get-in @store [:persistence :status]))))))) diff --git a/frontend/test/frontend_tests/data/repo_test.cljs b/frontend/test/frontend_tests/data/repo_test.cljs index 6b0f99e742..9c9c6a7355 100644 --- a/frontend/test/frontend_tests/data/repo_test.cljs +++ b/frontend/test/frontend_tests/data/repo_test.cljs @@ -8,7 +8,8 @@ (:require [app.main.repo :as repo] [beicon.v2.core :as rx] - [cljs.test :as t :include-macros true])) + [cljs.test :as t :include-macros true] + [cuerdas.core :as str])) ;; --------------------------------------------------------------------------- ;; retryable-error? tests (synchronous) @@ -29,6 +30,11 @@ (let [err (ex-info "service unavailable" {:type :service-unavailable})] (t/is (true? (repo/retryable-error? err)))))) +(t/deftest retryable-error-gateway-error + (t/testing "504 and the Cloudflare 52x family are retryable" + (let [err (ex-info "http error" {:type :gateway-error})] + (t/is (true? (repo/retryable-error? err)))))) + (t/deftest retryable-error-offline (t/testing "offline (status 0) is retryable" (let [err (ex-info "offline" {:type :offline})] @@ -215,3 +221,123 @@ (t/is (= 2 @call-count)) (t/is (= :authentication (:type (ex-data err)))) (done)))))))) + +;; --------------------------------------------------------------------------- +;; handle-response classification tests +;; --------------------------------------------------------------------------- + +(defn- capture-error + "Subscribes to `handle-response` for `response` and calls `k` with the + `ex-data` of the raised error." + [response k] + (->> (repo/handle-response response) + (rx/subs! + (fn [_] (t/is false "expected an error response") (k nil)) + (fn [cause] (k (ex-data cause)))))) + +(def ^:private html-error-page + "Web Page Blockedblocked") + +(t/deftest handle-response-gateway-statuses + (t/testing "every edge status that never reached the backend is a gateway error" + (t/async done + (let [statuses [504 520 521 522 523 524] + pending (atom (count statuses))] + (doseq [status statuses] + (capture-error {:status status + :body html-error-page + :headers {"content-type" "text/html"} + :uri "https://design.penpot.app/api/main/methods/get-teams"} + (fn [data] + (t/is (= :gateway-error (:type data)) + (str "status " status " should be a gateway error")) + (t/is (= status (:status data)) + (str "status " status " should be carried through")) + (when (zero? (swap! pending dec)) + (done))))))))) + +(t/deftest handle-response-rate-limit + (t/testing "a 429 with no interpretable body is a rate-limit error" + (t/async done + (capture-error {:status 429 + :body "" + :headers {} + :uri "https://design.penpot.app/api/main/methods/get-teams"} + (fn [data] + (t/is (= :rate-limit (:type data))) + (t/is (= 429 (:status data))) + (done)))))) + +(t/deftest handle-response-intercepted-by-proxy + (t/testing "an error page from a filtering proxy is not an internal error" + (t/async done + (capture-error {:status 403 + :body html-error-page + :headers {"content-type" "text/html" + "server" "Zscaler/6.2"} + :uri "https://design.penpot.app/api/main/methods/update-file"} + (fn [data] + (t/is (= :unexpected-response (:type data))) + (t/is (= 403 (:status data))) + (done)))))) + +(t/deftest handle-response-keeps-body-excerpt + (t/testing "a body too long to keep is cut short and marked as cut" + (t/async done + (capture-error {:status 403 + :body (apply str (repeat 5000 "x")) + :headers {"content-type" "text/html"} + :uri "https://design.penpot.app/api/main/methods/update-file"} + (fn [data] + (t/is (= 503 (count (:data data))) "500 characters and an ellipsis") + (t/is (str/ends-with? (:data data) "...")) + (done)))))) + +(t/deftest handle-response-keeps-a-short-body-whole + (t/testing "a body short enough to read is passed through untouched" + (t/async done + (capture-error {:status 403 + :body html-error-page + :headers {"content-type" "text/html"} + :uri "https://design.penpot.app/api/main/methods/update-file"} + (fn [data] + (t/is (= html-error-page (:data data))) + (done)))))) + +(t/deftest handle-response-challenge-still-authorization + (t/testing "a Cloudflare challenge keeps its own type" + (t/async done + (capture-error {:status 403 + :body html-error-page + :headers {"server" "cloudflare" + "cf-mitigated" "challenge"} + :uri "https://design.penpot.app/api/main/methods/get-teams"} + (fn [data] + (t/is (= :authorization (:type data))) + (t/is (= :challenge-required (:code data))) + (done)))))) + +(t/deftest handle-response-keeps-backend-error + (t/testing "an error the backend itself reported is passed through untouched" + (t/async done + (capture-error {:status 400 + :body {:type :validation :code :missing-param} + :headers {"content-type" "application/transit+json"} + :uri "https://design.penpot.app/api/main/methods/update-file"} + (fn [data] + (t/is (= :validation (:type data))) + (t/is (= :missing-param (:code data))) + (t/is (= 400 (:status data))) + (done)))))) + +(t/deftest handle-response-undecodable-non-error-is-internal + (t/testing "a non-error response we cannot interpret is still an internal error" + (t/async done + (capture-error {:status 301 + :body html-error-page + :headers {"content-type" "text/html"} + :uri "https://design.penpot.app/api/main/methods/get-teams"} + (fn [data] + (t/is (= :internal (:type data))) + (t/is (= :unable-to-process-repository-response (:code data))) + (done)))))) diff --git a/frontend/test/frontend_tests/errors_governor_test.cljs b/frontend/test/frontend_tests/errors_governor_test.cljs new file mode 100644 index 0000000000..a51f775a43 --- /dev/null +++ b/frontend/test/frontend_tests/errors_governor_test.cljs @@ -0,0 +1,403 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.errors-governor-test + "Unit tests for the error report governor (`app.main.errors`). + + Tests cover: + - error fingerprinting – stable identity incl. wrapper stacks + - reserve-report* – pure window/count/eviction decisions + - submit-report – governed emission, thunk reports + - flash report pipeline – reserve-before-generate, totality" + (:require + [app.main.data.event :as ev] + [app.main.errors :as errors] + [app.main.router :as rt] + [app.main.store :as st] + [app.util.timers :as tm] + [cljs.test :as t :include-macros true] + [cuerdas.core :as str] + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) + +;; --------------------------------------------------------------------------- +;; Error report governor +;; +;; The governor deduplicates report by fingerprint: the first occurrence is +;; always emitted, repeats inside a 2 minute window are counted, and the +;; fingerprint cache is bounded (the oldest entry is evicted when full). +;; --------------------------------------------------------------------------- + +(t/use-fixtures :each {:before #(errors/reset-report-governor!)}) + +(defn- error-cause + [& {:keys [type code hint]}] + (ex-info (or hint "boom") + (cond-> {} + (some? type) (assoc :type type) + (some? code) (assoc :code code) + (some? hint) (assoc :hint hint)))) + +(defn- capture-reports! + "Run `f` capturing the events emitted through `st/emit!`." + [f] + (let [events (atom [])] + (with-redefs [st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")] + (f) + @events))) + +(t/deftest fingerprint-is-stable-for-equivalent-errors + (let [cause-a (error-cause :type :network :code :fetch-failed :hint "unable to perform fetch operation") + cause-b (error-cause :type :network :code :fetch-failed :hint "unable to perform fetch operation")] + (t/is (= (errors/error-fingerprint "handled-exception" cause-a) + (errors/error-fingerprint "handled-exception" cause-b))))) + +(t/deftest fingerprint-changes-with-error-identity + (let [base (error-cause :type :network :code :fetch-failed :hint "boom")] + (t/is (not= (errors/error-fingerprint "handled-exception" base) + (errors/error-fingerprint "handled-exception" + (error-cause :type :network :code :fetch-failed :hint "other")))) + (t/is (not= (errors/error-fingerprint "handled-exception" base) + (errors/error-fingerprint "handled-exception" + (error-cause :type :validation :code :fetch-failed :hint "boom")))) + (t/is (not= (errors/error-fingerprint "handled-exception" base) + (errors/error-fingerprint "handled-exception" + (error-cause :type :network :code :other :hint "boom")))))) + +(t/deftest fingerprint-includes-the-report-name + (let [cause (error-cause :type :network :hint "boom")] + (t/is (not= (errors/error-fingerprint "handled-exception" cause) + (errors/error-fingerprint "unhandled-exception" cause))) + (t/is (not= (errors/error-fingerprint "handled-exception" cause) + (errors/error-fingerprint "exception-page" cause))))) + +(t/deftest fingerprint-handles-missing-type-and-code + (let [fingerprint (errors/error-fingerprint "handled-exception" (js/Error. "plain failure"))] + (t/is (string? fingerprint)) + (t/is (str/starts-with? fingerprint "handled-exception|unknown|unknown|")))) + +(t/deftest fingerprint-skips-non-frame-preamble-lines + ;; Scenario: a bundler/polyfill wrapper prepends extra non-frame lines + ;; before the real frames. Positional indexing would take the preamble + ;; as the frame and fragment the grouping; matching the first frame + ;; line keeps the identity equal to the plain equivalent. Proves: + ;; grouping survives wrapper-prepended stacks. + (let [plain (doto (ex-info "boom" {:type :internal :hint "boom"}) + (unchecked-set "stack" "Error: boom\n at call-site-a (app.js:1)")) + wrapped (doto (ex-info "boom" {:type :internal :hint "boom"}) + (unchecked-set "stack" "Error: boom\nWrapped by polyfill\n at call-site-a (app.js:1)"))] + (t/is (= (errors/error-fingerprint "handled-exception" plain) + (errors/error-fingerprint "handled-exception" wrapped))))) + +(t/deftest environment-fingerprints-ignore-the-stack-frame + (let [cause-a (doto (ex-info "http error" {:type :offline :hint "http error"}) + (unchecked-set "stack" "Error: http error\n at call-site-a (app.js:1)")) + cause-b (doto (ex-info "http error" {:type :offline :hint "http error"}) + (unchecked-set "stack" "Error: http error\n at call-site-b (app.js:2)")) + defect-a (doto (ex-info "boom" {:type :internal :hint "boom"}) + (unchecked-set "stack" "Error: boom\n at call-site-a (app.js:1)")) + defect-b (doto (ex-info "boom" {:type :internal :hint "boom"}) + (unchecked-set "stack" "Error: boom\n at call-site-b (app.js:2)"))] + (t/testing "environment failures group across internal call sites" + (t/is (= (errors/error-fingerprint "handled-exception" cause-a) + (errors/error-fingerprint "handled-exception" cause-b)))) + (t/testing "application defects keep the stack frame in their identity" + (t/is (not= (errors/error-fingerprint "handled-exception" defect-a) + (errors/error-fingerprint "handled-exception" defect-b)))))) + +(t/deftest governor-emits-first-occurrence-and-suppresses-repeats + (let [d1 (errors/reserve-report* (errors/initial-report-state) "fp" 1000) + d2 (errors/reserve-report* d1 "fp" 2000) + d3 (errors/reserve-report* d2 "fp" 3000) + d4 (errors/reserve-report* d3 "fp" (+ 1000 errors/report-window-ms))] + (t/is (true? (::errors/emit d1))) + (t/is (= 1 (::errors/occurrences d1))) + (t/is (false? (::errors/emit d2))) + (t/is (nil? (::errors/occurrences d2))) + (t/is (false? (::errors/emit d3))) + (t/is (nil? (::errors/occurrences d3))) + (t/is (true? (::errors/emit d4))) + (t/is (= 3 (::errors/occurrences d4))))) + +(t/deftest governor-evicts-oldest-entry-when-cache-is-full + (let [base (reduce (fn [state i] + (errors/reserve-report* state (str "fp-" i) (* 1000 i))) + (errors/initial-report-state) + (range errors/max-tracked-fingerprints)) + state (errors/reserve-report* base + "fp-new" + (* 1000 errors/max-tracked-fingerprints))] + (t/is (= errors/max-tracked-fingerprints (count (:entries state)))) + (t/is (= errors/max-tracked-fingerprints (count (:order state)))) + (t/is (true? (::errors/emit state))) + (t/is (nil? (get-in state [:entries "fp-0"]))) + (t/is (= "fp-1" (peek (:order state)))) + (t/is (some? (get-in state [:entries "fp-new"]))))) + +(t/deftest governor-evicts-by-insertion-order-not-by-last-emission + (let [base (reduce (fn [state i] + (errors/reserve-report* state (str "fp-" i) (* 1000 i))) + (errors/initial-report-state) + (range errors/max-tracked-fingerprints)) + ;; fp-0 re-emits after the window, so its :emitted-at becomes the + ;; most recent one, but it keeps its insertion position. + re-emitted (errors/reserve-report* base + "fp-0" + (+ (* 1000 errors/max-tracked-fingerprints) + errors/report-window-ms)) + state (errors/reserve-report* re-emitted + "fp-new" + (+ (* 1000 errors/max-tracked-fingerprints) + errors/report-window-ms + 1000))] + (t/is (true? (::errors/emit state))) + ;; FIFO: the first inserted one goes, even though it was the last + ;; emitted and fp-1 is the oldest by :emitted-at. + (t/is (nil? (get-in state [:entries "fp-0"]))) + (t/is (some? (get-in state [:entries "fp-1"]))) + (t/is (= errors/max-tracked-fingerprints (count (:entries state)))) + (t/is (= errors/max-tracked-fingerprints (count (:order state)))))) + +(t/deftest submit-report-is-governed-and-reports-occurrences + (let [cause (error-cause :type :network :code :fetch-failed :hint "boom") + events (capture-reports! + (fn [] + (dotimes [_ 5] + (errors/submit-report :event-name "handled-exception" + :report "report" + :hint "boom" + :cause cause))))] + (t/is (= 1 (count events))) + (t/is (= 1 (:occurrences (deref (first events))))))) + +(t/deftest invalid-report-does-not-consume-a-reservation + (let [cause (error-cause :type :network :hint "boom") + events (capture-reports! + (fn [] + (errors/submit-report :event-name "handled-exception" + :report nil :hint "boom" :cause cause) + (errors/submit-report :event-name "handled-exception" + :report "report" :hint "boom" :cause cause)))] + (t/is (= 1 (count events))) + (t/is (= 1 (:occurrences (deref (first events))))))) + +(t/deftest submit-report-evaluates-thunk-reports-lazily + ;; Scenario: the same failure is submitted twice with the report as a + ;; thunk. The first occurrence is granted and builds the report; the + ;; second is suppressed and must never run the thunk. Proves: suppressed + ;; occurrences skip the report-building cost entirely. + (let [calls (atom 0) + report (fn [] (swap! calls inc) "report") + cause (error-cause :type :network :hint "boom") + events (capture-reports! + (fn [] + (errors/submit-report :event-name "handled-exception" + :report report + :hint "boom" + :cause cause) + (errors/submit-report :event-name "handled-exception" + :report report + :hint "boom" + :cause cause)))] + (t/is (= 1 (count events))) + (t/is (= 1 @calls) "only the granted occurrence builds the report") + (t/is (= "report" (:report (deref (first events))))))) + +(t/deftest governor-applies-to-every-report-name + (let [events (capture-reports! + (fn [] + (doseq [event-name ["handled-exception" "unhandled-exception" "exception-page"]] + (let [cause (error-cause :type :internal :hint event-name)] + (errors/submit-report :event-name event-name + :report "report" + :hint event-name + :cause cause) + (errors/submit-report :event-name event-name + :report "report" + :hint event-name + :cause cause)))))] + (t/is (= 3 (count events))))) + +(t/deftest submit-report-without-cause-is-ignored + (let [events (capture-reports! + (fn [] + (errors/submit-report :event-name "exception-page" :report "report" :hint "boom") + (errors/submit-report :event-name "exception-page" + :report "report" + :hint "boom" + :cause (error-cause :type :internal :hint "boom"))))] + ;; The cause-less call is ignored and must not consume the reservation + ;; of the cause-based report. + (t/is (= 1 (count events))) + (t/is (= 1 (:occurrences (deref (first events))))))) + +(t/deftest governor-bounds-an-incident-like-loop + (let [cause (error-cause :type :network :hint "unable to perform fetch operation") + events (capture-reports! + (fn [] + (dotimes [_ 10000] + (errors/submit-report :event-name "handled-exception" + :report "report" + :hint "unable to perform fetch operation" + :cause cause))))] + (t/is (= 1 (count events))))) + +(defn- report-events + "The audit events (report emissions) out of everything collected through + the `st/emit!` double. Toasts are ungoverned by design — one per `flash` + call — so only the events typed as audit events count for emission + bounds. Discriminates by `ptk/type`, which is total (never throws), + because toasts are not derefable." + [events] + (filter #(= ::ev/event (ptk/type %)) events)) + +(t/deftest ^:async flash-suppressed-occurrence-does-not-build-a-report + ;; Scenario: the same failure flashes 3 times inside the governor window. + ;; Only the first occurrence reserves emission; the suppressed ones skip + ;; generation entirely. Proves: one audit event and a single report build + ;; for the whole burst; each `flash` is awaited to completion, so no + ;; timer reasoning is involved. + (let [generated (atom 0) + cause (error-cause :type :internal :hint "unable to perform fetch operation") + events (atom [])] + (await + (mock/with-mocks* + {st/format-last-events (mock/stub (fn [& _] (swap! generated inc) "report")) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (mock/asap f)))} + (dotimes [_ 3] + (await (errors/flash :cause cause :type :handled))) + (t/is (= 1 (count (report-events @events)))) + (t/is (= 1 @generated)))))) + +(t/deftest ^:async flash-bounds-an-incident-like-loop + ;; Scenario: 10 000 identical flashes, replaying the incident loop. Every + ;; flash is awaited (all 10 000 scheduled at once, one wait for all of + ;; them), but the governor emits only the first occurrence. Proves: the + ;; burst produces exactly one audit event and one report build. + (let [generated (atom 0) + cause (error-cause :type :internal :hint "unable to perform fetch operation") + events (atom [])] + (await + (mock/with-mocks* + {st/format-last-events (mock/stub (fn [& _] (swap! generated inc) "report")) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (mock/asap f)))} + (await (js/Promise.all + (into-array + (map (fn [_] (errors/flash :cause cause :type :handled)) + (range 10000))))) + (t/is (= 1 (count (report-events @events)))) + (t/is (= 1 @generated)))))) + +(t/deftest generate-report-is-total-when-formatting-fails + (with-redefs [st/format-last-events (mock/stub (fn [& _] (throw (ex-info "formatting failed" {}))))] + (let [report (errors/generate-report (error-cause :type :network :hint "boom"))] + (t/is (string? report))))) + +(t/deftest ^:async flash-emits-a-fallback-report-when-generation-fails + ;; Scenario: the full-report formatter throws. `generate-report` stays + ;; total and the fallback string is what gets emitted. Proves: a + ;; formatting failure still produces exactly one audit event carrying a + ;; string report, with the `flash` awaited to completion. + (let [events (atom [])] + (await + (mock/with-mocks* + {st/format-last-events (mock/stub (fn [& _] (throw (ex-info "formatting failed" {})))) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (mock/asap f)))} + (await (errors/flash :cause (error-cause :type :internal :hint "boom") :type :handled)) + (let [reports (report-events @events)] + (t/is (= 1 (count reports))) + (t/is (string? (:report (deref (first reports)))))))))) + +(t/deftest ^:async flash-emits-nothing-synchronously + ;; Scenario: a single handled failure. Nothing report- or toast-related + ;; may run on the error handler's stack: the whole `flash` body executes + ;; inside one scheduled callback. Proves: zero events right after the + ;; call returns; report + toast once the returned promise completes. + (errors/reset-report-governor!) + (let [events (atom []) + cause (error-cause :type :internal :hint "async flash probe")] + (await + (mock/with-mocks* + {st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (mock/asap f)))} + (let [completed (errors/flash :cause cause :type :handled)] + (t/is (empty? @events) "nothing emitted synchronously") + (await completed) + (t/is (= 2 (count @events)) "report + toast once completed")))))) + +(t/deftest ^:async flash-report-failure-neither-propagates-nor-kills-the-toast + ;; Scenario: the report emission itself throws (audit sink down). The + ;; failure is logged and swallowed at the schedule boundary while the + ;; toast is still attempted, so the user keeps the banner. Proves: + ;; `flash` never throws, its promise still completes, and a reporting + ;; failure still leaves the toast emitted. + (errors/reset-report-governor!) + (let [events (atom []) + calls (atom 0) + cause (error-cause :type :internal :hint "emit failure probe")] + (await + (mock/with-mocks* + {st/emit! (mock/stub (fn [& emitted] + (if (zero? @calls) + (do (swap! calls inc) + (throw (ex-info "sink down" {}))) + (swap! events into emitted)))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (mock/asap f)))} + (await (errors/flash :cause cause :type :handled)) + (t/is (= 1 (count @events)) "only the toast survives a reporting failure"))))) + +(defn- toast-links + "The download links of every toast collected through the `st/emit!` + double, one entry per toast." + [events] + (->> events + (remove #(= ::ev/event (ptk/type %))) + (map #(get-in (ptk/update % {}) [:notification :links])))) + +(t/deftest ^:async repeated-flash-keeps-the-report-download-link + ;; Scenario: the same save failure flashes twice inside the governor + ;; window with a report link. The repeat is not emitted, but its toast + ;; still offers the report. Proves: the governor bounds emission, never + ;; the user's download. + (let [generated (atom 0) + cause (error-cause :type :validation :hint "save failed") + events (atom [])] + (await + (mock/with-mocks* + {st/format-last-events (mock/stub (fn [& _] (swap! generated inc) "report")) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (mock/asap f)))} + (dotimes [_ 2] + (await (errors/flash :cause cause :type :handled :report-link? true))) + (t/is (= 1 (count (report-events @events))) "the repeat is not emitted") + (t/is (= [1 1] (map count (toast-links @events))) "both toasts carry the link") + (t/is (= 2 @generated) "the repeat builds its report for the link only"))))) + +(t/deftest ^:async unreportable-flash-keeps-the-report-download-link + ;; Scenario: a failure with an empty hint flashes with a report link. It + ;; cannot be emitted, but its toast still offers the report. Proves: the + ;; link does not depend on emission. + (let [cause (ex-info "" {:type :validation}) + events (atom [])] + (await + (mock/with-mocks* + {st/format-last-events (mock/stub (fn [& _] "report")) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (mock/asap f)))} + (await (errors/flash :cause cause :type :handled :report-link? true)) + (t/is (empty? (report-events @events)) "nothing is emitted") + (t/is (= [1] (map count (toast-links @events))) "the toast carries the link"))))) diff --git a/frontend/test/frontend_tests/helpers/async.cljs b/frontend/test/frontend_tests/helpers/async.cljs new file mode 100644 index 0000000000..1e52a49c25 --- /dev/null +++ b/frontend/test/frontend_tests/helpers/async.cljs @@ -0,0 +1,92 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.helpers.async + "Async-first plumbing for ClojureScript tests. + + Lets a test read sequentially while presupposing asynchronous APIs: + triggers run, effects are awaited as promises, and `done` always runs + at the end. Relies on two platform properties: + + - `cljs.test` keeps its env in a `set!` var, so assertions inside + deferred ticks are still counted. + - RxJS delivers `observe-on :async` notifications in scheduling (FIFO) + order, so awaiting the last scheduled delivery observes every effect + scheduled before it." + (:require + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true])) + +(defn ->promise + "Coerces a single-value observable into a js/Promise that resolves + with its first value (or rejects on error)." + [ob] + (js/Promise. (fn [resolve reject] (rx/subs! resolve reject ob)))) + +(defn ^:async await-response + "Pushes `value` into the `response` subject and resolves once the last + recorded request completes, delivery included. + + The promise subscribes BEFORE pushing: subscribing after the push + would never resolve. Request entries are maps holding the request + observable under `:req`." + [requests response value] + (let [p (->promise (:req (last @requests)))] + (rx/push! response value) + (await p))) + +(defn settle + "Resolves on the next macrotask, letting scheduled deliveries land. + Await it before asserting absence: silence is only meaningful once the + queue had a chance to deliver." + [] + (js/Promise. (fn [resolve] (js/setTimeout resolve 0)))) + +(defn observe + "Subscribes to `ob` forcing asynchronous delivery through `observe-on + :async` — even when the source is synchronous — and returns a promise + resolving once the stream terminates. + + Keyword args: `:on-next` per value, `:on-error` (handled errors resolve; + unhandled reject), `:on-complete` on clean termination, `:timeout-ms` + (default 2000). Asserts the stream is provided: resolving nil would let + a test pass without observing anything. Rejects on timeout instead of + hanging." + [ob & {:keys [on-next on-error on-complete timeout-ms] + :or {timeout-ms 2000}}] + (assert (some? ob) "observe requires an observable stream") + (js/Promise. + (fn [resolve reject] + (let [timer (js/setTimeout + #(reject (ex-info "Stream did not terminate in time" {})) + timeout-ms) + settle (fn [f v] + (js/clearTimeout timer) + (f v))] + (->> ob + (rx/observe-on :async) + (rx/subs! + (fn [v] (when on-next (on-next v))) + (fn [e] + (if on-error + (do (on-error e) (settle resolve nil)) + (settle reject e))) + (fn [] + (when on-complete (on-complete)) + (settle resolve nil)))))))) + +(defn ^:async wait-for + "Resolves once `pred` holds, checking immediately and then once per + macrotask (bounded: records a failure instead of hanging). Await it + before asserting effects of triggers — it holds whether the effect + lands synchronously or not." + [pred msg & [{:keys [max-ticks] :or {max-ticks 50}}]] + (if (pred) + nil + (if (<= max-ticks 0) + (t/is false (str "Timed out waiting for: " msg)) + (do (await (settle)) + (await (wait-for pred msg {:max-ticks (dec max-ticks)})))))) diff --git a/frontend/test/frontend_tests/helpers/mock.cljc b/frontend/test/frontend_tests/helpers/mock.cljc index 50f4d49d29..35e73f17c6 100644 --- a/frontend/test/frontend_tests/helpers/mock.cljc +++ b/frontend/test/frontend_tests/helpers/mock.cljc @@ -23,9 +23,14 @@ 4. `inner-done` restores originals and calls `outer-done` (typically `cljs.test/async`'s done). - Usage: `(with-mocks {ns/sym mock-fn, ...} test-fn done)`" + Usage: `(with-mocks {ns/sym mock-fn, ...} test-fn done)`. + + `with-mocks*` is the preferred form for new tests: it wraps the body in a + generated `^:async` fn and evaluates to a promise, so there is no `done` to + thread. See [[run-mocked]] for the restore-on-settle machinery." #?(:cljs (:require - [beicon.v2.core :as rx])) + [beicon.v2.core :as rx] + [cljs.test :as t])) #?(:cljs (:require-macros [frontend-tests.helpers.mock]))) ;; ═══════════════════════════════════════════════════════════════ @@ -84,6 +89,50 @@ ~@restore-exprs (~outer-done)))))))) +#?(:clj + (defmacro with-mocks* + "Installs `mocks` via `set!`, then evaluates `body` inside a generated + `^:async` fn awaited via `run-mocked`: when the body settles, the + originals are restored — always. A rejection is reported as an `:error`. + + Evaluates to a promise resolving once the body settles and the mocks + are restored. `await` it, either directly or by returning it from an + `^:async` fn; nested scopes must be awaited too. + + Example: + + (t/deftest ^:async my-async-test + (await (mock/with-mocks* + {app.main.repo/cmd! mock/rpc-cmd-mock} + (await (some-async-flow)) + ...)))" + [mocks & body] + (let [entries (map identity mocks) + gen-pairs (mapv (fn [[qsym _mock]] + {:qsym qsym + :osym (gensym "orig-")}) + entries) + let-bindings (vec (mapcat + (fn [{:keys [qsym osym]}] + [osym qsym]) + gen-pairs)) + install-exprs (mapv + (fn [[_qsym mock-fn] {:keys [qsym]}] + `(set! ~qsym ~mock-fn)) + entries + gen-pairs) + restore-exprs (mapv + (fn [{:keys [qsym osym]}] + `(set! ~qsym ~osym)) + gen-pairs)] + `(do + (frontend-tests.helpers.mock/reset-state!) + (let ~let-bindings + ~@install-exprs + (frontend-tests.helpers.mock/run-mocked + (^:async fn [] ~@body) + (fn [] ~@restore-exprs))))))) + ;; ═══════════════════════════════════════════════════════════════ ;; Runtime (ClojureScript only) ;; ═══════════════════════════════════════════════════════════════ @@ -164,6 +213,39 @@ ([a b c d e] (f a b c d e)) ([a b c d e g] (f a b c d e g)))) + ;; Scheduling + ;; ═══════════════════════════════════════════════════════════════ + + (defn asap + "Runs `f` on the next macrotask (`js/setTimeout` 0) and returns + the timer id." + [f] + (js/setTimeout f 0)) + + (defn ^:async run-mocked + "Awaits the async zero-arg `thunk`, then runs `restore` — always, in + that order — and resolves. A rejection is reported as an `:error` + (uncaught-exception semantics, like `test-var-block*`); a non-promise + return is reported as an `:error` too." + [thunk restore] + (let [p (thunk)] + (if (and (some? p) (fn? (.-then p))) + (try + (await p) + (catch :default e + (t/report {:type :error + :message "Uncaught exception, not in assertion." + :expected nil + :actual e})) + (finally + (restore))) + (do + (restore) + (t/report {:type :error + :message "with-mocks* body did not return a promise." + :expected nil + :actual p}))))) + ;; Lifecycle ;; ═══════════════════════════════════════════════════════════════ diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index 0e60f75393..41fa032aa3 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -11,13 +11,15 @@ - stale-asset-error? – pure predicate - exception->error-data – pure transformer - on-error re-entrancy guard – prevents recursive invocations - - flash schedules async emit – ntf/show is not emitted synchronously + - 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 - invalid-sso-config handler – requires :organization-id to promote to :sso-error - save failure notification – sticky toast carrying the error report - delegated save failures – causes handled by the handler for their type" (:require [app.common.uuid :as uuid] + [app.main.data.event :as ev] [app.main.data.persistence :as dps] [app.main.data.workspace :as-alias dw] [app.main.errors :as errors] @@ -31,6 +33,8 @@ [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [cuerdas.core :as str] + [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) @@ -97,6 +101,245 @@ data (errors/exception->error-data err)] (t/is (= "fallback message" (:hint data)))))) +;; Shared report-test helpers (copied from the governor suite so each +;; namespace stays self-contained). +(t/use-fixtures :each {:before #(errors/reset-report-governor!)}) + +(defn- error-cause + [& {:keys [type code hint]}] + (ex-info (or hint "boom") + (cond-> {} + (some? type) (assoc :type type) + (some? code) (assoc :code code) + (some? hint) (assoc :hint hint)))) + +(defn- capture-reports! + "Run `f` capturing the events emitted through `st/emit!`." + [f] + (let [events (atom [])] + (with-redefs [st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")] + (f) + @events))) + +(defn- report-events + "The audit events (report emissions) out of everything collected through + the `st/emit!` double. Toasts are ungoverned by design — one per `flash` + call — so only the events typed as audit events count for emission + bounds. Discriminates by `ptk/type`, which is total (never throws), + because toasts are not derefable." + [events] + (filter #(= ::ev/event (ptk/type %)) events)) + +(defn- ^:async report-names + "Drives `flash-persistence` with an `ex-data` map of `cause-data` and + returns the audit event names it emitted, in order. + + The report generator is stubbed and the scheduler runs its callback + inline, so the whole flash body completes before returning. The emitter + is the only observable." + [cause-data] + (let [events (atom []) + cause (ex-info "Save failed" (assoc cause-data :hint "Save failed"))] + ;; The governor is global and remembers fingerprints for two minutes, so + ;; a second identical report would be suppressed. Clear it per case. + (errors/reset-report-governor!) + (await + (mock/with-mocks* + {errors/generate-report (fn [_ & _] "generated error report") + st/emit! (mock/stub (fn [& emitted] + (swap! events into emitted))) + ;; `schedule` is multi-arity, so the mock needs every arity the + ;; flash body may call it with. + tm/schedule (mock/stub (fn + ([] nil) + ([callback] (callback)) + ([_ms callback] (callback))))} + (errors/flash-persistence cause))) + (mapv #(-> % deref ::ev/name) (report-events @events)))) + +;; --------------------------------------------------------------------------- +;; Environment failures +;; +;; Connectivity and service failures are not application defects: they are +;; audit-only telemetry with a compact report and a dedicated toast. +;; --------------------------------------------------------------------------- + +(t/deftest environment-error-classification + (t/testing "environment failures are recognised" + (doseq [type [:network :offline :bad-gateway :service-unavailable + :gateway-error :nitrate-unavailable :nitrate-not-configured]] + (t/is (true? (errors/environment-error? (error-cause :type type))) + (str "expected environment error: " type)))) + (t/testing "application defects are not environment failures" + (doseq [type [:internal :assertion :persistence :validation :authentication]] + (t/is (false? (errors/environment-error? (error-cause :type type))) + (str "did not expect environment error: " type)))) + (t/testing "causes without ex-data are not environment failures" + (t/is (false? (errors/environment-error? (js/Error. "plain failure")))) + (t/is (false? (errors/environment-error? nil))))) + +(t/deftest ^:async generate-report-compact-omits-stack-data-and-last-events + (await + (mock/with-mocks* + {st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {})))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (let [cause (ex-info "http error" {:type :offline + :hint "http error" + :uri "/api/rpc/command/update-file" + :headers {"x-session-id" "secret"}}) + report (errors/generate-report cause {:format :compact})] + (t/is (string? report)) + (t/is (str/includes? report "Hint:")) + (t/is (str/includes? report "http error")) + (t/is (str/includes? report ":offline")) + (t/is (str/includes? report "/api/rpc/command/update-file")) + (t/is (not (str/includes? report "Last events:"))) + (t/is (not (str/includes? report "Data:"))) + (t/is (not (str/includes? report "===="))) + (t/is (not (str/includes? report "secret"))) + ;; Trailing settle: the body is synchronous, but `with-mocks*` + ;; evaluates to a promise, so the body must settle one. + (await (async/settle)))))) + +(t/deftest ^:async generate-report-defaults-to-the-full-format + (await + (mock/with-mocks* + {st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (let [report (errors/generate-report (error-cause :type :internal :hint "boom"))] + (t/is (str/includes? report "Last events:")) + (t/is (str/includes? report "(stub last events)")) + ;; Trailing settle: the body is synchronous, but `with-mocks*` + ;; evaluates to a promise, so the body must settle one. + (await (async/settle)))))) + +(t/deftest ^:async connectivity-handlers-report-governed-compact-audit-events + ;; Scenario: a network failure and an offline status through the global + ;; handler. Each is reported as a governed compact audit event plus its + ;; connection toast. Proves: per type, report first and toast right after, + ;; with no stack in the report and the dedicated toast message. + (doseq [type [:network :offline :gateway-error]] + (errors/reset-report-governor!) + (let [events (atom []) + cause (ex-info "http error" {:type type :hint "http error"})] + (await + (mock/with-mocks* + {st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (f))) + st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))} + (errors/on-error cause) + (await (async/settle)) + ;; `flash` emits the report first and schedules the toast right after. + (t/is (= 2 (count @events)) (str "unexpected event count for " type)) + (let [report-event (first @events) + toast-event (second @events) + props (deref report-event)] + (t/is (= "handled-exception" (::ev/name props))) + (t/is (not (str/includes? (:report props) "Last events:"))) + (t/is (= (i18n/tr "errors.connection-error") + (get-in (ptk/update toast-event {}) [:notification :content]))))))))) + +(t/deftest ^:async flash-keeps-the-canonical-event-name-and-derives-the-format + ;; Scenario: an environment failure flashed as `:handled` and as + ;; `:unhandled`. The audit event name is the canonical one requested by + ;; the caller while only the payload format derives from the cause + ;; (compact, with no stack and no header dump). Proves: per type, one + ;; compact audit event under the requested name, with the `flash` + ;; awaited to completion. + (let [cause (ex-info "http error" {:type :network + :hint "http error" + :headers {"x-session-id" "secret"}})] + (doseq [[type event-name] [[:handled "handled-exception"] + [:unhandled "unhandled-exception"]]] + (errors/reset-report-governor!) + (let [events (atom [])] + (await + (mock/with-mocks* + {st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (mock/asap f))) + st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))} + ;; The event name is the canonical one requested by the caller; + ;; only the payload format is derived from the cause. + (await (errors/flash :cause cause :type type)) + (let [reports (report-events @events)] + (t/is (= 1 (count reports)) (str "unexpected event count for " type)) + (let [props (deref (first reports)) + report (:report props)] + (t/is (= event-name (::ev/name props)) (str "event name for " type)) + (t/is (not (str/includes? report "Last events:"))) + (t/is (not (str/includes? report "secret"))))))))))) + +(t/deftest ^:async offline-loop-is-governed-and-never-unhandled + ;; Scenario: 10 000 offline errors through the global handler, replaying + ;; a connectivity outage. Every occurrence is handled (never unhandled) + ;; and the governor emits only the first. Proves: one `handled-exception` + ;; audit event for the whole loop, observed after timers drain. + (let [events (atom []) + cause (ex-info "http error" {:type :offline :hint "http error"})] + (await + (mock/with-mocks* + {st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (mock/asap f))) + st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))} + (dotimes [_ 10000] + (errors/on-error cause)) + (await (async/settle)) + (t/is (= 1 (count (report-events @events)))) + (t/is (= "handled-exception" (::ev/name (deref (first (report-events @events)))))))))) + +(t/deftest ^:async flash-persistence-uses-compact-reports-for-environment-failures + ;; Scenario: a save failure caused by the environment. `flash-persistence` + ;; delegates to `flash`, so the audit event carries a compact report + ;; (context only: no stack, no header dump). Proves: one + ;; `handled-exception` with a compact payload, observed after timers + ;; drain. + (let [events (atom [])] + (await + (mock/with-mocks* + {st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + tm/schedule (mock/stub (fn [f] (mock/asap f))) + st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))} + (errors/flash-persistence (ex-info "http error" {:type :offline + :hint "http error" + :headers {"x-session-id" "secret"}})) + (await (async/settle)) + (let [reports (report-events @events)] + (t/is (= 1 (count reports))) + (let [props (deref (first reports))] + (t/is (= "handled-exception" (::ev/name props))) + (t/is (not (str/includes? (:report props) "Last events:"))) + (t/is (not (str/includes? (:report props) "secret"))))))))) + +(t/deftest exception-page-reports-dedup-by-cause + (let [cause-a (error-cause :type :internal :code :unable-to-process-repository-response :hint "boom") + cause-b (error-cause :type :internal :code :other :hint "other") + events (capture-reports! + (fn [] + (errors/submit-report :event-name "exception-page" + :report "report" :hint "boom" :cause cause-a) + (errors/submit-report :event-name "exception-page" + :report "report" :hint "different hint" :cause cause-a) + (errors/submit-report :event-name "exception-page" + :report "report" :hint "other" :cause cause-b)))] + (t/is (= 2 (count events))))) + +(t/deftest reports-of-the-same-cause-under-different-names-do-not-coalesce + (let [cause (error-cause :type :internal :hint "boom") + events (capture-reports! + (fn [] + (errors/submit-report :event-name "handled-exception" + :report "report" :hint "boom" :cause cause) + (errors/submit-report :event-name "unhandled-exception" + :report "report" :hint "boom" :cause cause) + (errors/submit-report :event-name "exception-page" + :report "report" :hint "boom" :cause cause)))] + (t/is (= 3 (count events))))) + ;; --------------------------------------------------------------------------- ;; on-error dispatches to ptk/handle-error ;; @@ -173,152 +416,136 @@ :organization-id organization-id :team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"}) -(t/deftest expired-organization-sso-navigates-to-identity-provider - (t/async done - (t/testing "the browser is sent to the identity provider instead of an error page" - (let [events (atom [])] - (mock/with-mocks - {rp/cmd! (mock/stub - (fn [_command _params] - (rx/of {:authorized false - :redirect-uri "https://idp.example.com/authorize"}))) - rt/get-current-href (constantly workspace-href) - st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} - (fn [done'] - (errors/on-error (sso-required-error)) - (t/is (= [::rt/nav-raw] (mapv ptk/type @events))) - (done')) - done))))) +(t/deftest ^:async expired-organization-sso-navigates-to-identity-provider + (t/testing "the browser is sent to the identity provider instead of an error page" + (let [events (atom [])] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + rt/get-current-href (constantly workspace-href) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} + (errors/on-error (sso-required-error)) + (await (async/settle)) + (t/is (= [::rt/nav-raw] (mapv ptk/type @events)))))))) -(t/deftest expired-organization-sso-comes-back-to-the-current-location - (t/async done - (t/testing "the SSO check asks the provider to return the user where they were" - (let [rpc-calls (atom [])] - (mock/with-mocks - {rp/cmd! (mock/stub - (fn [command params] - (swap! rpc-calls conj {:command command :params params}) - (rx/of {:authorized false - :redirect-uri "https://idp.example.com/authorize"}))) - rt/get-current-href (constantly workspace-href) - st/emit! mock/noop} - (fn [done'] - (errors/on-error (sso-required-error)) - (t/is (= [{:command :check-nitrate-sso - :params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9" - :organization-id organization-id - :url workspace-href}}] - @rpc-calls)) - (done')) - done))))) +(t/deftest ^:async expired-organization-sso-comes-back-to-the-current-location + (t/testing "the SSO check asks the provider to return the user where they were" + (let [rpc-calls (atom [])] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [command params] + (swap! rpc-calls conj {:command command :params params}) + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + rt/get-current-href (constantly workspace-href) + st/emit! mock/noop} + (errors/on-error (sso-required-error)) + (await (async/settle)) + (t/is (= [{:command :check-nitrate-sso + :params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9" + :organization-id organization-id + :url workspace-href}}] + @rpc-calls))))))) -(t/deftest already-satisfied-organization-sso-retries-the-location - (t/async done - (t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring" - (let [events (atom [])] - (mock/with-mocks - {rp/cmd! (mock/stub - (fn [_command _params] - (rx/of {:authorized true :reason :sso-satisfied}))) - rt/get-current-href (constantly workspace-href) - st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} - (fn [done'] - (errors/on-error (sso-required-error)) - (t/is (= [::rt/reload] (mapv ptk/type @events))) - (done')) - done))))) +(t/deftest ^:async already-satisfied-organization-sso-retries-the-location + (t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring" + (let [events (atom [])] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized true :reason :sso-satisfied}))) + rt/get-current-href (constantly workspace-href) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} + (errors/on-error (sso-required-error)) + (await (async/settle)) + (t/is (= [::rt/reload] (mapv ptk/type @events)))))))) -(t/deftest organization-sso-without-usable-provider-shows-the-sso-error-dialog - (t/async done - (t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error" - (let [assigned* (atom nil)] - (mock/with-mocks - {rp/cmd! (mock/stub - (fn [_command _params] - (rx/of {:authorized false :redirect-uri nil}))) - rt/get-current-href (constantly workspace-href) - rt/assign-exception (fn [error] - (reset! assigned* error) - (ptk/data-event ::assigned error))} - (fn [done'] - (errors/on-error (sso-required-error)) - (t/is (= :sso-error (:type @assigned*))) - (t/is (= organization-id (:organization-id @assigned*))) - (t/is (true? (:is-workspace @assigned*))) - (done')) - done))))) +(t/deftest ^:async organization-sso-without-usable-provider-shows-the-sso-error-dialog + (t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error" + (let [assigned* (atom nil)] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized false :redirect-uri nil}))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + (errors/on-error (sso-required-error)) + (await (async/settle)) + (t/is (= :sso-error (:type @assigned*))) + (t/is (= organization-id (:organization-id @assigned*))) + (t/is (true? (:is-workspace @assigned*)))))))) -(t/deftest organization-sso-without-team-access-reports-a-permission-failure - (t/async done - (t/testing "a user who cannot reach the team keeps getting the authentication error" - (let [assigned* (atom nil)] - (mock/with-mocks - {rp/cmd! (mock/stub - (fn [_command _params] - (rx/of {:authorized true :reason :no-team-access}))) - rt/get-current-href (constantly workspace-href) - rt/assign-exception (fn [error] - (reset! assigned* error) - (ptk/data-event ::assigned error))} - (fn [done'] - (errors/on-error (sso-required-error)) - (t/is (= :authentication (:type @assigned*))) - (t/is (= :nitrate-sso-required (:code @assigned*))) - (done')) - done))))) +(t/deftest ^:async organization-sso-without-team-access-reports-a-permission-failure + (t/testing "a user who cannot reach the team keeps getting the authentication error" + (let [assigned* (atom nil)] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized true :reason :no-team-access}))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + (errors/on-error (sso-required-error)) + (await (async/settle)) + (t/is (= :authentication (:type @assigned*))) + (t/is (= :nitrate-sso-required (:code @assigned*)))))))) -(t/deftest organization-sso-does-not-retry-on-an-unexplained-authorization - (t/async done - (t/testing "reloading on an answer we don't understand would spin on the same rejection" - (let [events (atom [])] - (mock/with-mocks - {rp/cmd! (mock/stub (fn [_command _params] (rx/of {:authorized true}))) - rt/get-current-href (constantly workspace-href) - rt/assign-exception (fn [error] (ptk/data-event ::assigned error)) - st/async-emit! (fn [& emitted] (swap! events into emitted))} - (fn [done'] - (errors/on-error (sso-required-error)) - (t/is (= [::assigned] (mapv ptk/type @events))) - (done')) - done))))) +(t/deftest ^:async organization-sso-does-not-retry-on-an-unexplained-authorization + (t/testing "reloading on an answer we don't understand would spin on the same rejection" + (let [events (atom [])] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_command _params] (rx/of {:authorized true}))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] (ptk/data-event ::assigned error)) + st/async-emit! (fn [& emitted] (swap! events into emitted))} + (errors/on-error (sso-required-error)) + (await (async/settle)) + (t/is (= [::assigned] (mapv ptk/type @events)))))))) -(t/deftest organization-sso-error-without-context-is-reported-as-it-arrives - (t/async done - (t/testing "with no organization and no team there is nothing to check" - (let [rpc-calls (atom 0) - assigned* (atom nil)] - (mock/with-mocks - {rp/cmd! (mock/stub (fn [_command _params] - (swap! rpc-calls inc) - (rx/empty))) - rt/get-current-href (constantly workspace-href) - rt/assign-exception (fn [error] - (reset! assigned* error) - (ptk/data-event ::assigned error))} - (fn [done'] - (errors/on-error {:type :authentication - :code :nitrate-sso-required}) - (t/is (zero? @rpc-calls)) - (t/is (= :nitrate-sso-required (:code @assigned*))) - (done')) - done))))) +(t/deftest ^:async organization-sso-error-without-context-is-reported-as-it-arrives + (t/testing "with no organization and no team there is nothing to check" + (let [rpc-calls (atom 0) + assigned* (atom nil)] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_command _params] + (swap! rpc-calls inc) + (rx/empty))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + (errors/on-error {:type :authentication + :code :nitrate-sso-required}) + (await (async/settle)) + (t/is (zero? @rpc-calls)) + (t/is (= :nitrate-sso-required (:code @assigned*)))))))) -(t/deftest a-resultless-organization-sso-check-does-not-wedge-later-rejections - (t/async done - (t/testing "the one-in-flight guard is released even when no answer arrives" - (let [rpc-calls (atom 0)] - (mock/with-mocks - {rp/cmd! (mock/stub (fn [_command _params] - (swap! rpc-calls inc) - (rx/empty))) - rt/get-current-href (constantly workspace-href) - st/emit! mock/noop} - (fn [done'] - (errors/on-error (sso-required-error)) - (errors/on-error (sso-required-error)) - (t/is (= 2 @rpc-calls)) - (done')) - done))))) +(t/deftest ^:async a-resultless-organization-sso-check-does-not-wedge-later-rejections + (t/testing "the one-in-flight guard is released even when no answer arrives" + (let [rpc-calls (atom 0)] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_command _params] + (swap! rpc-calls inc) + (rx/empty))) + rt/get-current-href (constantly workspace-href) + st/emit! mock/noop} + (errors/on-error (sso-required-error)) + (errors/on-error (sso-required-error)) + (await (async/settle)) + (t/is (= 2 @rpc-calls))))))) ;; A failing check must stay a failing check: the generic handling turns it ;; into a toast, whereas swallowing it would show a permission error for @@ -332,35 +559,31 @@ [error] (swap! check-failures conj error)) -(t/deftest failing-organization-sso-check-is-not-reported-as-missing-access - (t/async done +(t/deftest ^:async failing-organization-sso-check-is-not-reported-as-missing-access + (t/testing "the SSO check fails like a real request (later tick) and the failure stays a failure" (reset! check-failures []) (let [assigned* (atom nil)] - (mock/with-mocks - {rp/cmd! - (mock/stub - (fn [_command _params] - (->> (rx/timer 0) - (rx/mapcat (fn [_] - (rx/throw (ex-info "boom" {:type ::test-check-failure}))))))) + (await + (mock/with-mocks* + {rp/cmd! + (mock/stub + (fn [_command _params] + (->> (rx/timer 0) + (rx/mapcat (fn [_] + (rx/throw (ex-info "boom" {:type ::test-check-failure}))))))) - rt/get-current-href - (constantly workspace-href) + rt/get-current-href + (constantly workspace-href) - rt/assign-exception - (fn [error] - (reset! assigned* error) - (ptk/data-event ::assigned error))} - - (fn [done'] - (errors/on-error (sso-required-error)) - (tm/schedule - 50 - (fn [] - (t/is (= [::test-check-failure] (mapv :type @check-failures))) - (t/is (nil? @assigned*)) - (done')))) - done)))) + rt/assign-exception + (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + (errors/on-error (sso-required-error)) + (await (async/wait-for #(= 1 (count @check-failures)) + "sso check failure observed")) + (t/is (= [::test-check-failure] (mapv :type @check-failures))) + (t/is (nil? @assigned*))))))) ;; --------------------------------------------------------------------------- ;; :validation / :invalid-sso-config @@ -422,7 +645,7 @@ (t/is (= timeout (get-in state [:notification :timeout]))) (t/is (= :visible (get-in state [:notification :status])))))))) -(t/deftest persistence-notifications-include-an-error-report-download +(t/deftest ^:async persistence-notifications-include-an-error-report-download (let [scheduled (atom []) idle-callbacks (atom []) events (atom []) @@ -430,66 +653,142 @@ revoked (atom []) report "generated error report" cause (ex-info "Save failed" {:type :validation})] - (with-redefs [dom/prevent-default (fn [_]) - dom/trigger-download-uri (fn [& params] - (swap! downloads conj params)) - errors/generate-report (fn [_] report) - errors/submit-report (fn [& _]) - ;; `tr` is called with one and with two arguments, and its - ;; two-argument arity is variadic: the stub exposes both - ;; shapes so the compiled static calls resolve. - i18n/tr (fn ([key] (str key ":")) - ([key & args] - (str key ":" (first args)))) - st/emit! (mock/stub (fn [& emitted] - (swap! events into emitted))) - tm/schedule (mock/stub (fn [callback] - (swap! scheduled conj callback))) - tm/schedule-on-idle (mock/stub (fn [callback] - (swap! idle-callbacks conj callback))) - wapi/create-blob (mock/stub (fn [content media-type] - {:content content :media-type media-type})) - wapi/create-uri (fn [_] "blob:report") - wapi/revoke-uri (fn [uri] - (swap! revoked conj uri))] - (errors/flash-persistence cause) - (doseq [callback @scheduled] (callback)) - (let [state (ptk/update (first @events) {}) - download (get-in state [:notification :links 0])] - (t/is (= "labels.download:report.txt" (:label download))) - ((:callback download) nil) - (t/is (= [["report" "text/plain" "blob:report"]] @downloads)) - (doseq [callback @idle-callbacks] (callback)) - (t/is (= ["blob:report"] @revoked)))))) + (await + (mock/with-mocks* + {dom/prevent-default (fn [_]) + dom/trigger-download-uri (fn [& params] + (swap! downloads conj params)) + errors/generate-report (fn [_ & _] report) + errors/submit-report (fn [& _]) + ;; `tr` is called with one and with two arguments, and its + ;; two-argument arity is variadic: the stub exposes both + ;; shapes so the compiled static calls resolve. + i18n/tr (fn ([key] (str key ":")) + ([key & args] + (str key ":" (first args)))) + st/emit! (mock/stub (fn [& emitted] + (swap! events into emitted))) + tm/schedule (mock/stub (fn [callback] + (swap! scheduled conj callback))) + tm/schedule-on-idle (mock/stub (fn [callback] + (swap! idle-callbacks conj callback))) + wapi/create-blob (mock/stub (fn [content media-type] + {:content content :media-type media-type})) + wapi/create-uri (fn [_] "blob:report") + wapi/revoke-uri (fn [uri] + (swap! revoked conj uri))} + (errors/flash-persistence cause) + (doseq [callback @scheduled] (callback)) + (await (async/settle)) + ;; The report is emitted first and the toast right after, so the + ;; toast carrying the download link is the last event collected. + (let [state (ptk/update (last @events) {}) + download (get-in state [:notification :links 0])] + (t/is (= "labels.download:report.txt" (:label download))) + ((:callback download) nil) + (t/is (= [["report" "text/plain" "blob:report"]] @downloads)) + (doseq [callback @idle-callbacks] (callback)) + (t/is (= ["blob:report"] @revoked)) + ;; Autosave is stopped for the whole session, so the toast also + ;; offers the only action that restarts it without the user having + ;; to know they could leave the file and come back. + (let [reload (get-in state [:notification :links 1])] + (t/is (= "labels.reload-page:" (:label reload)) + "the `tr` stub renders a one-argument key with a trailing colon") + (t/is (fn? (:callback reload)) "the reload link has a callback"))))))) -(t/deftest persistence-waiters-do-not-report-an-already-handled-failure +;; Scenario: a save fails terminally. The toast offers the report download +;; and a reload. Proves: the recovery action reaches the user. +;; +;; The reload callback itself is not invoked here: `js/location` does not +;; exist under the test runner, so the link is verified by its label and by +;; being a callable, not by calling it. +(t/deftest ^:async save-failure-toast-offers-a-reload-action + (let [events (atom [])] + (await + (mock/with-mocks* + {errors/generate-report (fn [_ & _] "generated error report") + st/emit! (mock/stub (fn [& emitted] + (swap! events into emitted))) + tm/schedule (mock/stub (fn [callback] (callback)))} + (errors/flash-persistence (ex-info "Save failed" + {:type :validation :hint "Save failed"})) + (await (async/settle)) + ;; The report is emitted first and the toast right after, so the toast + ;; is the last event collected. + (let [state (ptk/update (last @events) {}) + links (get-in state [:notification :links])] + ;; `tr` is not stubbed here, and the test runner does not load the + ;; catalogue, so a key comes back verbatim without its arguments. + (t/is (= ["labels.download" "labels.reload-page"] (mapv :label links)) + "the toast carries the report and a reload, download first") + (t/is (every? #(fn? (:callback %)) links) + "both actions are callable") + (t/is (nil? (get-in state [:notification :timeout])) + "the toast stays until the user acts on it")))))) + +;; Scenario: a save fails on the transport and the queue keeps trying on +;; the slow cycle. The toast says the changes are kept and offers no reload, +;; which would lose them. Proves: the retrying variant tells the truth. +(t/deftest ^:async retrying-save-failure-toast-offers-no-reload + (let [events (atom [])] + (await + (mock/with-mocks* + {errors/generate-report (fn [_ & _] "generated error report") + st/emit! (mock/stub (fn [& emitted] + (swap! events into emitted))) + tm/schedule (mock/stub (fn [callback] (callback)))} + (errors/flash-persistence (ex-info "Save failed" + {:type :gateway-error :hint "Save failed"}) + :retrying true) + (await (async/settle)) + (let [state (ptk/update (last @events) {})] + (t/is (= "errors.save-still-retrying" (get-in state [:notification :content]))) + (t/is (= ["labels.download"] (mapv :label (get-in state [:notification :links]))) + "the toast carries the report and no reload") + (t/is (= errors/persistence-failed-tag (get-in state [:notification :tag])) + "a save that lands can hide it") + (t/is (nil? (get-in state [:notification :timeout])))))))) + +(t/deftest ^:async persistence-waiters-do-not-report-an-already-handled-failure (let [reports (atom []) rejected (atom []) pstate (atom {:status :saving}) store (ptk/store {:state {} :on-error errors/on-error}) cause (ex-info "Save failed" {:type :network})] - (with-redefs [refs/persistence pstate - errors/submit-report (fn [& params] - (swap! reports conj (apply hash-map params))) - tm/schedule (mock/stub (fn [_]))] - (try - (ptk/emit! store (#'dps/persistence-failed (uuid/next) cause)) - (reset! pstate (:persistence @store)) - (dotimes [_ 2] - (->> (dps/wait-persisted-or-error) - (rx/subs! (fn [_] (t/is false "A failed save must still reject")) - (fn [error] - (swap! rejected conj error) - (errors/on-error error))))) - (t/is (= 2 (count @rejected))) - (t/is (= 1 (count @reports))) - ;; A standalone timeout and a later save failure are new incidents. - (errors/on-error (ex-info "Save timed out" {:type :persistence :code :save-timeout})) - (t/is (= 2 (count @reports))) - (ptk/emit! store (#'dps/persistence-failed (uuid/next) cause)) - (t/is (= 3 (count @reports))) - (finally - (rx/dispose! store)))))) + (await + (mock/with-mocks* + {refs/persistence pstate + st/emit! (mock/stub (fn [& emitted] + (swap! reports into + (report-events emitted)))) + ;; Run the scheduled `flash` body synchronously: the whole flow + ;; under test is synchronous except for the deferral. + tm/schedule (mock/stub (fn [f] (f)))} + (try + (ptk/emit! store (#'dps/persistence-failed (uuid/next) cause)) + (reset! pstate (:persistence @store)) + (dotimes [_ 2] + (->> (dps/wait-persisted-or-error) + (rx/subs! (fn [_] (t/is false "A failed save must still reject")) + (fn [error] + (swap! rejected conj error) + (errors/on-error error))))) + (await (async/settle)) + (t/is (= 2 (count @rejected))) + (t/is (= 1 (count @reports))) + ;; A standalone timeout and a later save failure are new incidents. + ;; The later failure carries a distinct signature: repeating the same + ;; one inside the governor window is coalesced by design. + (errors/on-error (ex-info "Save timed out" {:type :persistence :code :save-timeout})) + (await (async/settle)) + (t/is (= 2 (count @reports))) + (ptk/emit! store (#'dps/persistence-failed (uuid/next) + (ex-info "Save failed again" {:type :network}))) + (await (async/settle)) + (t/is (= 3 (count @reports))) + (finally + (rx/dispose! store))))))) ;; --------------------------------------------------------------------------- ;; Save failures that belong to their own handler @@ -512,22 +811,20 @@ (t/is (= :authentication (:type @assigned))) (t/is (empty? @scheduled) "An expired session shows no save notification"))))) -(t/deftest expired-organization-sso-during-save-renews-the-session - (t/async done - (t/testing "an SSO-guarded save failure goes back through the identity provider" - (let [events (atom [])] - (mock/with-mocks - {rp/cmd! (mock/stub - (fn [_command _params] - (rx/of {:authorized false - :redirect-uri "https://idp.example.com/authorize"}))) - rt/get-current-href (constantly workspace-href) - st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} - (fn [done'] - (errors/flash-persistence (ex-info "SSO required" (sso-required-error))) - (t/is (= [::rt/nav-raw] (mapv ptk/type @events))) - (done')) - done))))) +(t/deftest ^:async expired-organization-sso-during-save-renews-the-session + (t/testing "an SSO-guarded save failure goes back through the identity provider" + (let [events (atom [])] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + rt/get-current-href (constantly workspace-href) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} + (errors/flash-persistence (ex-info "SSO required" (sso-required-error))) + (await (async/settle)) + (t/is (= [::rt/nav-raw] (mapv ptk/type @events)))))))) (t/deftest a-deleted-file-during-save-shows-the-exception-page (t/testing "a file that no longer exists shows its page, not the save notification" @@ -554,20 +851,86 @@ (t/is (= [::dw/reload-current-file] (mapv ptk/type @events))) (t/is (empty? @scheduled) "A restored version shows no save notification"))))) -(t/deftest other-validation-failures-during-save-keep-the-notification - (t/testing "a validation failure without a recovery of its own is still notified" - (let [events (atom []) - scheduled (atom [])] - (with-redefs [errors/generate-report (fn [_] "generated error report") - errors/submit-report (fn [& _]) - st/emit! (mock/stub (fn [& emitted] - (swap! events into emitted))) - tm/schedule (mock/stub (fn [callback] - (swap! scheduled conj callback)))] - (errors/flash-persistence (ex-info "Invalid data" {:type :validation - :code :invalid-data})) - (doseq [callback @scheduled] (callback)) - (t/is (= 1 (count @events))) - (let [state (ptk/update (first @events) {})] - (t/is (nil? (get-in state [:notification :timeout]))) - (t/is (some? (get-in state [:notification :links 0])))))))) +(t/deftest ^:async save-failure-report-type-follows-the-cause + ;; The user still gets the same sticky toast and report link; only the audit + ;; event name changes, and that is what decides whether the incident reaches + ;; the internal reports and the alerts. + (t/testing "a validation failure is a defect, so it reports unhandled" + (t/is (= ["unhandled-exception"] + (await (report-names {:type :validation :code :invalid-data}))))) + + (t/testing "the wrapped shape reads the cause, not the wrapper" + ;; This is the shape that actually reaches flash-persistence: + ;; persistence-failed sets :type :persistence and keeps the real type + ;; under :cause-type. Reading :type alone would match the wrapper and + ;; leave the cause as handled. + (t/is (= ["unhandled-exception"] + (await (report-names {:type :persistence + :cause-type :validation + :code :invalid-data}))))) + + (t/testing "environment failures stay handled after the retry budget" + ;; They land here once the backoff is spent, and the user's connection is + ;; not our bug, so they must not alert. + (doseq [cause-type [:network :offline :bad-gateway :service-unavailable + :gateway-error]] + (t/is (= ["handled-exception"] + (await (report-names {:type :persistence + :cause-type cause-type}))) + (str cause-type " is an environment failure, not a defect")))) + + (t/testing "responses the backend never wrote stay handled" + (doseq [cause-type [:rate-limit :unexpected-response]] + (t/is (= ["handled-exception"] + (await (report-names {:type :persistence + :cause-type cause-type}))) + (str cause-type " comes from a proxy, not from our code")))) + + (t/testing "an unclassified failure keeps the handled path" + (t/is (= ["handled-exception"] + (await (report-names {:type :persistence + :cause-type :unknown-thing}))))) + + (t/testing "a bare :type still classifies, without a wrapper" + (t/is (= ["unhandled-exception"] + (await (report-names {:type :validation}))))) + + (t/testing "a server-side or internal defect reports unhandled" + (doseq [cause-type [:server-error :internal]] + (t/is (= ["unhandled-exception"] + (await (report-names {:type :persistence + :cause-type cause-type}))) + (str cause-type " is our bug"))))) + +;; --------------------------------------------------------------------------- +;; transport errors +;; --------------------------------------------------------------------------- + +(t/deftest ^:async transport-errors-are-handled-without-replacing-the-page + ;; Scenario: a rate limit and an error page the backend did not write, + ;; through the global handler. Proves: neither replaces the page, each is + ;; a handled report followed by one toast. + (doseq [type [:rate-limit :unexpected-response]] + (errors/reset-report-governor!) + (let [events (atom []) + assigned (atom []) + cause (ex-info "http error" + {:type type + :hint "http error" + :status 429 + :uri "https://design.penpot.app/api/main/methods/get-teams"})] + (await + (mock/with-mocks* + {st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + rt/assign-exception (fn [error] (swap! assigned conj error) error) + tm/schedule (mock/stub (fn [f] (f))) + st/format-last-events (mock/stub (fn [& _] "(stub last events)"))} + (errors/on-error cause) + (await (async/settle)) + (t/is (empty? @assigned) + (str type " must not replace the page with an error screen")) + (t/is (= ["handled-exception"] + (map (comp ::ev/name deref) (report-events @events))) + (str type " is reported as handled, not unhandled")) + (t/is (= 2 (count @events)) (str type " must show one notification"))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 96b79f0419..8423e94fbd 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -10,7 +10,9 @@ [frontend-tests.copy-as-svg-test] [frontend-tests.data.dashboard-test] [frontend-tests.data.exports-assets-test] + [frontend-tests.data.fix-deleted-fonts-test] [frontend-tests.data.nitrate-test] + [frontend-tests.data.persistence-retry-test] [frontend-tests.data.persistence-test] [frontend-tests.data.profile-test] [frontend-tests.data.repo-test] @@ -29,6 +31,7 @@ [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] [frontend-tests.data.workspace-versions-test] + [frontend-tests.errors-governor-test] [frontend-tests.errors-test] [frontend-tests.fonts-test] [frontend-tests.helpers-shapes-test] @@ -113,7 +116,9 @@ 'frontend-tests.composable-tests.comp.sync-test 'frontend-tests.copy-as-svg-test 'frontend-tests.data.dashboard-test + 'frontend-tests.data.fix-deleted-fonts-test 'frontend-tests.data.nitrate-test + 'frontend-tests.data.persistence-retry-test 'frontend-tests.data.persistence-test 'frontend-tests.data.profile-test 'frontend-tests.data.repo-test @@ -133,6 +138,7 @@ 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test 'frontend-tests.data.workspace-versions-test + 'frontend-tests.errors-governor-test 'frontend-tests.errors-test 'frontend-tests.fonts-test 'frontend-tests.helpers-shapes-test diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 8135c2f712..e08161f63b 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1535,6 +1535,10 @@ msgstr "" msgid "errors.comment-error" msgstr "There was an error with the comment" +#: src/app/main/errors.cljs:499 +msgid "errors.connection-error" +msgstr "Cannot reach the server. Check your connection and try again." + #: src/app/main/errors.cljs:414 msgid "errors.deprecated" msgstr "" @@ -1799,7 +1803,16 @@ msgstr "The registration is currently disabled." #: src/app/main/errors.cljs:216 msgid "errors.save-failed" msgstr "" -"Autosave is not working due to an error. Contact support to report the error and reload to continue from your last saved version." +"Autosave has stopped for this session, so your changes since the last successful save cannot be saved. Download the report to help us fix it, then reload the page, or leave this file and open it again, to keep editing." + +#: src/app/main/data/persistence.cljs:310 +msgid "errors.save-retrying" +msgstr "Connection lost. Retrying to save your changes." + +#: src/app/main/errors.cljs +msgid "errors.save-still-retrying" +msgstr "" +"Your changes are not saved yet because Penpot cannot reach the server. They are kept, and Penpot will keep trying to save them. Keep this tab open until they are saved." #: src/app/main/errors.cljs:337 msgid "errors.svg-parser.invalid-svg" @@ -7133,6 +7146,10 @@ msgstr "Unlock guides" msgid "workspace.header.reset-zoom" msgstr "Reset" +#: src/app/main/ui/workspace/left_header.cljs:125 +msgid "workspace.header.retrying" +msgstr "Retrying to save" + #: src/app/main/ui/workspace/left_header.cljs:123 msgid "workspace.header.save-error" msgstr "Error on saving" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index aa583263ea..3807fd81d0 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1766,7 +1766,12 @@ msgstr "El registro está actualmente desactivado." #: src/app/main/errors.cljs:216 msgid "errors.save-failed" msgstr "" -"El autoguardado se ha detenido debido a un error. Contacta soporte para informar del error y recarga para continuar desde los últimos cambios guardados." +"El autoguardado se ha detenido en esta sesión, así que los cambios desde el último guardado correcto no se podrán guardar. Descarga el informe para ayudarnos a arreglarlo y recarga la página, o sal de este fichero y vuelve a abrirlo, para seguir editando." + +#: src/app/main/errors.cljs +msgid "errors.save-still-retrying" +msgstr "" +"Tus cambios aún no se han guardado porque Penpot no puede conectar con el servidor. Se conservan y Penpot seguirá intentando guardarlos. Mantén esta pestaña abierta hasta que se guarden." #: src/app/main/errors.cljs:337 msgid "errors.svg-parser.invalid-svg" From 334f727e870599538b07f758c145520f9aa875e7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 08:40:39 +0000 Subject: [PATCH 021/167] :bug: Stop oversized audit events from wedging the queue Port staging c74d415 (#12252) to main, adapted to this branch. The :export-shapes modal event carried the full :exports vector, each export embedding its shape, which could reach megabytes and wedge the audit archive queue past the proxy body limit. Build a compact body instead: totals, distinct shapes and per-type counts, plus the page name. Other modals keep their props. Move the modal :origin prop to ::ev/origin so make-proto-event records it as :event-origin; the UI still reads the plain prop. Bound exception messages at the source: get-hint clamps to max-message-length (1000 chars) and the CLJS format-throwable clamps the trace message line and honors :trace-length. The Context Hint header in generate-report prints get-hint instead of the raw hint. Also fix first-line on the JVM, where cuerdas/index-of returns nil and (pos? nil) throws. Adaptations to main: skip audit-log.md (absent here), skip the generate-report huge-message test (staging async helpers are absent), and fit the errors.cljs hunk to main's generate-report. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/clojure/idioms.md | 1 + .../frontend/routing-app-shell-subtleties.md | 3 +- common/src/app/common/exceptions.cljc | 39 ++++++++++--- common/test/common_tests/exceptions_test.cljc | 57 +++++++++++++++++++ common/test/common_tests/runner.cljc | 4 +- frontend/src/app/main/data/modal.cljs | 27 ++++++++- frontend/src/app/main/errors.cljs | 2 +- .../test/frontend_tests/data/modal_test.cljs | 55 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 9 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 common/test/common_tests/exceptions_test.cljc create mode 100644 frontend/test/frontend_tests/data/modal_test.cljs diff --git a/.serena/memories/clojure/idioms.md b/.serena/memories/clojure/idioms.md index 26db315487..1345fd50a6 100644 --- a/.serena/memories/clojure/idioms.md +++ b/.serena/memories/clojure/idioms.md @@ -18,3 +18,4 @@ assumption; a wrong assumption here already cost a review round. - `^:async` fns never throw synchronously (rejected promises instead); `try/catch/finally` supported; continuations are microtasks, timers and RxJS schedulers are macrotasks (FIFO). +- `cuerdas.core/index-of` returns `nil` (not `-1`) when the substring is absent, like `clojure.string/index-of`. `(pos? nil)` **throws on the JVM** but evaluates to `false` in CLJS (JS coercion), so `(if (pos? (str/index-of s "\n")) …)` works in the browser and crashes the backend. Guard with `some?` before `pos?` (this was `app.common.exceptions/first-line`). diff --git a/.serena/memories/frontend/routing-app-shell-subtleties.md b/.serena/memories/frontend/routing-app-shell-subtleties.md index d62690ec72..7181144a9f 100644 --- a/.serena/memories/frontend/routing-app-shell-subtleties.md +++ b/.serena/memories/frontend/routing-app-shell-subtleties.md @@ -16,10 +16,11 @@ - `flash` accepts a `:links` vector of `{:label :callback}` actions, appended after the download link that `:report-link?` produces. `flash-persistence` passes a reload link, so a terminal save failure hands the user both the report and the only action that restarts persistence for the session. `labels.reload-page` plus `js/location.reload` is the established full-restart pattern; the link cannot be invoked under the test runner because `js/location` does not exist there, so it is covered by its label and by being callable. - `flash-persistence` only delegates to another handler for `:authentication`, `:not-found` and `:code :vern-conflict`. Everything else it reports itself, and the report type is derived from the cause: `#{:validation :server-error :internal}` are application defects and report as `unhandled-exception` so they reach the internal reports and the alerts; the rest stay `handled-exception`. `:cause-type` wins over `:type` when classifying, because `persistence-failed` wraps the original cause under `:type :persistence`. Environment failures are deliberately kept handled: they land here after the retry budget is spent and the user's connection is not our bug. `:assertion` is also kept handled, matching its own handler. - The report governor is global and stateful across tests: two reports with the same fingerprint inside `report-window-ms` collapse. Call `errors/reset-report-governor!` between assertions that expect an emission. +- Error messages are bounded at the source: `app.common.exceptions/get-hint` clamps to `max-message-length` (1000 chars) and the CLJS `format-throwable` clamps the message line of the stack trace and honors `:trace-length`. A message can embed a whole payload (a serialized file, for example) and reach megabytes; the bound keeps the audit `:hint` prop and the `:report` string small. The `Context: Hint:` header in `generate-report` prints `get-hint` (not the raw `:hint` from `ex-data`) for the same reason. ## Store and websocket For general store mechanics such as `emit!`, `last-events`, persistence, and undo, read `mem:frontend/workspace-state-persistence-subtleties`. - Websocket initialization uses `cf/public-uri` joined with `ws/notifications`, converting `http/https` to `ws/wss`, and includes the current `session-id` as query param. -- Reinitializing or finalizing websocket stops the previous receive stream. Incoming websocket payloads become Potok data events under `app.main.data.websocket/message`. \ No newline at end of file +- Reinitializing or finalizing websocket stops the previous receive stream. Incoming websocket payloads become Potok data events under `app.main.data.websocket/message`. diff --git a/common/src/app/common/exceptions.cljc b/common/src/app/common/exceptions.cljc index 588391601a..7bb36f2253 100644 --- a/common/src/app/common/exceptions.cljc +++ b/common/src/app/common/exceptions.cljc @@ -29,6 +29,22 @@ (def ^:dynamic *data-length* 8) (def ^:dynamic *data-level* 8) +(def max-message-length + "Upper bound, in characters, for an exception message surfaced as a hint or + embedded in a formatted stack trace. A message can carry a whole payload (a + serialized file, for instance) and grow to megabytes; only the head helps to + identify the failure, and the rest is noise that bloats reports and the audit + events built from them." + 1000) + +(defn- truncate + "Truncates a string to at most `n` characters, appending an ellipsis when it + had to cut. Values that are not strings pass through unchanged." + [s n] + (if (and (string? s) (> (count s) n)) + (str (subs s 0 n) "...") + s)) + (defmacro error [& {:keys [type hint] :as params}] `(ex-info ~(or hint (name type)) @@ -243,7 +259,7 @@ :cljs (defn format-throwable - [cause & {:as opts}] + [cause & {:keys [trace-length]}] (with-out-str (when-let [exdata (ex-data cause)] (when-let [hint (or (get exdata :hint) @@ -251,7 +267,7 @@ (when (str/index-of hint "\n") (println "Hint:") (println "--------------------") - (println hint) + (println (truncate hint max-message-length)) (println))) (when-let [explain (get exdata ::sm/explain)] @@ -272,14 +288,20 @@ (println)) (when-let [trace (.-stack cause)] - (println "Trace:") - (println "--------------------") - (println (.-stack cause)))))) + (let [lines (str/lines trace) + head (truncate (first lines) max-message-length) + tail (rest lines)] + (println "Trace:") + (println "--------------------") + (when head + (println head)) + (doseq [line (if trace-length (take trace-length tail) tail)] + (println line))))))) (defn first-line [s] (let [break-index (str/index-of s "\n")] - (if (pos? break-index) + (if (and (some? break-index) (pos? break-index)) (subs s 0 break-index) s))) @@ -308,5 +330,6 @@ (defn get-hint [cause] - (or (some-> (ex-data cause) (get :hint) first-line) - (some-> (ex-message cause) first-line))) + (truncate (or (some-> (ex-data cause) (get :hint) first-line) + (some-> (ex-message cause) first-line)) + max-message-length)) diff --git a/common/test/common_tests/exceptions_test.cljc b/common/test/common_tests/exceptions_test.cljc new file mode 100644 index 0000000000..6f9df8da60 --- /dev/null +++ b/common/test/common_tests/exceptions_test.cljc @@ -0,0 +1,57 @@ +;; 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.exceptions-test + "Exception message bounding in `app.common.exceptions`. + + A message can carry a whole payload (a serialized file, for instance) and + grow to megabytes. `get-hint` and the CLJS `format-throwable` keep a bounded + head so neither the audit `:hint` prop nor the report can explode." + (:require + #?(:cljs [cuerdas.core :as str]) + [app.common.exceptions :as ex] + [clojure.test :as t])) + +(defn- huge-string + [n] + (apply str (repeat n "x"))) + +(t/deftest first-line-keeps-a-single-line-message-intact-test + (t/is (= "boom" (ex/first-line "boom"))) + (t/is (= "boom" (ex/first-line "boom\nrest of the message")))) + +(t/deftest get-hint-returns-the-first-line-test + (t/is (= "boom" (ex/get-hint (ex-info "boom\nsecond line" {}))))) + +(t/deftest get-hint-prefers-the-ex-data-hint-test + (t/is (= "from-data" (ex/get-hint (ex-info "from-message" {:hint "from-data"}))))) + +(t/deftest get-hint-bounds-a-huge-message-test + (let [huge (huge-string (* 4 ex/max-message-length)) + hint (ex/get-hint (ex-info huge {}))] + (t/is (string? hint)) + (t/is (<= (count hint) (+ ex/max-message-length 3))) + (t/is (= (subs hint 0 32) (subs huge 0 32))))) + +(t/deftest get-hint-bounds-a-huge-ex-data-hint-test + (let [huge (huge-string (* 4 ex/max-message-length)) + hint (ex/get-hint (ex-info "short" {:hint huge}))] + (t/is (<= (count hint) (+ ex/max-message-length 3))) + (t/is (= (subs hint 0 32) (subs huge 0 32))))) + +#?(:cljs + (t/deftest format-throwable-bounds-a-huge-trace-message-test + (let [huge (huge-string (* 4 ex/max-message-length)) + out (ex/format-throwable (ex-info huge {})) + msg (some #(when (str/includes? % (subs huge 0 16)) %) (str/lines out))] + (t/is (some? msg)) + (t/is (<= (count msg) (+ ex/max-message-length 16)))))) + +#?(:cljs + (t/deftest format-throwable-honors-trace-length-test + (let [full (ex/format-throwable (ex-info "boom" {})) + short (ex/format-throwable (ex-info "boom" {}) {:trace-length 1})] + (t/is (< (count (str/lines short)) (count (str/lines full))))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 1b55f1d1e4..1a0ba1217d 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -16,6 +16,7 @@ [common-tests.buffer-test] [common-tests.colors-test] [common-tests.data-test] + [common-tests.exceptions-test] [common-tests.files-builder-test] [common-tests.files-changes-test] [common-tests.files-migrations-0025-test] @@ -96,8 +97,9 @@ 'common-tests.buffer-test 'common-tests.colors-test 'common-tests.data-test - 'common-tests.files-changes-test + 'common-tests.exceptions-test 'common-tests.files-builder-test + 'common-tests.files-changes-test 'common-tests.files-migrations-0025-test 'common-tests.files-migrations-0026-test 'common-tests.files-migrations-test diff --git a/frontend/src/app/main/data/modal.cljs b/frontend/src/app/main/data/modal.cljs index 052080f3fb..c6351e1a8d 100644 --- a/frontend/src/app/main/data/modal.cljs +++ b/frontend/src/app/main/data/modal.cljs @@ -17,6 +17,19 @@ ;; TODO: rename `:type` to `:name` +(defn- summarize-export-shapes + "Builds a compact body for the `:export-shapes` modal event. We know the + shape of these props here, so instead of dumping every export (each one + carrying its full shape, which can be megabytes) we emit counters: total + exports, distinct shapes and one count per export type." + [props] + (let [exports (:exports props)] + (merge + {:page (:name props) + :num-shapes (count (distinct (map :object-id exports))) + :num-exports (count exports)} + (frequencies (map :type exports))))) + (defn show ([props] (show (uuid/next) (:type props) props)) @@ -26,9 +39,17 @@ (ptk/reify ::show-modal ev/Event (-data [_] - (-> props - (dissoc :type) - (assoc :name type))) + (let [origin (:origin props) + data (if (= type :export-shapes) + (assoc (summarize-export-shapes props) :name type) + (-> props + (dissoc :type) + (assoc :name type)))] + ;; The origin is event metadata, not a prop: sending it as + ;; ::ev/origin makes `make-proto-event` put it on the event context + ;; (`:event-origin`); as a plain `:origin` it only stayed as a prop. + (cond-> (dissoc data :origin) + (some? origin) (assoc ::ev/origin origin)))) ptk/UpdateEvent (update [_ state] diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 28e6e54a9b..5be508739a 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -157,7 +157,7 @@ (println "Context:") (println "--------------------") (println "Timestamp:" (ct/format-inst (ct/now) :rfc1123)) - (println "Hint: " (or (:hint data) (ex-message cause) "--")) + (println "Hint: " (or (ex/get-hint cause) "--")) (println "Prof ID: " (str (or profile-id "--"))) (println "Team ID: " (str (or team-id "--"))) (when-let [file-id (or (:file-id data) file-id)] diff --git a/frontend/test/frontend_tests/data/modal_test.cljs b/frontend/test/frontend_tests/data/modal_test.cljs new file mode 100644 index 0000000000..e3b0fd4325 --- /dev/null +++ b/frontend/test/frontend_tests/data/modal_test.cljs @@ -0,0 +1,55 @@ +;; 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.modal-test + (:require + [app.common.uuid :as uuid] + [app.main.data.event :as ev] + [app.main.data.modal :as modal] + [cljs.test :as t :include-macros true])) + +(t/deftest export-shapes-modal-event-carries-a-summary + (let [shape-a (uuid/next) + shape-b (uuid/next) + exports [{:object-id shape-a :type :png} + {:object-id shape-a :type :svg} + {:object-id shape-b :type :png}] + event (modal/show :export-shapes + {:exports exports + :origin "workspace:sidebar" + :name "Page 1"}) + data (ev/-data event)] + + (t/testing "the raw exports never leak into the event" + (t/is (not (contains? data :exports)))) + + (t/testing "the event carries counters instead" + (t/is (= 3 (:num-exports data))) + (t/is (= 2 (:num-shapes data))) + (t/is (= 2 (:png data))) + (t/is (= 1 (:svg data)))) + + (t/testing "the modal context is preserved" + (t/is (= "Page 1" (:page data))) + (t/is (= :export-shapes (:name data)))) + + (t/testing "origin is emitted as event metadata, not as a prop" + (t/is (= "workspace:sidebar" (::ev/origin data))) + (t/is (not (contains? data :origin)))))) + +(t/deftest other-modals-keep-their-props + (let [event (modal/show :some-modal {:foo "bar" :items [1 2 3]}) + data (ev/-data event)] + (t/is (= "bar" (:foo data))) + (t/is (= [1 2 3] (:items data))) + (t/is (= :some-modal (:name data))))) + +(t/deftest modal-origin-becomes-event-metadata + (let [event (modal/show :some-modal {:foo "bar" :origin "dashboard"}) + data (ev/-data event)] + (t/is (= "dashboard" (::ev/origin data))) + (t/is (not (contains? data :origin))) + (t/is (= "bar" (:foo data))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 8423e94fbd..e7a5859879 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -11,6 +11,7 @@ [frontend-tests.data.dashboard-test] [frontend-tests.data.exports-assets-test] [frontend-tests.data.fix-deleted-fonts-test] + [frontend-tests.data.modal-test] [frontend-tests.data.nitrate-test] [frontend-tests.data.persistence-retry-test] [frontend-tests.data.persistence-test] @@ -117,6 +118,7 @@ 'frontend-tests.copy-as-svg-test 'frontend-tests.data.dashboard-test 'frontend-tests.data.fix-deleted-fonts-test + 'frontend-tests.data.modal-test 'frontend-tests.data.nitrate-test 'frontend-tests.data.persistence-retry-test 'frontend-tests.data.persistence-test From 98f8b7e62548280a8dea45d484c4603925477f02 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 16:23:32 +0200 Subject: [PATCH 022/167] :bug: Keep the picture in place when a crop crosses the opposite edge (#12361) With the mod key, resizing a shape with an image fill crops it: the shape is a window over a picture that keeps its size and position. When the dragged edge crossed the opposite one, the crop mode turned off and a normal resize with a negative scale flipped the shape. The image transform is relative to the shape, so the picture was mirrored and shrank a bit more on every crossing until it disappeared. Keep the crop mode when the edge crosses: the window moves to the other side of the fixed edge without flipping, the part outside the picture shows empty, and moving the window back shows the picture again. The crop math lives in app.common.geom.image-crop and replaces the image transform code that was duplicated for fills and fill-image. Closes #12349 AI-assisted-by: claude-opus-5-5 --- common/src/app/common/geom/image_crop.cljc | 38 +++++++++ .../common_tests/geom_image_crop_test.cljc | 67 +++++++++++++++ common/test/common_tests/runner.cljc | 2 + .../app/main/data/workspace/transforms.cljs | 83 ++++++++----------- 4 files changed, 143 insertions(+), 47 deletions(-) create mode 100644 common/src/app/common/geom/image_crop.cljc create mode 100644 common/test/common_tests/geom_image_crop_test.cljc diff --git a/common/src/app/common/geom/image_crop.cljc b/common/src/app/common/geom/image_crop.cljc new file mode 100644 index 0000000000..fdd9f89527 --- /dev/null +++ b/common/src/app/common/geom/image_crop.cljc @@ -0,0 +1,38 @@ +;; 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.common.geom.image-crop + "Geometry of the image crop (resize with the mod key on a shape with an + image fill). The shape is a window over a picture that keeps its size + and position; the window never flips." + (:require + [app.common.math :as mth])) + +(defn crop-axis + "Resize one axis of the crop window. `size` is the current window size, + `origin` the fixed point relative to the window start and `scale` the + resize factor, negative when the dragged edge crosses the fixed point. + Returns `[offset size]`: the new window start relative to the old one, + and the new size, always positive." + [size origin scale] + (let [start (* origin (- 1 scale)) + end (+ origin (* (- size origin) scale))] + [(mth/min start end) (mth/abs (- end start))])) + +(defn update-image-transform + "Return the image fill transform that keeps the picture in place when + the window of `width` x `height` moves by `dx`, `dy` and gets a size of + `width'` x `height'`. The transform values are relative to the window + size, and a nil transform means the picture fills the window." + [transform width height dx dy width' height'] + (let [x (get transform :x 0.0) + y (get transform :y 0.0) + w (get transform :width 1.0) + h (get transform :height 1.0)] + {:x (/ (- (* x width) dx) width') + :y (/ (- (* y height) dy) height') + :width (/ (* w width) width') + :height (/ (* h height) height')})) diff --git a/common/test/common_tests/geom_image_crop_test.cljc b/common/test/common_tests/geom_image_crop_test.cljc new file mode 100644 index 0000000000..cdd30e444f --- /dev/null +++ b/common/test/common_tests/geom_image_crop_test.cljc @@ -0,0 +1,67 @@ +;; 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.geom-image-crop-test + (:require + [app.common.geom.image-crop :as gic] + [app.common.math :as mth] + [clojure.test :as t])) + +(defn- close-vals? + [a b] + (every? true? (map mth/close? a b))) + +(defn- picture-box + "Picture position and size relative to the original window start, for a + window at `offset` with `size` and the given image transform." + [{:keys [x y width height]} [dx dy] [w h]] + [(+ dx (* x w)) (+ dy (* y h)) (* width w) (* height h)]) + +(t/deftest crop-axis-test + (t/testing "shrinks the window towards the fixed edge" + ;; Top handler: the bottom edge (300) stays fixed. + (t/is (close-vals? [60 240] (gic/crop-axis 300 300 0.8)))) + + (t/testing "grows the window away from the fixed edge" + (t/is (close-vals? [-60 360] (gic/crop-axis 300 300 1.2)))) + + (t/testing "crossing the fixed edge moves the window to the other side" + ;; Top handler dragged 100px below the bottom edge. + (t/is (close-vals? [300 100] (gic/crop-axis 300 300 (/ -1 3)))) + ;; Bottom handler dragged 150px above the top edge. + (t/is (close-vals? [-150 150] (gic/crop-axis 300 0 -0.5)))) + + (t/testing "resize from the center keeps the center when crossing" + (t/is (close-vals? [75 150] (gic/crop-axis 300 150 -0.5))))) + +(t/deftest update-image-transform-test + (let [width 400 + height 300 + tf {:x 0 :y -0.2 :width 1 :height 1.4}] + + (t/testing "the picture keeps its position and size" + (doseq [[dx dy w' h'] [[0 60 400 240] + [0 300 400 100] + [0 -150 400 150] + [-50 75 500 150]]] + (let [tf' (gic/update-image-transform tf width height dx dy w' h')] + (t/is (close-vals? (picture-box tf [0 0] [width height]) + (picture-box tf' [dx dy] [w' h'])) + (str "window " [dx dy w' h']))))) + + (t/testing "a missing transform means the picture fills the window" + (t/is (close-vals? [0 -0.25 1 1.25] + ((juxt :x :y :width :height) + (gic/update-image-transform nil 400 300 0 60 400 240))))) + + (t/testing "moving the window back restores the original transform" + (let [[dy h'] (gic/crop-axis height height (/ -1 3)) + crossed (gic/update-image-transform tf width height 0 dy width h') + [dy2 h2] (gic/crop-axis h' 0 (/ height h' -1)) + back (gic/update-image-transform crossed width h' 0 dy2 width h2)] + (t/is (close-vals? [(- dy) height] [dy2 h2]) "window back at its original place") + (t/is (close-vals? ((juxt :x :y :width :height) tf) + ((juxt :x :y :width :height) back))))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index e4cec9b442..88e10df432 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -35,6 +35,7 @@ [common-tests.geom-grid-layout-test] [common-tests.geom-grid-test] [common-tests.geom-image-bounds-resize-test] + [common-tests.geom-image-crop-test] [common-tests.geom-line-test] [common-tests.geom-modif-tree-test] [common-tests.geom-modifiers-test] @@ -138,6 +139,7 @@ 'common-tests.geom-grid-layout-test 'common-tests.geom-grid-test 'common-tests.geom-image-bounds-resize-test + 'common-tests.geom-image-crop-test 'common-tests.geom-line-test 'common-tests.geom-modif-tree-test 'common-tests.geom-modifiers-test diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 15990f2de3..640a9fc11f 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -11,6 +11,7 @@ [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] + [app.common.geom.image-crop :as gic] [app.common.geom.matrix :as gmt] [app.common.geom.modifiers :as gm] [app.common.geom.point :as gpt] @@ -212,10 +213,29 @@ ;; Resize origin point given the selected handler selrect (dm/get-prop shape :selrect) handler-origin (get-handler-resize-origin selrect handler) + selrect-start (gpt/point (dm/get-prop selrect :x) (dm/get-prop selrect :y)) + + ;; Image crop (mod key): the shape is a window over a + ;; picture that keeps its size and position. Dragging an + ;; edge past the fixed one moves the window to the other + ;; side instead of flipping it. + crop-origin (if ^boolean center? + (gpt/point (/ width 2) (/ height 2)) + (gpt/subtract handler-origin selrect-start)) + [crop-dx crop-w] (gic/crop-axis width (dm/get-prop crop-origin :x) (dm/get-prop scalev :x)) + [crop-dy crop-h] (gic/crop-axis height (dm/get-prop crop-origin :y) (dm/get-prop scalev :y)) + + scalev (if ^boolean bounds-resize? + (gpt/point (/ crop-w width) (/ crop-h height)) + scalev) ;; If we want resize from center, displace the shape ;; so it is still centered after resize. - displacement (when ^boolean center? + displacement (cond + ^boolean bounds-resize? + (gpt/transform (gpt/point crop-dx crop-dy) shape-transform) + + ^boolean center? (-> shape-center (gpt/subtract handler-origin) (gpt/multiply scalev) @@ -224,7 +244,10 @@ (gpt/multiply (gpt/point -1 -1)) (gpt/transform shape-transform))) - resize-origin (gmt/transform-point-center handler-origin shape-center shape-transform) + resize-origin (gmt/transform-point-center + (if ^boolean bounds-resize? selrect-start handler-origin) + shape-center + shape-transform) resize-origin (if (some? displacement) (gpt/add resize-origin displacement) resize-origin) @@ -242,57 +265,23 @@ change-height? (not (mth/close? (dm/get-prop scalev :y) 1)) - ;; Calculate independent image bounds resize transform - sx (dm/get-prop scalev :x) - sy (dm/get-prop scalev :y) - w-new (* width sx) - h-new (* height sy) - - bounds-resize? (and ^boolean bounds-resize? - (pos? w-new) - (pos? h-new)) - - [dx dy] (if ^boolean center? - [(/ (* width (- 1.0 sx)) 2.0) - (/ (* height (- 1.0 sy)) 2.0)] - [(case handler - (:left :bottom-left :top-left) (* width (- 1.0 sx)) - 0.0) - (case handler - (:top :top-left :top-right) (* height (- 1.0 sy)) - 0.0)]) + ;; Keep the picture in place inside the new crop window + update-image + (fn [img-fill] + (update img-fill :transform gic/update-image-transform + width height crop-dx crop-dy crop-w crop-h)) new-fills - (when (and bounds-resize? (seq (:fills shape))) + (when (and ^boolean bounds-resize? (seq (:fills shape))) (mapv (fn [fill] - (if-let [img-fill (:fill-image fill)] - (let [tf (get img-fill :transform) - nx0 (get tf :x 0.0) - ny0 (get tf :y 0.0) - nw0 (get tf :width 1.0) - nh0 (get tf :height 1.0) - nx' (/ (- (* nx0 width) dx) w-new) - ny' (/ (- (* ny0 height) dy) h-new) - nw' (/ nw0 sx) - nh' (/ nh0 sy)] - (assoc-in fill [:fill-image :transform] - {:x nx' :y ny' :width nw' :height nh'})) - fill)) + (cond-> fill + (some? (:fill-image fill)) + (update :fill-image update-image))) (:fills shape))) new-fill-image - (when (and bounds-resize? (some? (:fill-image shape))) - (let [img-fill (:fill-image shape) - tf (get img-fill :transform) - nx0 (get tf :x 0.0) - ny0 (get tf :y 0.0) - nw0 (get tf :width 1.0) - nh0 (get tf :height 1.0) - nx' (/ (- (* nx0 width) dx) w-new) - ny' (/ (- (* ny0 height) dy) h-new) - nw' (/ nw0 sx) - nh' (/ nh0 sy)] - (assoc img-fill :transform {:x nx' :y ny' :width nw' :height nh'})))] + (when (and ^boolean bounds-resize? (some? (:fill-image shape))) + (update-image (:fill-image shape)))] (cond-> (ctm/empty) (some? displacement) From feb73f0fd82538a5f977a3e8b6a19378c010534d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Thu, 15 Oct 2026 16:53:30 +0200 Subject: [PATCH 023/167] :sparkles: Handle tokens libraries when exporting .penpot files (#12077) * :sparkles: Handle tokens libraries when exporting .penpot files Exporting a file that takes its tokens from an external library left the :tokens-source of the file pointing at a library that is not in the export, and the tokens lib of that library was not exported. With :detach-libraries, unset the :tokens-source and the tokens status in detach-external-references. The file is exported without any link to the library, and the shapes keep their applied tokens. With :merge-libraries, add cfo/absorb-tokens-lib, which copies the tokens lib of a library into a file and unsets its :tokens-source, so the file becomes its own tokens source. The tokens status of the file is kept; if it has none, the one of the library is copied. If the library has no tokens lib, only the source is unset. embed-assets calls it for the library that is the tokens source of the file, and ignores the other linked libraries. Add unit tests for absorb-tokens-lib, and binfile tests that export a file with tokens from an external library with both modes, checking the exported files, the tokens lib, the tokens source and the applied tokens. Also reformat the export-type docstring in binfile v3 and remove a leftover debug prn in the export dialog. AI-assisted-by: claude-sonnet-5-5 * :bug: Preserve active token sets when importing linked libraries Cache each decoded tokens library for the duration of an import so files and their tokens source resolve active sets against the same generated IDs. Add a round-trip regression test for exports that include linked libraries. * :bug: Preserve tokens when deleting or unpublishing libraries Absorb the tokens library into files that use it as their tokens source. Keep referencing file IDs in the deletion task before removing relations. Embed tokens from deleted, unlinked sources during export, and clear stale source and status data when the library no longer exists. Add regression tests for deletion, unpublishing, and exports with unavailable libraries. --------- Co-authored-by: alonso.torres --- backend/src/app/binfile/common.clj | 34 +- backend/src/app/binfile/v3.clj | 22 +- backend/src/app/rpc/commands/files.clj | 50 ++- backend/src/app/tasks/delete_object.clj | 4 +- backend/test/backend_tests/binfile_test.clj | 412 +++++++++++++++++- backend/test/backend_tests/rpc_file_test.clj | 92 ++++ common/src/app/common/files/tokens.cljc | 26 ++ common/src/app/common/types/file.cljc | 5 +- .../test/common_tests/files/tokens_test.cljc | 215 +++++++++ frontend/src/app/main/ui/exports/files.cljs | 1 - 10 files changed, 827 insertions(+), 34 deletions(-) diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index 2275a2cc6d..fe85844dc2 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -13,6 +13,7 @@ [app.common.features :as cfeat] [app.common.files.helpers :as cfh] [app.common.files.migrations :as fmg] + [app.common.files.tokens :as cfo] [app.common.files.validate :as fval] [app.common.logging :as l] [app.common.media :as cm] @@ -678,15 +679,34 @@ colors colors)) +(defn absorb-unlinked-tokens-source + "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." + [cfg data library-ids] + (let [source-id (cfo/get-tokens-source data)] + (if (and (some? source-id) + (not (contains? library-ids source-id))) + (if-let [library (get-file cfg source-id + :include-deleted? true + :throw-if-not-exists? false)] + (cfo/absorb-tokens-lib data (:data library)) + (dissoc data :tokens-source :tokens-status)) + data))) + (defn embed-assets [cfg data file-id] - (let [library-ids (get-libraries cfg [file-id])] - (reduce (fn [data library-id] - (if-let [library (get-file cfg library-id :include-deleted? true)] - (ctf/absorb-assets data (:data library)) - data)) - data - library-ids))) + (let [library-ids (get-libraries cfg [file-id]) + data (reduce (fn [data library-id] + (if-let [library (get-file cfg library-id :include-deleted? true)] + (cond-> (ctf/absorb-assets data (:data library)) + (= library-id (cfo/get-tokens-source data)) + (cfo/absorb-tokens-lib (:data library))) + data)) + data + library-ids)] + (absorb-unlinked-tokens-source cfg data library-ids))) (def ^:const import-transaction-timeout-ms "Ceiling for binfile import transactions (20 minutes). Interpolated diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 1d411336f7..c2fa61b686 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -259,6 +259,10 @@ embed? (update :data #(bfc/embed-assets cfg % file-id)) + (not (or detach? embed?)) + (update :data #(bfc/absorb-unlinked-tokens-source + cfg % (bfc/get-libraries cfg [file-id]))) + :always (bfc/clean-file-features)))))) @@ -800,7 +804,7 @@ {}) (not-empty))) -(defn- read-file-tokens-lib +(defn- read-file-tokens-lib* [{:keys [::bfc/input ::entries-index] :as cfg} file-id] (when-let [{:keys [entry]} (first (get-in entries-index [:tokens (str file-id)]))] (events/tap :progress {:section :tokens-lib :file-id file-id}) @@ -808,6 +812,19 @@ (decode-tokens-lib) (validate-tokens-lib)))) +(defn- read-file-tokens-lib + "Read the tokens lib of a file once per import. Decoding generates new + set ids, so the tokens lib of a library must be the same instance when + it is imported and when it resolves the tokens status of a file that + uses it as tokens source." + [cfg file-id] + (let [cache (:tokens-libs @bfc/*state*)] + (if (contains? cache file-id) + (get cache file-id) + (let [tokens-lib (read-file-tokens-lib* cfg file-id)] + (vswap! bfc/*state* assoc-in [:tokens-libs file-id] tokens-lib) + tokens-lib)))) + (defn- read-file-tokens-status [{:keys [::bfc/input ::entries-index] :as cfg} file-id tokens-lib] (when-let [{:keys [entry]} (first (get-in entries-index [:tokens-status (str file-id)]))] @@ -1313,7 +1330,8 @@ format. There are some options available for customize the output: `::bfc/export-type`: determines how linked libraries are handled. - Valid values: `:include-libraries` (include linked libraries), + Valid values: + `:include-libraries` (include linked libraries), `:merge-libraries` (embed library assets in the file), `:detach-libraries` (treat assets as basic objects), `:link-later` (preserve component metadata for relinking on import)." diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 1679e76204..b71d425040 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -940,7 +940,11 @@ (let [file (-> (bfc/get-file cfg file-id :include-deleted? true :lock-for-update? true) - (update :data ctf/absorb-assets ldata))] + (update :data ctf/absorb-assets ldata) + (update :data (fn [data] + (cond-> data + (cfo/effective-tokens-source? data (:id ldata)) + (cfo/absorb-tokens-lib ldata)))))] (l/trc :hint "library absorbed" :library-id (str (:id ldata)) @@ -953,16 +957,21 @@ :modified-at (ct/now) :has-media-trimmed false})))) +(defn- get-referenced-files + [cfg library-id] + (->> (db/exec! cfg [sql:get-referenced-files library-id]) + (into [] bfc/xf-map-id))) + (defn- absorb-library* - "Find all files using a shared library, and absorb all library assets - into the file local libraries" - [cfg {:keys [id data] :as library}] + "Absorb all library assets, and its tokens lib if it is their tokens + source, into the local libraries of the files that use it. The files + are the given file-ids, or the ones linked to the library if nil." + [cfg {:keys [id data] :as library} file-ids] (assert (db/connection-map? cfg) "expected cfg with valid connection") - (let [ids (->> (db/exec! cfg [sql:get-referenced-files id]) - (sequence bfc/xf-map-id))] + (let [ids (or file-ids (get-referenced-files cfg id))] (l/trc :hint "absorbing library" :library-id (str id) @@ -972,7 +981,9 @@ library)) (defn absorb-library - [{:keys [::db/conn] :as cfg} id] + "Absorb the library into the files that use it. The files are the given + file-ids, or the ones linked to the library if omitted." + [{:keys [::db/conn] :as cfg} id & {:keys [file-ids]}] (let [file (-> (bfc/get-file cfg id :realize? true :lock-for-update? true @@ -988,7 +999,7 @@ (-> (cfeat/get-team-enabled-features cf/flags team) (cfeat/check-file-features! (:features file))) - (absorb-library* cfg file))) + (absorb-library* cfg file file-ids))) (defn- set-file-shared [{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}] @@ -1056,12 +1067,15 @@ (defn- mark-file-deleted [conn team file-id] - (let [delay (ldel/get-deletion-delay team) - file (db/update! conn :file - {:deleted-at (ct/in-future delay)} - {:id file-id} - {::db/return-keys [:id :name :is-shared :deleted-at - :project-id :created-at :modified-at]})] + (let [delay (ldel/get-deletion-delay team) + file (db/update! conn :file + {:deleted-at (ct/in-future delay)} + {:id file-id} + {::db/return-keys [:id :name :is-shared :deleted-at + :project-id :created-at :modified-at]}) + ;; Files using the library, captured before their relations are removed + file-ids (when (:is-shared file) + (get-referenced-files conn file-id))] ;; Remove all possible relations for that file (db/delete! conn :file-library-rel @@ -1069,9 +1083,11 @@ (wrk/submit! {::db/conn conn ::wrk/task :delete-object - ::wrk/params {:object :file - :deleted-at (:deleted-at file) - :id file-id}}) + ::wrk/params (cond-> {:object :file + :deleted-at (:deleted-at file) + :id file-id} + (some? file-ids) + (assoc :file-ids file-ids))}) file)) (def ^:private diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj index abdc4bc5f3..aa64f2e8c1 100644 --- a/backend/src/app/tasks/delete_object.clj +++ b/backend/src/app/tasks/delete_object.clj @@ -61,7 +61,7 @@ {::db/return-keys false})) (defmethod delete-object :file - [{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}] + [{:keys [::db/conn] :as cfg} {:keys [id deleted-at file-ids]}] (when-let [file (db/get* conn :file {:id id} {::db/remove-deleted false ::sql/columns [:id :is-shared]})] @@ -79,7 +79,7 @@ (not *team-deletion*)) ;; NOTE: we don't prevent file deletion on absorb operation failure (try - (db/tx-run! cfg files/absorb-library id) + (db/tx-run! cfg files/absorb-library id :file-ids file-ids) (catch Throwable cause (l/warn :hint "error on absorbing library" :file-id id diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index b29b2fcf29..1b75b3a6cd 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -12,17 +12,18 @@ [app.binfile.v3 :as v3] [app.common.data :as d] [app.common.features :as cfeat] + [app.common.files.changes-builder :as pcb] + [app.common.files.tokens :as cfo] [app.common.files.validate :as cfv] - [app.common.pprint :as pp] [app.common.thumbnails :as thc] [app.common.time :as ct] [app.common.types.shape :as cts] + [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] + [app.common.types.tokens-status :as ctos] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] - [app.db.sql :as sql] - [app.http :as http] [app.rpc :as-alias rpc] [app.rpc.commands.binfile :as binfile] [app.storage :as sto] @@ -36,7 +37,6 @@ [datoteka.io :as io]) (:import java.io.ByteArrayInputStream - java.io.DataInputStream java.io.OutputStreamWriter java.io.Writer java.util.zip.Deflater @@ -105,6 +105,117 @@ (dissoc file :data)))) +(defn- prepare-file-with-tokens-lib + "A shared library and a linked file with different active token sets. + The file has a rect with two applied tokens. Returns the created files, + and the ids and tokens needed to check them." + [profile] + (let [lib-page-id (uuid/custom 5 1) + file-page-id (uuid/custom 5 2) + shape-id (uuid/custom 5 3) + set-id (uuid/custom 5 4) + theme-id (uuid/custom 5 5) + other-set-id (uuid/custom 5 8) + other-theme-id (uuid/custom 5 9) + + token-radius (ctob/make-token :id (uuid/custom 5 6) + :name "token-radius" + :type :border-radius + :value 10) + + token-opacity (ctob/make-token :id (uuid/custom 5 7) + :name "token-opacity" + :type :opacity + :value 0.7) + + tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id set-id + :name "test-token-set")) + (ctob/add-theme (ctob/make-token-theme :id theme-id + :name "test-theme" + :sets #{"test-token-set"})) + (ctob/add-set (ctob/make-token-set :id other-set-id + :name "other-token-set")) + (ctob/add-theme (ctob/make-token-theme :id other-theme-id + :name "other-theme" + :sets #{"other-token-set"})) + (ctob/add-token other-set-id (ctob/make-token :id (uuid/custom 5 10) + :name "other-token" + :type :opacity + :value 0.4)) + (ctob/add-token set-id token-radius) + (ctob/add-token set-id token-opacity)) + + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Tokens Library"}) + + file (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 file) + :library-id (:id library)}) + + (update-file! + :file-id (:id library) + :profile-id (:id profile) + :revn 0 + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data library)) + (pcb/add-empty-page lib-page-id "library page") + (pcb/set-tokens-lib tokens-lib) + (pcb/set-tokens-status (ctos/make-tokens-status :active-theme-ids #{other-theme-id} + :active-set-ids #{other-set-id})) + (:redo-changes))) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data file)) + (pcb/add-empty-page file-page-id "file page") + (pcb/set-tokens-source (:id library)) + (pcb/set-tokens-status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id})) + (:redo-changes))) + + (let [page (-> (bfc/get-file th/*system* (:id file) {:realize? true}) + (get-in [:data :pages-index file-page-id])) + shape (as-> (cts/setup-shape + {:id shape-id + :name "rect" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect}) $ + (cto/apply-token-to-shape {:shape $ + :token token-radius + :attributes [:r1 :r2 :r3 :r4]}) + (cto/apply-token-to-shape {:shape $ + :token token-opacity + :attributes [:opacity]}))] + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :changes + (-> (pcb/empty-changes nil) + (pcb/with-page page) + (pcb/with-objects (:objects page)) + (pcb/add-object shape) + (:redo-changes)))) + + {:library library + :file file + :page-id file-page-id + :shape-id shape-id + :token-radius token-radius + :token-opacity token-opacity})) + (def ^:private svg-raw-page-id (uuid/custom 1 1)) (def ^:private svg-raw-root-id (uuid/custom 3 1)) (def ^:private svg-raw-child-id (uuid/custom 3 2)) @@ -376,6 +487,299 @@ (t/is (= "icons-library" (:slug ext-lib))) (t/is (= [(:id file)] (:used-by ext-lib)))))))) +(defn- zip-entry-names + [path] + (with-open [zin (ZipFile. (fs/file path))] + (into #{} (map #(.getName ^ZipEntry %)) (iterator-seq (.entries zin))))) + +(t/deftest export-detach-libraries-removes-tokens-library-although-keeps-applied-tokens + (let [profile (th/create-profile* 1) + {:keys [library file page-id shape-id token-radius token-opacity]} + (prepare-file-with-tokens-lib profile)] + + ;; Precondition: before the export, the file really depends on the + ;; tokens of the library + (let [file-data (:data (bfc/get-file th/*system* (:id file)))] + (t/is (= (:id library) (cfo/get-tokens-source file-data))) + (t/is (some? (cfo/get-tokens-status file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data)))) + + ;; Do the export, with type :detach-libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries)) + (io/output-stream output)) + + ;; Only the file is exported, not the library + (let [manifest (v3/get-manifest th/*system* output) + names (zip-entry-names output)] + (t/is (= [(:id file)] (mapv :id (:files manifest)))) + (t/is (empty? (:relations manifest))) + (t/is (empty? (:external-libraries manifest))) + (t/is (contains? names (str "files/" (:id file) ".json"))) + (t/is (not-any? #(str/includes? % (str (:id library))) names))) + + ;; The exported file has neither tokens library nor tokens status + (let [names (zip-entry-names output)] + (t/is (not (contains? names (str "files/" (:id file) "/tokens.json")))) + (t/is (not (contains? names (str "files/" (:id file) "/tokens-status.json"))))) + + ;; Import the file again + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + imported (bfc/get-file th/*system* (first (:file-ids result))) + file-data (:data imported) + shape (get-in file-data [:pages-index page-id :objects shape-id])] + + (t/is (= 1 (count (:file-ids result)))) + + ;; ...so the imported file has no tokens source, status or library + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (nil? (cfo/get-tokens-status file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data))) + + ;; The shape keeps the applied tokens + (t/is (some? shape)) + (t/is (cfo/token-applied? token-radius shape [:r1 :r2 :r3 :r4])) + (t/is (cfo/token-applied? token-opacity shape [:opacity])))))) + +(t/deftest export-merge-libraries-embeds-tokens-library-and-keeps-applied-tokens + (let [profile (th/create-profile* 1) + {:keys [library file page-id shape-id token-radius token-opacity]} + (prepare-file-with-tokens-lib profile)] + + ;; Precondition: before the export, the file depends on the tokens + ;; of the library and has no tokens library of its own + (let [file-data (:data (bfc/get-file th/*system* (:id file)))] + (t/is (= (:id library) (cfo/get-tokens-source file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data)))) + + (let [library-tokens-lib (-> (bfc/get-file th/*system* (:id library)) + (:data) + (cfo/get-tokens-lib)) + output (tmp/tempfile :suffix ".zip")] + + (t/is (some? library-tokens-lib)) + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :merge-libraries)) + (io/output-stream output)) + + ;; Only the file is exported, not the library + (let [manifest (v3/get-manifest th/*system* output) + names (zip-entry-names output)] + (t/is (= [(:id file)] (mapv :id (:files manifest)))) + (t/is (empty? (:relations manifest))) + (t/is (empty? (:external-libraries manifest))) + (t/is (contains? names (str "files/" (:id file) ".json"))) + (t/is (not-any? #(str/includes? % (str (:id library))) names))) + + ;; The exported file carries its own tokens library + (t/is (contains? (zip-entry-names output) + (str "files/" (:id file) "/tokens.json"))) + + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + imported (bfc/get-file th/*system* (first (:file-ids result))) + file-data (:data imported) + shape (get-in file-data [:pages-index page-id :objects shape-id])] + + (t/is (= 1 (count (:file-ids result)))) + + ;; The tokens library of the library file is now in the file... + ;; (the import gives new ids to the sets and tokens, so compare + ;; their content) + (t/is (some? (cfo/get-tokens-lib file-data))) + (t/is (= (ctob/export-dtcg-json library-tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + + ;; ...so the file is its own tokens source + (t/is (nil? (cfo/get-tokens-source file-data))) + + ;; Activation follows the consuming file, using the imported ids. + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data)))) + (t/is (= #{"token-radius" "token-opacity"} + (set (keys (cfo/get-tokens-in-active-sets + (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data)))))) + + ;; The shape keeps the applied tokens + (t/is (some? shape)) + (t/is (cfo/token-applied? token-radius shape [:r1 :r2 :r3 :r4])) + (t/is (cfo/token-applied? token-opacity shape [:opacity])))))) + +(t/deftest export-detach-libraries-preserves-own-tokens-and-status + (let [profile (th/create-profile* 1) + {:keys [library file page-id shape-id token-radius token-opacity]} + (prepare-file-with-tokens-lib profile) + file-data (:data (bfc/get-file th/*system* (:id file))) + library-data (:data (bfc/get-file th/*system* (:id library))) + tokens-lib (cfo/get-tokens-lib library-data) + output (tmp/tempfile :suffix ".zip")] + + ;; Keep the linked library, but use the file's own tokens. + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data file-data) + (pcb/set-tokens-source nil) + (pcb/set-tokens-lib tokens-lib) + (:redo-changes))) + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :detach-libraries) + (io/output-stream output)) + + (let [names (zip-entry-names output)] + (t/is (contains? names (str "files/" (:id file) "/tokens.json"))) + (t/is (contains? names (str "files/" (:id file) "/tokens-status.json")))) + + (let [result (v3/import-files! + (assoc th/*system* + ::bfc/project-id (:default-project-id profile) + ::bfc/team-id (:default-team-id profile) + ::bfc/profile-id (:id profile) + ::bfc/input output)) + imported (bfc/get-file th/*system* (first (:file-ids result))) + file-data (:data imported) + shape (get-in file-data [:pages-index page-id :objects shape-id])] + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (= (ctob/export-dtcg-json tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + (t/is (some? (cfo/get-tokens-status file-data))) + (when-let [status (cfo/get-tokens-status file-data)] + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names status (cfo/get-tokens-lib file-data))))) + (t/is (cfo/token-applied? token-radius shape [:r1 :r2 :r3 :r4])) + (t/is (cfo/token-applied? token-opacity shape [:opacity]))))) + +(defn- import-output + "Import the exported output in the default project of the profile and + return the imported files, indexed by name." + [profile output] + (let [result (v3/import-files! + (assoc th/*system* + ::bfc/project-id (:default-project-id profile) + ::bfc/team-id (:default-team-id profile) + ::bfc/profile-id (:id profile) + ::bfc/input output))] + (into {} + (map (fn [id] + (let [file (bfc/get-file th/*system* id)] + [(:name file) file]))) + (:file-ids result)))) + +(t/deftest export-include-libraries-keeps-tokens-status-of-the-file + (let [profile (th/create-profile* 1) + {:keys [library file]} (prepare-file-with-tokens-lib profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :include-libraries) + (io/output-stream output)) + + (let [files (import-output profile output) + library' (get files (:name library)) + file' (get files (:name file)) + file-data (:data file') + library-lib (cfo/get-tokens-lib (:data library')) + file-status (cfo/get-tokens-status file-data)] + + (t/is (= 2 (count files))) + (t/is (= (:id library') (cfo/get-tokens-source file-data))) + + ;; The active sets of the file are resolved against the sets of the + ;; imported library, so they are not lost. + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names file-status library-lib))) + (t/is (= #{"token-radius" "token-opacity"} + (set (keys (cfo/get-tokens-in-active-sets file-status library-lib)))))))) + +(t/deftest export-file-with-deleted-tokens-library-embeds-its-tokens + (let [profile (th/create-profile* 1) + {:keys [library file]} (prepare-file-with-tokens-lib profile) + tokens-lib (-> (bfc/get-file th/*system* (:id library)) + (:data) + (cfo/get-tokens-lib)) + output (tmp/tempfile :suffix ".zip")] + + ;; The state left by deleting the library: in the trash and unlinked, + ;; but still the tokens source of the file. + (th/db-update! :file {:deleted-at (ct/in-future {:days 7})} {:id (:id library)}) + (th/db-delete! :file-library-rel {:library-file-id (:id library)}) + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :include-libraries) + (io/output-stream output)) + + (t/is (contains? (zip-entry-names output) + (str "files/" (:id file) "/tokens.json"))) + + (let [files (import-output profile output) + file-data (:data (get files (:name file)))] + (t/is (= 1 (count files))) + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (= (ctob/export-dtcg-json tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data))))))) + +(t/deftest export-file-with-missing-tokens-library-removes-tokens-source + (let [profile (th/create-profile* 1) + {:keys [library file]} (prepare-file-with-tokens-lib profile) + output (tmp/tempfile :suffix ".zip")] + + ;; The tokens source points to a library that does not exist anymore + (th/db-delete! :file-library-rel {:library-file-id (:id library)}) + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data (bfc/get-file th/*system* (:id file)))) + (pcb/set-tokens-source (uuid/next)) + (:redo-changes))) + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :include-libraries) + (io/output-stream output)) + + (let [files (import-output profile output) + file-data (:data (get files (:name file)))] + (t/is (= 1 (count files))) + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (nil? (cfo/get-tokens-status file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data)))))) + (t/deftest import-auto-links-single-candidate (let [profile (th/create-profile* 1) ;; Create a shared library file diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index aadd976c2d..1bb3a0a09e 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -6,11 +6,16 @@ (ns backend-tests.rpc-file-test (:require + [app.binfile.common :as bfc] [app.common.features :as cfeat] + [app.common.files.changes-builder :as pcb] + [app.common.files.tokens :as cfo] [app.common.pprint :as pp] [app.common.thumbnails :as thc] [app.common.time :as ct] [app.common.types.shape :as cts] + [app.common.types.tokens-lib :as ctob] + [app.common.types.tokens-status :as ctos] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -2169,6 +2174,93 @@ (get-in out [:result :data :pages-index f-page :objects inst-id :component-file]))))))))) +(defn- prepare-tokens-library + "A shared library with a tokens lib, a file linked to it that uses it + as its tokens source, and another linked file that does not." + [profile] + (let [set-id (uuid/next) + theme-id (uuid/next) + tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id set-id :name "test-set")) + (ctob/add-theme (ctob/make-token-theme :id theme-id + :name "test-theme" + :sets #{"test-set"})) + (ctob/add-token set-id (ctob/make-token :name "token-radius" + :type :border-radius + :value 10))) + params {:profile-id (:id profile) + :project-id (:default-project-id profile)} + library (th/create-file* 1 (assoc params :is-shared true)) + file (th/create-file* 2 params) + other (th/create-file* 3 params)] + + (th/link-file-to-library* {:file-id (:id file) :library-id (:id library)}) + (th/link-file-to-library* {:file-id (:id other) :library-id (:id library)}) + + (update-file! + :file-id (:id library) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data library)) + (pcb/set-tokens-lib tokens-lib) + (:redo-changes))) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data file)) + (pcb/set-tokens-source (:id library)) + (pcb/set-tokens-status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id})) + (:redo-changes))) + + {:library library :file file :other other :tokens-lib tokens-lib})) + +(defn- check-tokens-absorbed + "The file is its own tokens source, with a copy of the tokens lib, and + keeps its tokens status; the other file is not changed." + [{:keys [file other tokens-lib]}] + (let [file-data (:data (bfc/get-file th/*system* (:id file))) + other-data (:data (bfc/get-file th/*system* (:id other)))] + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (= (ctob/export-dtcg-json tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-set"}} + (cfo/ids->names (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data)))) + (t/is (nil? (cfo/get-tokens-lib other-data))))) + +(t/deftest delete-shared-library-absorbs-its-tokens-lib + (let [profile (th/create-profile* 1) + {:keys [library file] :as prepared} (prepare-tokens-library profile) + out (th/command! {::th/type :delete-file + ::rpc/profile-id (:id profile) + :id (:id library)})] + (t/is (nil? (:error out))) + + ;; The relations are removed at once, the absorption is done by the + ;; deletion task. + (t/is (empty? (th/db-query :file-library-rel {:library-file-id (:id library)}))) + (t/is (= (:id library) + (cfo/get-tokens-source (:data (bfc/get-file th/*system* (:id file)))))) + + (th/run-pending-tasks!) + (check-tokens-absorbed prepared))) + +(t/deftest unpublish-shared-library-absorbs-its-tokens-lib + (let [profile (th/create-profile* 1) + {:keys [library] :as prepared} (prepare-tokens-library profile) + out (th/command! {::th/type :set-file-shared + ::rpc/profile-id (:id profile) + :id (:id library) + :is-shared false})] + (t/is (nil? (:error out))) + (check-tokens-absorbed prepared))) + (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/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 34c5b760d6..e78adf2c47 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -547,6 +547,32 @@ [file-data f & args] (d/update-when file-data :tokens-status #(apply f % args))) +(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 + its own tokens source. + + The tokens-status of the file is preserved. If the file has none, the + 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." + [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 [tokens-status (or (get-tokens-status file-data) + (get-tokens-status library-data))] + (cond-> (-> file-data + (set-tokens-source nil) + (assoc :tokens-lib tokens-lib)) + (some? tokens-status) + (assoc :tokens-status tokens-status))))))) + ;; Tokens status with tokens lib (defn- calculate-active-sets diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index c4ab29e3da..3a52dd79ca 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -11,6 +11,7 @@ [app.common.features :as cfeat] [app.common.files.defaults :refer [version]] [app.common.files.helpers :as cfh] + [app.common.files.tokens :as cfo] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.geom.shapes.tree-seq :as gsts] @@ -1238,7 +1239,9 @@ (fn [pages-index] (d/update-vals pages-index #(update % :objects detach-objects)))] - (update-in file [:data :pages-index] detach-pages))) + (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)))) ;; Base font size diff --git a/common/test/common_tests/files/tokens_test.cljc b/common/test/common_tests/files/tokens_test.cljc index 76da45bd4e..8d6a28b393 100644 --- a/common/test/common_tests/files/tokens_test.cljc +++ b/common/test/common_tests/files/tokens_test.cljc @@ -1494,3 +1494,218 @@ ;; set-theme-active false removes non-existent id (let [status' (cfo/set-theme-active all-invalid tokens-lib invalid-theme-id false)] (t/is (not (ctos/theme-active? status' invalid-theme-id)))))))) + +;; absorb-tokens-lib + +(defn- make-sample-tokens-lib + "A tokens-lib with a set (and a token in it) for each label. Each set + is named as its label, and its id is registered in thi under it." + [& set-labels] + (reduce (fn [tokens-lib set-label] + (let [set-id (thi/new-id! set-label)] + (-> tokens-lib + (ctob/add-set (ctob/make-token-set :id set-id + :name (name set-label))) + (ctob/add-token set-id (ctob/make-token :name (str (name set-label) "-token") + :type :border-radius + :value 10))))) + (ctob/make-tokens-lib) + set-labels)) + +(defn- make-sample-tokens-status + "A tokens-status with the sets of the given labels active. The sets + must have been created before by make-sample-tokens-lib." + [& set-labels] + (ctos/make-tokens-status :active-set-ids (into #{} (map thi/id) set-labels))) + +(defn- make-sample-data + "File data with the given tokens-lib, tokens-status and tokens-source. + Those not given are left unset." + [label & {:keys [tokens-lib tokens-status tokens-source]}] + (-> (ctf/file-data (thf/sample-file label)) + (cfo/ensure-tokens-lib) + (cfo/update-tokens-lib (constantly tokens-lib)) + (cfo/update-tokens-status (constantly tokens-status)) + (cfo/set-tokens-source tokens-source))) + +(t/deftest test-absorb-tokens-lib + (t/testing "copies the tokens-lib of the library and unsets the tokens-source" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (cfo/get-tokens-lib library-data) (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))) + (t/is (true? (cfo/effective-tokens-source? file-data' (:id file-data')))) + (t/is (true? (cfo/editable-tokens? file-data'))))) + + (t/testing "replaces the own tokens-lib of the file" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-lib (make-sample-tokens-lib :own-set) + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (cfo/get-tokens-lib library-data) (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "copies an empty tokens-lib of the library" + (let [empty-lib (ctob/make-tokens-lib) + library-data (make-sample-data :library :tokens-lib empty-lib) + file-data (make-sample-data :file1 + :tokens-lib (make-sample-tokens-lib :own-set) + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= empty-lib (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "unsets the tokens-source even if the file had no tokens-source" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (cfo/get-tokens-lib library-data) (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "does not change anything else in the file" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (dissoc file-data :tokens-lib :tokens-status :tokens-source) + (dissoc file-data' :tokens-lib :tokens-status :tokens-source)))))) + +(t/deftest test-absorb-tokens-lib-status + (t/testing "preserves the tokens-status of the file" + (let [library-lib (make-sample-tokens-lib :lib-set :other-set) + file-status (make-sample-tokens-status :lib-set) + library-status (make-sample-tokens-status :other-set) + library-data (make-sample-data :library + :tokens-lib library-lib + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-status file-status + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-status (cfo/get-tokens-status file-data'))) + (t/is (not= library-status (cfo/get-tokens-status file-data'))))) + + (t/testing "preserves an empty tokens-status of the file" + (let [library-lib (make-sample-tokens-lib :lib-set) + file-status (ctos/make-tokens-status) + library-status (make-sample-tokens-status :lib-set) + library-data (make-sample-data :library + :tokens-lib library-lib + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-status file-status + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-status (cfo/get-tokens-status file-data'))))) + + (t/testing "copies the tokens-status of the library if the file has none" + (let [library-lib (make-sample-tokens-lib :lib-set) + library-status (make-sample-tokens-status :lib-set) + library-data (make-sample-data :library + :tokens-lib library-lib + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= library-status (cfo/get-tokens-status file-data'))) + (t/is (= #{(thi/id :lib-set)} + (ctos/get-active-set-ids (cfo/get-tokens-status file-data')))) + (t/is (some? (ctob/get-set (cfo/get-tokens-lib file-data') (thi/id :lib-set)))))) + + (t/testing "leaves the tokens-status unset if neither the file nor the library have one" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (nil? (cfo/get-tokens-status file-data')))))) + +(t/deftest test-absorb-tokens-lib-library-without-tokens + (t/testing "library without tokens-lib: keeps the tokens-lib and tokens-status of the file, and unsets the tokens-source" + (let [file-lib (make-sample-tokens-lib :own-set) + file-status (make-sample-tokens-status :own-set) + library-status (ctos/make-tokens-status :active-set-ids #{(uuid/next)}) + library-data (make-sample-data :library + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-lib file-lib + :tokens-status file-status + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-lib (cfo/get-tokens-lib file-data'))) + (t/is (= file-status (cfo/get-tokens-status file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "library without tokens-lib: does not copy the tokens-status of the library" + (let [library-status (ctos/make-tokens-status :active-set-ids #{(uuid/next)}) + library-data (make-sample-data :library + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (nil? (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-status file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "library without any tokens attribute" + (let [library-data (ctf/file-data (thf/sample-file :library)) + file-lib (make-sample-tokens-lib :own-set) + file-data (make-sample-data :file1 + :tokens-lib file-lib + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-lib (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "neither the file nor the library have tokens" + (let [library-data (ctf/file-data (thf/sample-file :library)) + file-data (ctf/file-data (thf/sample-file :file1)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (nil? (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-status file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data')))))) + +(t/deftest test-absorb-tokens-lib-no-library-data + (t/testing "nil library-data: the file is returned unchanged" + (let [file-lib (make-sample-tokens-lib :own-set) + file-data (make-sample-data :file1 + :tokens-lib file-lib + :tokens-status (make-sample-tokens-status :own-set) + :tokens-source (uuid/next))] + (t/is (identical? file-data (cfo/absorb-tokens-lib file-data nil))))) + + (t/testing "nil library-data and a file without tokens" + (let [file-data (ctf/file-data (thf/sample-file :file1))] + (t/is (identical? file-data (cfo/absorb-tokens-lib file-data nil)))))) + +(t/deftest test-detach-external-references-preserves-own-tokens + (let [file (thf/sample-file :file1) + lib (make-sample-tokens-lib :own-set) + status (make-sample-tokens-status :own-set) + file (-> file + (assoc-in [:data :tokens-lib] lib) + (assoc-in [:data :tokens-status] status))] + (doseq [source [nil (:id file)]] + (t/testing (str "local tokens source " source) + (let [file (cond-> file + source (assoc-in [:data :tokens-source] source)) + detached (ctf/detach-external-references file (:id file))] + (t/is (= source (cfo/get-tokens-source (:data detached)))) + (t/is (identical? lib (cfo/get-tokens-lib (:data detached)))) + (t/is (= status (cfo/get-tokens-status (:data detached))))))))) + +(t/deftest test-detach-external-references-removes-external-tokens-status + (let [file (-> (thf/sample-file :file1) + (assoc-in [:data :tokens-source] (uuid/next)) + (assoc-in [:data :tokens-status] (ctos/make-tokens-status))) + detached (ctf/detach-external-references file (:id file))] + (t/is (not (contains? (:data detached) :tokens-source))) + (t/is (not (contains? (:data detached) :tokens-status))) + (t/is (= detached (ctf/check-file detached))))) diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index 92a458e772..27d91275c0 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -123,7 +123,6 @@ (let [type (-> (dom/get-target event) (dom/get-data "type") (keyword))] - (prn "AAA" selected type) (swap! state* assoc :selected type))))] (mf/with-effect [has-libs?] From 556d604e67b85b753740a066a63a8601d2d968de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 15 Oct 2026 17:09:01 +0200 Subject: [PATCH 024/167] :bug: Refresh clip content button state after toggling it (#12353) --- .../main/ui/workspace/sidebar/options/menus/measures.cljs | 2 ++ .../test/frontend_tests/ui/measures_menu_props_test.cljs | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index b398f099fc..3f56fbbc23 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -154,6 +154,8 @@ (get n-values :rotation)) (identical? (get o-values :hide-in-viewer) (get n-values :hide-in-viewer)) + (identical? (get o-values :show-content) + (get n-values :show-content)) (identical? (get o-values :width) (get n-values :width)) (identical? (get o-values :height) diff --git a/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs b/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs index 0f509d3a08..790b749aef 100644 --- a/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs +++ b/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs @@ -55,4 +55,9 @@ ;; force a re-render so the X/Y inputs enabled/disabled state updates. (t/is (false? (check-measures-menu-props (props (assoc base-values :layout-item-absolute false)) - (props (assoc base-values :layout-item-absolute true))))))) + (props (assoc base-values :layout-item-absolute true)))))) + + (t/testing "re-renders when clip content changes but nothing else does" + (t/is (false? (check-measures-menu-props + (props (assoc base-values :show-content false)) + (props (assoc base-values :show-content true))))))) From 57fa280209c63e02dc1ca06e7311d877780f2f49 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 19:18:04 +0200 Subject: [PATCH 025/167] :bug: Validate and decode a job result with its declared schema (#12350) * :bug: Decode job results with the schema of the job-def A job result is stored as plain JSON, so a uuid comes back as the text it is on disk and a uuid used as a map key comes back as a keyword. The import resolution is a map keyed by file id, and the wizard lost that identity: choosing a library in the link step created no link at all, with no error and with the modal closing as if it had worked. A job-def can now declare a result schema and its precompiled decoder, the pair the params already have, and the substrate applies them when the row is read. A def that declares neither answers with the JSON the row holds, as before; a row whose job-def is gone is read untouched instead of raising. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Type the import result so its resolution keeps uuid keys The resolution of a version 3 import is a map keyed by the new file id, and the result column is plain JSON: without a schema of its own every key came back as a keyword while the :id of the entry came back as text, so the dashboard wizard could not match the file it was linking and created no library link at all. Declare the shape of the resolution (files, done and pending entries and their candidates) and give the job-def its result schema and decoder. The check before the result is stored keeps validating the same schema. Closes #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Take the imported file id from the entry, not the map key The wizard looked a file up in the selection with the key of the resolution map while it had indexed the selection by the :id of the entry. The key is keywordized on its way through the job result, so the lookup missed and the confirm button created no library link at all, with no error and with the modal closing as if it had worked. Take the id from the entry, the source the rest of the wizard already uses, and move the decision to a pure function covered by tests of both shapes of the resolution. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Test that an import result keeps its ids as uuids The regression this branch fixes is only visible through the read path of the substrate: the resolution of an import is a map keyed by the new file id, and a test that works with the value in memory never sees the keys coming back as keywords from the plain JSON column. Import a package whose library needs a replacement chosen by the user, read the result with `jobs/get-job` as the client does, and check the keys, the file ids and the ids of the candidates. Without the result decoder of the import job-def the test answers with text ids. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :books: Document the result schema of a job-def The memories and the docstring of the substrate said a job result has no schema of its own and comes back as the JSON the row holds. A job-def can now declare its result schema and decoder, and the substrate applies them when the row is read. Keep the rule next to it: a result map keyed by uuid needs its schema declared, or every key comes back as a keyword, and the link-later step of the import wizard takes the file id from the entry and never from the key of the resolution map. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :books: Fix the documented contract of a job result The docstring of get-job said a result has no schema of its own and comes back as the JSON the row holds, which is what this branch changes: the result is decoded with the schema its job-def declares, and a result that does not conform never reaches the column because the write validates it. Say the same in the management command that stores the result of an external worker, the other writer of the column. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Check the result of a job against its schema before storing it The result of a job crosses a plain JSON column, so a uuid comes back as text and a uuid used as a map key as a keyword. The job-def now declares the schema of its result and the substrate compiles the decoder from it, which is what the read path uses to get the types back. The same schema is the contract of the write path: complete refuses a result that does not conform, with the malli explanation, so nothing invalid reaches the column, and the answer is the same for the runner and for the external worker that completes a job through the management RPC. A job that is no longer completable is skipped without checking, and the read path decodes only: a row written by another version stays readable. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Check the result of an import inside its transaction The check ran after the transaction of the import had committed, so a result the schema does not accept failed the job with the files already created in the destination project: a half imported project and an error. The core decides whether the whole import is kept or rolled back, so the check belongs inside its transaction, which is what turns a contract violation into a rollback. The substrate checks the result again when the job is completed, which is the only net for a job that does not validate its own result. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Cover the edges of an import result read by the client The shape of a result with no libraries to resolve, and the one of a version 1 import, is the most common in production and neither was covered by the tests that read a result the way the client does. Cover both through jobs/get-job, and the case of a registry that no longer knows the kind of the row: it answers with the JSON of the column, keywordized keys and uuid as text, instead of raising. Say in the two tests that depend on the decoder what implementation without it makes them fail. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :books: Fix the prose and the fixtures of the result contract The result schema of a job-def is what the read decodes with and what the write checks: say that in the memories, with the rule that any uuid of a result needs it, whether a value or a map key. The motive of the wizard helper was a fact this branch makes false: what a key of the resolution map holds depends on the result schema of the job-def that wrote it, so the rule is to read the id from the entry. Same correction in its memory and in the test that pins the shape. The candidates of a pending entry match by slugified name, not by name, and every test row now states the kind of its job, because the name is what resolves its job-def when the result is read or checked. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :recycle: Keep the wizard link helper private and cover its shape The helper that decides which links the wizard creates belongs to its namespace, as its neighbours do: reach it from the test with @#', the pattern the frontend tests already use for a private helper. Cover the shapes it had no test for: a file with two libraries pending resolution, a file whose libraries were auto-linked, and a selection where only one of two entries was resolved. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :recycle: Share one job-row helper across the job tests Three test namespaces had their own copy of the same insert, differing only in the kind of the job and in whether the row holds the resource it consumes. Move it to the test helpers as create-job*, with the kind of the job as its first argument: it is what resolves the job-def of the row when its result is read or checked, so every test states it. The bang of the name goes away with it: the design rules keep it for the mutating API of app.db, and this is a fixture of the tests. The three namespaces lose the requires that only their removed copy used. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh --------- Signed-off-by: Andrey Antukh --- .serena/memories/backend/core.md | 8 +- .serena/memories/backend/subtleties.md | 2 +- .../frontend/dashboard-viewer-subtleties.md | 2 +- backend/src/app/jobs.clj | 107 ++++++++-- backend/src/app/rpc/commands/jobs.clj | 14 +- backend/src/app/rpc/management/jobs.clj | 12 +- backend/src/app/tasks/import_binfile.clj | 77 +++++-- .../test/backend_tests/binfile_jobs_test.clj | 200 +++++++++++++++--- backend/test/backend_tests/helpers.clj | 24 +++ backend/test/backend_tests/jobs_test.clj | 129 ++++++++++- .../tasks/export_binfile_test.clj | 31 +-- .../tasks/import_binfile_test.clj | 74 ++++--- .../src/app/main/ui/dashboard/import.cljs | 41 ++-- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/dashboard_import_linking_test.cljs | 128 +++++++++++ 15 files changed, 698 insertions(+), 153 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/dashboard_import_linking_test.cljs diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index ef916f93a4..e884cda4e4 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -55,7 +55,7 @@ For deeper details on transaction semantics, advisory locks, Transit vs JSON hel ## Background tasks (unified jobs) -Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` in its namespace + a malli params schema + an `ig/init-key` that returns the job-def map `{::jobs/name, ::jobs/schema, ::jobs/handler, ::jobs/decoder, ::jobs/validator}` (decoder/validator precompiled at init). The registry is the `::jobs/defs` wiring in `app.main`, which also populates a module-level registry used by `jobs/submit` as fallback — job-def components cannot ig/ref `::jobs/defs` (wiring cycle). Submit from RPC code passes its RPC cfg (it carries `::jobs/defs` via ig/ref). +Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` in its namespace + a malli params schema + an `ig/init-key` that returns the job-def map `{::jobs/name, ::jobs/schema, ::jobs/handler, ::jobs/decoder, ::jobs/validator}` (decoder/validator precompiled at init), optionally with `::jobs/result-schema` when the result carries types plain JSON does not keep (a uuid, whether a value or a map key): the substrate compiles the decoder from that schema on registry init and checks a result against the schema before storing it. The registry is the `::jobs/defs` wiring in `app.main`, which also populates a module-level registry used by `jobs/submit` as fallback — job-def components cannot ig/ref `::jobs/defs` (wiring cycle). Submit from RPC code passes its RPC cfg (it carries `::jobs/defs` via ig/ref). - `::jobs/handler` is `(fn [context params] ...)`. It is NOT given the runner cfg: a job-def closes over its own dependencies, and handing it the runner cfg would let a handler reach the pool or any other component. A wrapper passes the context to its implementation only when the implementation takes it; most do not, and their implementation stays `[cfg params]`. - The context is built by `jobs/make-context` and has exactly five keys: `id`, `name`, `label`, `resource-id`, `profile-id`, behind a closed schema. `profile-id` is the owner of a user-facing job and nil for an internal one, and it is there so a handler can revalidate permissions and check who owns its resource without a second query. `queue` is absent on purpose (a routing detail of the dispatcher, not a property of the job) and so are `retry-num`/`max-retries`/`attempt` (retry policy belongs to the runner, and an attempt number cannot be durable because the `noop` strategy re-runs without incrementing the counter). @@ -64,14 +64,14 @@ Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` i - A job-def may carry optional metadata keys read from the registry by its consumers, never by the substrate: `::jobs/family` (e.g. `:export`) groups the jobs of a family for quotes, `::jobs/resource-role` (`:input` or `:output`) says whether the job's `resource_id` is what it consumes or what it produces, and `::jobs/queue-name` names the queue the creation command routes the job to (`submit-job` falls back to `:binfile` when the def does not set it). - Job resources (`app.jobs.storage`) are the objects a job owns through `job.resource_id`: they live in the `job-resource` bucket, are written with the profile of the job as owner (only that profile can read them back from `/assets/by-id`) and are never deduplicated. They carry no expiry of their own, because `expired-at` is stored as `deleted_at` and an object with it is unreadable from birth: the retention of the artifact is the retention of its job row. The writer marks the object as touched right away, so an artifact that no row references (a crash between the upload and the completion) is reclaimed by `storage-gc-touched`, and a row that does reference it freezes it until the jobs GC deletes the row and touches the object. - The binfile jobs (`:export-binfile` and `:import-binfile`) run the core of `bf.v1`/`bf.v3` through `app.binfile.jobs`, which turns the `:progress` taps of the core into `progress` events of the job with `jobs/heartbeat` (the `modified_at` touch at most once per second, progress events at up to ~200ms). Each event is a self-contained milestone: a `:stage` keyword of one vocabulary shared by the two formats (the same work has a different name in v1 and v3) and an optional `:counters` map, with the counter of the activity named after its own stage and the outer scopes in course as context, which the tap carries itself (the reader puts `::outer-counters` in every tap inside them — today only `:files`, extendable to `:teams`/`:projects` for multi-scope exports — so the adapter remembers nothing between taps and a tap that happens outside any scope has no context); the ids and names the taps carry never reach the event. Every tap is reported, so a tap with no units to count still keeps the job alive, and a milestone the throttle drops loses nothing because the next one carries the same state. The adapter checks that the job is still active before the run, and the beat raises an `:interrupt` right there on the run thread when the job is no longer active, so the work stops at its next event point inside the transaction of its handler, which rolls back. A run whose last tap predates the interruption is discarded by the check once it returns. The adapter passes the job id from the handler context. Their job-def components carry pool, storage, metrics and msgbus, because a job with a `profile_id` cannot store an event without a msgbus on its cfg. Both readers answer with the same shape (`:file-ids` and, in version 3, the resolution of the libraries), because the version 1 reader returns a bare set of file ids. -- Both binfile handlers revalidate their permission at run time, not only when the job is created, because it can be revoked while the job waits: the export checks the read permission of every file before reading anything and stores the package in `job-resource` as the resource of the job (answering with the completion envelope), and the import checks the edition permission of the destination and runs the core inside a transaction, which is what makes a cancelled import roll back instead of leaving half of a file. The import releases its package (marks it as deleted and drops the reference from the job row in the same transaction) and deletes its temporary copy once the job is terminal, whatever the outcome. Both handlers check their own result against a schema before returning it, because the job-def has no result schema of its own. +- Both binfile handlers revalidate their permission at run time, not only when the job is created, because it can be revoked while the job waits: the export checks the read permission of every file before reading anything and stores the package in `job-resource` as the resource of the job (answering with the completion envelope), and the import checks the edition permission of the destination and runs the core inside a transaction, which is what makes a cancelled import roll back instead of leaving half of a file. The import releases its package (marks it as deleted and drops the reference from the job row in the same transaction) and deletes its temporary copy once the job is terminal, whatever the outcome. Both handlers check their own result against a schema before returning it (the import job-def declares that same schema as its result schema, which is what types the resolution when the row is read). - The domain cfg of a binfile request lives in `app.binfile.common` (`export-cfg`, `import-cfg`), so the legacy RPC and the jobs freeze the same ids, resolve the same team features and apply the same anti-zip-bomb limits. `resolve-export-type` is the translation the legacy RPC needs for its deprecated boolean flags; the creation command asks the caller for the type instead of guessing a default. - Two retention knobs, easy to confuse: `:jobs-retention` sweeps terminal internal rows (the ones without a profile), while `:jobs-user-ttl` is the default expiry a user-facing job gets when it is created; the jobs GC deletes the row once `expires_at` passes and touches its resource. The heavy work of a user job runs on its own `:binfile` queue (`:worker-binfile-parallelism`, default 1) so it does not block the default runner. - The quote of a family of jobs (`::export-jobs-per-profile`, `::import-jobs-per-profile`) counts the jobs of the profile that are still on their way (`new`/`scheduled`/`running`/`retry`) whose name the registry gives for that family, so a new job of the family is covered without touching the quote. - A user-facing job is created through `app.rpc.commands.jobs`. One command per job type: `create-export-binfile-job` and `create-import-binfile-job` name the job they create and their body carries only `:params`, whose schema the command imports from the job namespace (`::sm/params`), so the RPC validates the business params up front, before the command runs; the job-def is still the place that owns them and validates them again at run time. The commands check the permission and the quote of the family before the expensive part, submit with `max-retries 0`, an expiry from `:jobs-user-ttl` and the queue the job-def routes to through `::jobs/queue-name` (`:binfile` when the def says nothing; read by the command, never by the substrate), and answer with a summary of the row. The import command has its own creation schema (`import-binfile/schema:create-params`, version optional) because the request is not the stored params: the command fills the version (from the caller or the package header) and the manifest metadata kept for audit. It assembles the upload, reads the manifest of a version 3 package for the audit (never its content) and stores the package in `job-resource` as the resource of the job, releasing it when the job never comes to own it. -- `get-job` is the read surface a profile has over its own jobs: it answers with the same summary the creation commands return plus the `result` of a finished job or its public error (through `decode-job-error`, so the keywords come back), and a job of another profile is not distinguishable from one that does not exist: both answer `not-found`. `decode-row` decodes `params`, `result` and `error`, and the result is the plain JSON the row holds, so a uuid comes back as the text it is on disk (a result has no schema of its own like the params do). The event log still has no read surface: a client that follows a job takes its progress from the websocket. +- `get-job` is the read surface a profile has over its own jobs: it answers with the same summary the creation commands return plus the `result` of a finished job or its public error (through `decode-job-error`, so the keywords come back), and a job of another profile is not distinguishable from one that does not exist: both answer `not-found`. `decode-row` decodes `params`, `result` and `error`, and the result is then decoded with the decoder the substrate compiled from the result schema the job-def the row names declares (`decode-result`; a def without one, or a row whose job-def is gone, keeps the plain JSON the row holds: a uuid as the text it is on disk, a uuid used as a map key as a keyword). The event log still has no read surface: a client that follows a job takes its progress from the websocket. - `heartbeat` uses named options `[cfg & {:keys [job-id progress] :as options}]` and returns the number of durable writes, so 0 means there was nothing to write: no job context, or the throttle did not allow it. A write that finds the job no longer active (terminal or gone) raises an `:interrupt` (`code :job-interrupted`, carrying the job id and the status it found) through the shared `jobs/check-active` instead of counting zero, so a task in course stops at its next beat; the interrupt is internal and never reaches HTTP (the management progress report answers `:skip` for it). The job id falls back to `::jobs/job-id` on the cfg and then to the runner-bound `*job-id*`. The `modified_at` touch is throttled to ~1s and the progress event to ~200ms, so the check fires at most once per second with no extra query (the read of the row happens only when a write already found nothing). A progress report is a milestone (`schema:progress`): a required `:stage` keyword plus an optional `:counters` map of `scope -> {:current, :total?}`, and no other key. Counters are non-negative, a `total` is positive and never lower than its `current`, and stage and counter keys are names of at most 64 characters. A milestone without counters is valid (a point of the run with no units to count), the stage vocabulary belongs to the worker and not to the substrate, and a client must degrade for a stage it does not know. Read a stored payload back with `jobs/decode-progress`: the database holds the keywords as strings. The 1s beat (down from 60s, so a cancelled heavy job is seen as gone at its next beat) costs one `modified_at` touch per beating job per second: cheap while the only heavy beater runs alone on its queue, revisit if concurrent beating jobs ever grow. -- `job.error` conforms to `app.jobs/schema:job-error` (`type` and `code` keywords, `hint` text, extra details allowed). Read it back with `jobs/decode-job-error`; a plain `db/decode-json-pgobject` leaves the keywords as strings. `complete` takes named options and an optional `resource-id`, which is only ever set, never replaced: it reads the row locked inside its own transaction and applies the rules in a fixed order, skipping a job that is no longer completable before refusing one for its resource, so the answer is a function of the row and not of the order two callers arrive in. +- `job.error` conforms to `app.jobs/schema:job-error` (`type` and `code` keywords, `hint` text, extra details allowed). Read it back with `jobs/decode-job-error`; a plain `db/decode-json-pgobject` leaves the keywords as strings. `complete` takes named options and an optional `resource-id`, which is only ever set, never replaced: it reads the row locked inside its own transaction and applies the rules in a fixed order, skipping a job that is no longer completable before refusing one for its resource, so the answer is a function of the row and not of the order two callers arrive in. It checks the result against the schema the job-def of the row declares before it writes (a violation raises `:invalid-result` and nothing is stored), and only for a transition that is going to write: a terminal job answers `:skip` without validating. `complete` is the single door of a result, so the check covers the runner and the external worker that completes a job through the management RPC (`::complete-job`), which stores a result the substrate would otherwise take blind. For worker dispatch, cron, retry semantics (`ex/raise :type ::wrk/retry` with `:delay`/`:strategy`), deduplication, job events, and queue internals: `mem:backend/subtleties`. diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index 18af2954aa..c95dd5d174 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -29,7 +29,7 @@ - Whether a call is nested is decided by the CONNECTION, not by an ambient flag: `db/transact!` binds `*after-commit-conn*` around the body, and a transactable that is not identical to that connection is an independent unit of work that owns its own after-commit context. A `tx-run!` that passes a pool while a transaction is open therefore commits on its own and drains its own callbacks, instead of leaving them in a context that may roll back. - `after-commit` callbacks run on commit only. A transaction marked `::db/rollback` (used by `srepl` and `app.tasks/offload-file-data`) commits nothing, so it runs none of them. - `db/run!` opens/reuses one connection but does not create a transaction. -- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Job params (`job.params`) are plain JSON, decoded with the job-def decoder (`app.jobs/decode-params`); only the legacy `task` props were Transit (`decode-transit-pgobject`). +- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Job params (`job.params`) are plain JSON, decoded with the job-def decoder (`app.jobs/decode-params`); only the legacy `task` props were Transit (`decode-transit-pgobject`). Job results (`job.result`) are plain JSON too: they are read with the result decoder of the job-def (`app.jobs/decode-result`), and a result map keyed by uuid must declare its schema (`[:map-of ::sm/uuid ...]`, the import resolution does) or every key comes back as a keyword and every uuid as the text it is on disk. - Advisory transaction locks accept UUIDs or ints. UUID locks are hashed using a zero-UUID seeded siphash. ## Workers and cron diff --git a/.serena/memories/frontend/dashboard-viewer-subtleties.md b/.serena/memories/frontend/dashboard-viewer-subtleties.md index abd03e4f03..9c753d8875 100644 --- a/.serena/memories/frontend/dashboard-viewer-subtleties.md +++ b/.serena/memories/frontend/dashboard-viewer-subtleties.md @@ -5,7 +5,7 @@ - Dashboard initialization fetches projects and fonts for the team, then listens to websocket messages only for global topic `uuid/zero` or the current profile id. - Project fetch replaces each project map completely instead of merging, so fields such as `deleted-at` can disappear cleanly. - Dashboard file/project mutations are often optimistic local updates with fire-and-forget RPC watchers. Bulk permanent delete/restore paths use SSE progress and progress notifications. -- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its rows paint the milestone of the job (the stage and its counter, with the file in course) instead of the messages the old SSE stream sent. Before the job exists, the rows paint a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and the row writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, each entry reports `:queued` ("Queued"); the `:start`/`:retry` events move it to bare progress, and only `:progress` carries a milestone. The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line any more: each row carries its own status. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The legacy SSE import stays in the backend for other clients. +- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its rows paint the milestone of the job (the stage and its counter, with the file in course) instead of the messages the old SSE stream sent. Before the job exists, the rows paint a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and the row writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, each entry reports `:queued` ("Queued"); the `:start`/`:retry` events move it to bare progress, and only `:progress` carries a milestone. The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line any more: each row carries its own status. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The link-later step takes the file id from the `:id` of its resolution entry, never from the key of the resolution map: the map comes from the job result, so what a key holds depends on the result schema of the job-def that wrote it, while the wizard indexes its selection by the id of the entry. The legacy SSE import stays in the backend for other clients. - File creation/duplication strips file `:data` before putting file summaries into dashboard state. ## Viewer diff --git a/backend/src/app/jobs.clj b/backend/src/app/jobs.clj index 19df02cc9b..caf19b6f4e 100644 --- a/backend/src/app/jobs.clj +++ b/backend/src/app/jobs.clj @@ -9,11 +9,15 @@ Every job is defined as a job-def map returned by the `ig/init-key` of its module: {::name, ::schema, ::handler, ::decoder, ::validator} — decoder and - validator are precompiled at init time. A job-def may also carry the - optional metadata {::family, ::resource-role}, which the consumers of the - job (quotes, storage helpers) read from the registry: the substrate never - reads them. The registry of job-defs is plain integrant wiring (::jobs/defs); - submit, dispatch and management consume it by reference. + validator are precompiled at init time. A def whose result needs its types + back declares the optional ::result-schema: the substrate compiles the + decoder from that schema and checks a result against it before storing it, + while a def without one answers with the JSON the row holds. A job-def may + also carry the optional metadata {::family, ::resource-role}, which the + consumers of the job (quotes, storage helpers) read from the registry: the + substrate never reads them. The registry of job-defs is plain integrant + wiring (::jobs/defs); submit, dispatch and management consume it by + reference. Two execution modes are provided: - `submit` (durable): validates + JSON-encodes params and inserts a row @@ -24,6 +28,11 @@ Params payloads are stored as plain JSON (not transit) in the `params` jsonb column and decoded back to typed Clojure values using the job-def decoder. + Results are plain JSON in the `result` jsonb column: they are decoded with the + decoder the substrate compiled from the schema the def declares, and a result + that does not conform to that schema is refused before it is stored. Without a + schema a uuid comes back as the text it is on disk and a uuid used as a map + key as a keyword, so a def that keys a result map by uuid must declare it. Any job that can run longer than `:jobs-lease` must call `heartbeat` on its loop, otherwise the dispatcher marks it `aborted` (a system-side @@ -151,7 +160,14 @@ ;; implementation stays [cfg params]. [::handler ::sm/fn] [::decoder ::sm/fn] - [::validator ::sm/fn]]) + [::validator ::sm/fn] + ;; A result crosses a plain JSON column on its way out of the job, so a + ;; uuid comes back as the text it is on disk and a uuid used as a map + ;; key comes back as a keyword. A def that declares the schema of its + ;; result gets its types back when the row is read (the substrate + ;; compiles the decoder from the schema), and its result is checked + ;; against that schema before it is stored. + [::result-schema {:optional true} any?]]) (def ^:private schema:job-defs [:map-of :keyword schema:job-def]) @@ -189,11 +205,24 @@ :job (d/name name) :expected (d/name (::name job-def)))))) +(defn- compile-result-schema + "Compile the decoder of a job-def from the result schema it declares, so the + pair cannot drift from the schema and a def without one keeps working + untouched. The write path checks the result against the schema itself." + [job-def] + (if-let [schema (::result-schema job-def)] + (assoc job-def ::result-decoder (sm/decoder schema sm/json-transformer)) + job-def)) + (defmethod ig/init-key ::defs [_ defs] - (reset! defs-registry defs) - (l/inf :hint "job definitions initialized" :jobs (count defs)) - defs) + (let [defs (reduce-kv (fn [acc name job-def] + (assoc acc name (compile-result-schema job-def))) + {} + defs)] + (reset! defs-registry defs) + (l/inf :hint "job definitions initialized" :jobs (count defs)) + defs)) (defmethod ig/halt-key! ::defs [_ defs] @@ -222,6 +251,32 @@ db/decode-json-pgobject) ((::decoder job-def)))) +(defn decode-result + "Decode the raw JSON result (pgobject or decoded map) with the decoder the + substrate compiled from the result schema of the job-def. A def without one + answers with the JSON the row holds: every key keywordized and every uuid as + the text it is on disk." + [job-def result] + (when (some? result) + (let [value (cond-> result + (db/pgobject? result) + db/decode-json-pgobject)] + (if-let [decoder (::result-decoder job-def)] + (decoder value) + value)))) + +(defn- check-result + "Validate the result of a job against the schema its job-def declares, with + the malli explanation on failure. A job-def without a result schema has + nothing to check, and so has a job whose definition is gone." + [job-def result] + (when-let [schema (::result-schema job-def)] + (sm/check schema result + :hint "invalid result of a job" + :type :validation + :code :invalid-result)) + result) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SUBMIT API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -432,11 +487,21 @@ (decode-json-col :result) (decode-json-col :error))) +(defn- decode-result-of + "Decode the result of a row with the decoder of the job-def that owns it. + A kind the registry no longer knows (a retired job-def) answers with the + row untouched instead of raising." + [cfg row] + (if-let [job-def (get (get-defs cfg) (keyword (:name row)))] + (update row :result #(decode-result job-def %)) + row)) + (defn get-job - "Retrieve the job row (with the raw JSON params decoded to a plain map)." + "Retrieve the job row, with params and result decoded to plain maps (the + result only when its job-def declares a result decoder)." [cfg job-id] - (some-> (db/get* cfg :job {:id job-id}) - (decode-row))) + (when-let [row (db/get* cfg :job {:id job-id})] + (decode-result-of cfg (decode-row row)))) (defn get-user-status "Map the internal job status to the user-facing status." @@ -1058,7 +1123,8 @@ - `:job-id` the job to complete. - `:result` the JSON-encodable result, nil when the job returns - nothing. + nothing; a result that does not conform to the schema + its job-def declares is refused. - `:resource-id` optional storage object to associate with the job. A resource is only ever set, never replaced: passing one for a job that already has a resource is a validation @@ -1073,11 +1139,13 @@ aborted by the dispatcher — is never overwritten) and writes no event. Returns the number of affected rows. - The row is read locked inside that same transaction, so the two rules - are applied in a fixed order against the row and not against the - clock: a job that is no longer completable is skipped, and only a job - that is still completable can be refused for its resource. A - concurrent completion waits for the lock and then sees the outcome." + The row is read locked inside that same transaction, so the rules are + applied in a fixed order against the row and not against the clock: a + job that is no longer completable is skipped, only a job that is still + completable can be refused for its resource, and the result is checked + against the schema its job-def declares before it is stored (a def + without one has nothing to check). A concurrent completion waits for + the lock and then sees the outcome." [cfg & {:keys [job-id result resource-id]}] (assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg") (check-resource-id resource-id) @@ -1097,6 +1165,9 @@ :hint "the job already has a resource, it cannot be replaced" :job-id job-id :resource-id (:resource-id job))) + (check-result (get (get-defs tx-cfg) + (keyword (:name job))) + result) (if (db/exec-one! conn [sql:complete-job (ct/now) (ct/now) encoded resource-id job-id]) (do diff --git a/backend/src/app/rpc/commands/jobs.clj b/backend/src/app/rpc/commands/jobs.clj index 54c7c352ed..c3f590315b 100644 --- a/backend/src/app/rpc/commands/jobs.clj +++ b/backend/src/app/rpc/commands/jobs.clj @@ -330,11 +330,15 @@ "Read a job the caller created: its state and, once it is over, what it produced or why it failed. - The result of a finished job is the plain JSON the row holds, because a - result has no schema of its own like the params do: a uuid comes back as - the text it is on disk. The progress and the history of the job are not - part of the answer: a client that follows a job takes its progress from - the websocket, and the event log still has no read surface." + The result of a finished job is the JSON the row holds, decoded with the + result schema its job-def declares: a job that keys its result by uuid + (the import resolution) answers with the types it stored, and a job whose + job-def declares no schema answers with the JSON as it is on disk, where a + uuid is text and a uuid used as a map key is a keyword. A result that does + not conform to the declared schema never reaches the column: the substrate + validates it when the job is completed. The progress and the history of + the job are not part of the answer: a client that follows a job takes its + progress from the websocket, and the event log still has no read surface." {::doc/added "2.20" ::sm/params schema:get-job ::sm/result schema:job-detail} diff --git a/backend/src/app/rpc/management/jobs.clj b/backend/src/app/rpc/management/jobs.clj index 1e71acb101..f5fc250c96 100644 --- a/backend/src/app/rpc/management/jobs.clj +++ b/backend/src/app/rpc/management/jobs.clj @@ -106,7 +106,10 @@ [:job-id ::sm/uuid] ;; Per-job result: any JSON-encodable value, nil when the job returns ;; nothing. Nested maps and vectors are allowed; unserializable - ;; values are dropped to nil by jobs/encode-result with a warning. + ;; values are dropped to nil by jobs/encode-result with a warning. A + ;; job whose job-def declares a result schema has its result validated + ;; against it: one that does not conform is rejected and the job is + ;; not completed. [:result [:maybe :any]] ;; Storage object the worker produced for this job. A resource is only ;; set, never replaced: a job that already has one is rejected. @@ -117,6 +120,13 @@ [:action [:enum :run :skip]]]) (sv/defmethod ::complete-job + "Complete a job on behalf of the worker that ran it, with the result it + produced and, when it wrote one, the resource it stores. + + The result of a job whose job-def declares a result schema is validated + against it before it is stored: one that does not conform is rejected and + the job is not completed. A job that is no longer completable is skipped, + and nothing is validated for it." {::doc/added "2.20" ::sm/params schema:complete-job-params ::sm/result schema:complete-job-result diff --git a/backend/src/app/tasks/import_binfile.clj b/backend/src/app/tasks/import_binfile.clj index 5dc286db08..754bb4bf25 100644 --- a/backend/src/app/tasks/import_binfile.clj +++ b/backend/src/app/tasks/import_binfile.clj @@ -44,12 +44,53 @@ [:map-of ::sm/uuid [:string {:max 250}]]]] [:version {:optional true} [:enum 1 3]]]) +(def schema:candidate + "A library of the instance whose slugified name matches the one the package + used and is therefore offered to the user as a replacement." + [:map + [:id ::sm/uuid] + [:name :string] + [:project-id ::sm/uuid] + [:project-name :string]]) + +(def schema:done-entry + "A library of the package that was linked while it was read." + [:map + [:id ::sm/uuid] + [:name :string] + [:linked-to ::sm/uuid]]) + +(def schema:pending-entry + "A library of the package that needs the user to choose a replacement: + candidates are the libraries of the instance with the same slugified name + (`bfc/find-shared-files-by-slug`)." + [:map + [:id ::sm/uuid] + [:name :string] + [:candidates [:vector schema:candidate]]]) + +(def schema:resolution-entry + "The libraries one imported file uses, split in the ones already linked + and the ones pending resolution." + [:map + [:id ::sm/uuid] + [:name :string] + [:done [:vector schema:done-entry]] + [:pending [:vector schema:pending-entry]]]) + +(def schema:resolution + "The resolution of every imported file, keyed by its new file id. The key + is a uuid, so the map needs this schema to come back typed: the result + column is plain JSON, and without a decoder every key of the map would be + read back as a keyword." + [:map-of ::sm/uuid schema:resolution-entry]) + (def schema:result "What the job stores when the import succeeds: the files it created and how the libraries of the package were resolved." [:map {:title "import-binfile-result" :closed true} [:file-ids [:vector ::sm/uuid]] - [:resolution {:optional true} :map] + [:resolution {:optional true} schema:resolution] [:name [:or [:string {:max 250}] [:map-of ::sm/uuid [:string {:max 250}]]]] [:version [:enum 1 3]]]) @@ -66,7 +107,11 @@ The edition permission is checked again here, and not only when the job was created: it can be revoked while the job waits in its queue. The core runs inside a transaction, so a cancellation detected between units - rolls the whole import back. + rolls the whole import back, and the result is checked against its schema + inside that same transaction: a result the contract does not accept rolls + the import back instead of leaving half of a file imported. The substrate + checks the result again when the job is completed, which is the only net + for a job that does not validate its own result. The package is the input of the job: it is released and its temporary copy deleted once the job is terminal, whatever the outcome. A crash is @@ -92,17 +137,18 @@ ;; the core joins this transaction: it ;; is the one that decides whether the ;; whole import is kept or rolled back - (bfj/import-files tx-cfg context - {:profile-id profile-id - :project-id project-id - :team team - :name (:name params) - :input input - :version (:version params)})))] + (check-result + (assoc (bfj/import-files tx-cfg context + {:profile-id profile-id + :project-id project-id + :team team + :name (:name params) + :input input + :version (:version params)}) + :name (:name params) + :version (:version params)))))] - (check-result (assoc result - :name (:name params) - :version (:version params)))) + result) (finally (fs/delete input) @@ -123,4 +169,9 @@ ::jobs/handler (fn [context params] (execute-import cfg context params)) ::jobs/decoder (sm/decoder schema:params sm/json-transformer) - ::jobs/validator (sm/validator schema:params)}) + ::jobs/validator (sm/validator schema:params) + ;; The resolution is keyed by file id: without the result schema the + ;; keys come back as keywords and the dashboard wizard can no longer + ;; match them with the file ids it holds. The substrate compiles the + ;; decoder from it and checks the result against it before storing it. + ::jobs/result-schema schema:result}) diff --git a/backend/test/backend_tests/binfile_jobs_test.clj b/backend/test/backend_tests/binfile_jobs_test.clj index 7b4619c96c..e03cb79421 100644 --- a/backend/test/backend_tests/binfile_jobs_test.clj +++ b/backend/test/backend_tests/binfile_jobs_test.clj @@ -13,7 +13,6 @@ [app.binfile.v3 :as v3] [app.common.time :as ct] [app.common.uuid :as uuid] - [app.config :as cf] [app.db :as db] [app.jobs :as jobs] [app.jobs.storage :as js] @@ -49,27 +48,6 @@ (assoc ::bfc/input (fixture-path)) (v3/import-files!))) -(defn- make-job! - "A job row as the substrate writes it: running, owned by a profile and - holding a resource when the test needs one." - [profile-id resource-id] - (let [id (uuid/next)] - (th/db-insert! :job {:id id - :name "export-binfile" - :tenant (cf/get :tenant) - :queue "binfile" - :params (db/json {}) - :priority 100 - :max-retries 0 - :retry-num 0 - :status "running" - :profile-id profile-id - :resource-id resource-id - :scheduled-at (ct/now) - :created-at (ct/now) - :modified-at (ct/now)}) - id)) - (defn- job-cfg "The system cfg as the runner hands it to a handler. The job id is NOT bound here: the adapter takes it from the context, because the progress @@ -90,6 +68,46 @@ job-id]) (mapv #(jobs/decode-progress (:payload %))))) +(defn- export-link-later-package + "A package of one file that uses a shared library, exported so the + library is resolved while importing. The library of the package is gone + afterwards and two replacements share its name, so the import cannot + link it by itself and leaves the file pending resolution." + [profile] + (let [library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + output (tmp/tempfile :suffix ".zip")] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + (v3/export-files! (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + (th/create-file* 4 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + {:library-id (:id library) + :output output})) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ADAPTER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -97,7 +115,7 @@ (t/deftest export-through-the-adapter-produces-the-artifact (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile) nil) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) output (tmp/tempfile* :suffix ".penpot")] (bfj/export-files (job-cfg) (job-context job-id) @@ -129,7 +147,8 @@ {:content (sto/content (fixture-path)) :filename "package.penpot" :mtype "application/zip"}) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) context (job-context job-id) result (bfj/import-files (job-cfg) context {:profile-id (:id profile) @@ -148,7 +167,7 @@ (t/deftest the-adapter-composes-a-milestone-per-tap (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile) nil) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) reports (atom [])] ;; the substrate throttles the durable events, so the whole stream of @@ -183,7 +202,7 @@ (t/deftest a-tap-carries-the-outer-counters-in-course (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile) nil) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) taps (atom [])] ;; the taps themselves, before the adapter turns them into events @@ -242,7 +261,8 @@ {:content (sto/content (fixture-path)) :filename "package.penpot" :mtype "application/zip"}) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) context (job-context job-id) reports (atom [])] @@ -278,7 +298,7 @@ (t/deftest a-job-that-is-no-longer-active-stops-the-work (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile) nil) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) context (job-context job-id)] (t/testing "a running job is active" @@ -369,7 +389,7 @@ (t/deftest the-export-handler-runs-through-the-registry (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile) nil) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) result (jobs/invoke (-> th/*system* (assoc ::jobs/name :export-binfile) (assoc ::jobs/params {:file-ids #{file-id} @@ -389,7 +409,8 @@ {:content (sto/content (fixture-path)) :filename "package.penpot" :mtype "application/zip"}) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) result (jobs/invoke (-> th/*system* (assoc ::jobs/name :import-binfile) (assoc ::jobs/params {:project-id (:default-project-id profile) @@ -401,3 +422,122 @@ (t/is (pos? (count (:file-ids result))))) (t/testing "the handler reported progress without knowing the job id" (t/is (pos? (count (progress-events job-id))))))) + +(t/deftest the-import-result-keeps-the-type-of-its-ids + "The resolution of an import is a map keyed by the new file id, and it + crosses a plain JSON column: the result schema the job-def declares is + what keeps the keys and the values as uuids, which is what the dashboard + wizard matches against the ids it holds. Without the decoder the substrate + compiles from that schema, the key comes back as a keyword and the entry as + text, and this test fails." + (let [profile (th/create-profile* 1) + {:keys [library-id output]} (export-link-later-package profile) + + staged (js/put-resource th/*system* (:id profile) + {:content (sto/content output) + :filename "package.penpot" + :mtype "application/zip"}) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) + result (jobs/invoke (-> th/*system* + (assoc ::jobs/name :import-binfile) + (assoc ::jobs/params {:project-id (:default-project-id profile) + :name "imported" + :version 3}) + (assoc ::jobs/context (job-context job-id))))] + + (t/testing "the result of the job is stored as the substrate does" + (t/is (= 1 (jobs/complete th/*system* :job-id job-id :result result)))) + + (let [stored (:result (jobs/get-job th/*system* job-id)) + [file-id file-res] (first (:resolution stored))] + + (t/testing "the resolution comes back keyed by the new file id" + (t/is (= 1 (count (:resolution stored)))) + (t/is (uuid? file-id)) + (t/is (= file-id (:id file-res)))) + + (t/testing "the ids of the entries keep their type" + (t/is (= [file-id] (:file-ids stored))) + (t/is (= [] (:done file-res))) + (t/is (= 1 (count (:pending file-res)))) + + (let [pending (first (:pending file-res))] + (t/is (= library-id (:id pending))) + (t/is (= 2 (count (:candidates pending)))) + (t/is (every? (comp uuid? :id) (:candidates pending))) + (t/is (every? (comp uuid? :project-id) (:candidates pending)))))))) + +(t/deftest a-package-without-external-libraries-reads-with-an-empty-resolution + (let [profile (th/create-profile* 1) + staged (js/put-resource th/*system* (:id profile) + {:content (sto/content (fixture-path)) + :filename "package.penpot" + :mtype "application/zip"}) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) + result (jobs/invoke (-> th/*system* + (assoc ::jobs/name :import-binfile) + (assoc ::jobs/params {:project-id (:default-project-id profile) + :name "imported" + :version 3}) + (assoc ::jobs/context (job-context job-id))))] + + (t/is (= 1 (jobs/complete th/*system* :job-id job-id :result result))) + + (let [stored (:result (jobs/get-job th/*system* job-id))] + + (t/testing "a package without external libraries resolves nothing" + (t/is (= {} (:resolution stored)))) + + (t/testing "and the file ids come back typed" + (t/is (= 1 (count (:file-ids stored)))) + (t/is (every? uuid? (:file-ids stored))))))) + +(t/deftest a-version-1-result-reads-without-a-resolution + "A version 1 import answers with the file ids and no resolution: the read + path decodes it with the schema of the job-def, which declares the + resolution as optional. Without that schema the file ids come back as text." + (let [profile (th/create-profile* 1) + file-id (uuid/next) + job-id (th/create-job* :import-binfile {:profile-id (:id profile)})] + + (t/is (= 1 (jobs/complete th/*system* :job-id job-id + :result {:file-ids [file-id] + :name "imported-v1" + :version 1}))) + + (let [stored (:result (jobs/get-job th/*system* job-id))] + + (t/testing "there is no resolution to decode" + (t/is (nil? (:resolution stored)))) + + (t/testing "and the file ids come back typed" + (t/is (= [file-id] (:file-ids stored))) + (t/is (= 1 (:version stored))))))) + +(t/deftest a-result-of-a-retired-job-def-reads-as-plain-json + "A registry that no longer knows the kind of the row answers with the JSON + the column holds, keywordized keys and uuid as text, instead of raising." + (let [profile (th/create-profile* 1) + file-id (uuid/next) + job-id (th/create-job* :import-binfile {:profile-id (:id profile)}) + key (keyword (str file-id))] + + (t/is (= 1 (jobs/complete th/*system* :job-id job-id + :result {:file-ids [file-id] + :resolution {file-id {:id file-id + :name "card.penpot" + :done [] + :pending []}} + :name "imported" + :version 3}))) + + (let [stored (:result (jobs/get-job (assoc th/*system* ::jobs/defs {}) job-id))] + + (t/testing "the resolution comes back as it is on disk" + (t/is (= [key] (keys (:resolution stored)))) + (t/is (= (str file-id) (get-in stored [:resolution key :id])))) + + (t/testing "and so do the file ids" + (t/is (= [(str file-id)] (:file-ids stored))))))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 24759bae21..bbcc71173c 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -252,6 +252,30 @@ :features features} params))))))) +(defn create-job* + "A job row in the state a worker finds it: running, in the `binfile` queue, + owned by `profile-id` and holding `resource-id` when it consumes one. + `job-name` is the kind of the job (a keyword like the `::jobs/name` a caller + would pass, or the text the row holds) and it is what resolves the job-def + of the row when its result is read or checked." + [job-name {:keys [profile-id resource-id]}] + (let [id (uuid/next)] + (db/insert! *pool* :job {:id id + :name (name job-name) + :tenant (cf/get :tenant) + :queue "binfile" + :params (db/json {}) + :priority 100 + :max-retries 0 + :retry-num 0 + :status "running" + :profile-id profile-id + :resource-id resource-id + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + id)) + (defn mark-file-deleted* ([params] (mark-file-deleted* *system* params)) diff --git a/backend/test/backend_tests/jobs_test.clj b/backend/test/backend_tests/jobs_test.clj index 5dee48bbb3..15c7e6d83b 100644 --- a/backend/test/backend_tests/jobs_test.clj +++ b/backend/test/backend_tests/jobs_test.clj @@ -65,10 +65,31 @@ ::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer) ::jobs/validator (sm/validator schema:echo-params)}) +(def schema:typed-result + "A result with a uuid keyed map, the shape that needs a declared schema + to survive the plain JSON round trip of the `job.result` column." + [:map + [:file-ids [:vector ::sm/uuid]] + [:resolution [:map-of ::sm/uuid [:map [:id ::sm/uuid]]]]]) + +(defmethod ig/init-key ::typed-job + [_ cfg] + {::jobs/name :typed + ::jobs/schema schema:echo-params + ::jobs/handler (fn [_context params] + (echo-handler cfg params)) + ::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer) + ::jobs/validator (sm/validator schema:echo-params) + ;; the substrate compiles the decoder from this schema and checks the + ;; result against it before storing it + ::jobs/result-schema schema:typed-result}) + (defn- get-job-defs [] - (-> {::jobs/defs {:echo (ig/ref ::echo-job)} - ::echo-job {::db/pool th/*pool*}} + (-> {::jobs/defs {:echo (ig/ref ::echo-job) + :typed (ig/ref ::typed-job)} + ::echo-job {::db/pool th/*pool*} + ::typed-job {::db/pool th/*pool*}} (ig/expand) (ig/init) (get ::jobs/defs))) @@ -1188,11 +1209,12 @@ (defn- mk-running "A job already claimed by a worker, ready for a terminal transition." - [cfg] - (let [job-id (jobs/submit cfg {::jobs/name :echo - ::jobs/params (make-params)})] - (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) - job-id)) + ([cfg] (mk-running cfg :echo)) + ([cfg name] + (let [job-id (jobs/submit cfg {::jobs/name name + ::jobs/params (make-params)})] + (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) + job-id))) (t/deftest lifecycle-writers-require-metrics ;; Every one of the six writers must check the metrics instance before @@ -1375,6 +1397,99 @@ (t/is (= 0 (jobs/cancel cfg job-id))) (t/is (= ["cancelled"] (get-outcomes job-id)))))) +(t/deftest get-job-decodes-the-result-with-the-schema-of-the-def + "A result with a declared schema comes back typed. Without it, the plain + JSON of the column keywordizes every key and leaves every uuid as text; + without the substrate compiling the decoder from that schema, the key of + the uuid map comes back as a keyword and this test fails." + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg :typed) + file-id (uuid/next) + lib-id (uuid/next)] + + (t/is (= 1 (jobs/complete cfg :job-id job-id + :result {:file-ids [lib-id] + :resolution {file-id {:id file-id}}}))) + + (let [result (:result (jobs/get-job cfg job-id))] + (t/testing "the uuid keyed map keeps the type of its key and values" + (t/is (= [file-id] (keys (:resolution result)))) + (t/is (uuid? (:id (get (:resolution result) file-id))))) + + (t/testing "the vector of uuids keeps the type of its elements" + (t/is (= [lib-id] (:file-ids result))))))) + +(t/deftest get-job-leaves-a-result-without-a-schema-untouched + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg) + file-id (uuid/next)] + + (t/is (= 1 (jobs/complete cfg :job-id job-id + :result {:resolution {file-id {:id file-id}}}))) + + (t/testing "the raw JSON shape survives: keywordized keys, uuid as text" + (let [result (:result (jobs/get-job cfg job-id)) + key (keyword (str file-id))] + (t/is (= [key] (keys (:resolution result)))) + (t/is (= (str file-id) (get-in result [:resolution key :id]))))))) + +(t/deftest get-job-reads-a-row-whose-job-def-is-gone + (let [job-id (mk-running (make-cfg (get-job-defs))) + ;; a registry that no longer knows the kind of the row (a retired + ;; job-def): reading its result must not raise + row (jobs/get-job (make-cfg {}) job-id)] + (t/is (some? row)) + (t/is (= "echo" (:name row))))) + +(t/deftest complete-refuses-a-result-that-does-not-conform + "The schema a job-def declares is checked before the result is stored: the + row keeps no result and the job is not completed." + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg :typed)] + + (t/is (thrown-with-msg? Exception #"invalid result of a job" + (jobs/complete cfg :job-id job-id + :result {:resolution "not-a-map"}))) + + (t/testing "the row was not completed and holds no result" + (let [row (jobs/get-job cfg job-id)] + (t/is (= "running" (:status row))) + (t/is (nil? (:result row))))) + + (t/testing "a job that is no longer completable is skipped without checking" + (th/db-update! :job {:status "aborted"} {:id job-id}) + (t/is (zero? (jobs/complete cfg :job-id job-id + :result {:resolution "not-a-map"})))))) + +(t/deftest a-stored-result-that-does-not-conform-is-read-decode-only + "A row written by another version may hold a result the schema of today no + longer accepts: reading it decodes and never raises." + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg :typed)] + + (th/db-update! :job + {:status "completed" + :result (db/json {:resolution "not-a-map"})} + {:id job-id}) + + (let [row (jobs/get-job cfg job-id)] + (t/is (= "completed" (:status row))) + (t/is (= {:resolution "not-a-map"} (:result row)))))) + +(t/deftest decode-result-accepts-a-pgobject + (let [job-def (jobs/get-job-def (get (make-cfg (get-job-defs)) ::jobs/defs) :typed) + file-id (uuid/next)] + + (t/testing "a raw column value is decoded with the schema of the def" + (let [decoded (jobs/decode-result job-def + (db/json {:file-ids [file-id] + :resolution {file-id {:id file-id}}}))] + (t/is (= [file-id] (:file-ids decoded))) + (t/is (= [file-id] (keys (:resolution decoded)))))) + + (t/testing "no result answers nil" + (t/is (nil? (jobs/decode-result job-def nil)))))) + (t/deftest aborted-job-is-terminal-for-all-writers "An aborted job (system-side terminal set by the orphan sweep) cannot be moved by any lifecycle writer: first-terminal-wins, with no event and no diff --git a/backend/test/backend_tests/tasks/export_binfile_test.clj b/backend/test/backend_tests/tasks/export_binfile_test.clj index 80fcdedd36..40a67a549a 100644 --- a/backend/test/backend_tests/tasks/export_binfile_test.clj +++ b/backend/test/backend_tests/tasks/export_binfile_test.clj @@ -11,10 +11,6 @@ [app.binfile.common :as bfc] [app.binfile.v3 :as v3] [app.common.exceptions :as ex] - [app.common.time :as ct] - [app.common.uuid :as uuid] - [app.config :as cf] - [app.db :as db] [app.jobs :as jobs] [app.storage :as sto] [app.storage.tmp :as tmp] @@ -46,25 +42,6 @@ (assoc ::bfc/input (-> fixture io/resource jio/file)) (v3/import-files!))) -(defn- make-job! - "A job row as the substrate writes it: running and owned by a profile." - [profile-id] - (let [id (uuid/next)] - (th/db-insert! :job {:id id - :name "export-binfile" - :tenant (cf/get :tenant) - :queue "binfile" - :params (db/json {}) - :priority 100 - :max-retries 0 - :retry-num 0 - :status "running" - :profile-id profile-id - :scheduled-at (ct/now) - :created-at (ct/now) - :modified-at (ct/now)}) - id)) - (defn- run-export "Run the export handler of a job, as the runner does." [job-id params] @@ -99,7 +76,7 @@ (t/deftest the-export-handler-stores-the-artifact-of-its-job (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile)) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) result (run-export job-id {:file-ids #{file-id} :export-type :detach-libraries}) object (sto/get-object (get-storage) (:resource-id result))] @@ -136,7 +113,7 @@ (let [owner (th/create-profile* 1) other (th/create-profile* 2) file-id (first (:file-ids (import-fixture! owner))) - job-id (make-job! (:id other)) + job-id (th/create-job* :export-binfile {:profile-id (:id other)}) before (count-storage-objects)] (t/testing "a job whose owner cannot read a file does not export it" @@ -154,7 +131,7 @@ (t/deftest the-export-handler-leaves-nothing-when-the-storage-fails (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile)) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) before (count-storage-objects)] (with-redefs [sto/put-object! (fn [& _] @@ -177,7 +154,7 @@ (t/deftest the-export-handler-leaves-nothing-behind-when-the-job-is-cancelled (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile)) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) before (count-storage-objects)] (th/db-update! :job {:status "cancelled"} {:id job-id}) diff --git a/backend/test/backend_tests/tasks/import_binfile_test.clj b/backend/test/backend_tests/tasks/import_binfile_test.clj index e2c619177f..eadc44104f 100644 --- a/backend/test/backend_tests/tasks/import_binfile_test.clj +++ b/backend/test/backend_tests/tasks/import_binfile_test.clj @@ -9,11 +9,10 @@ it leaves behind when it fails." (:require [app.binfile.common :as bfc] + [app.binfile.jobs :as bfj] [app.binfile.v1 :as v1] [app.binfile.v3 :as v3] - [app.common.time :as ct] [app.common.uuid :as uuid] - [app.config :as cf] [app.db :as db] [app.jobs :as jobs] [app.jobs.storage :as js] @@ -65,27 +64,6 @@ :filename "package.penpot" :mtype "application/zip"})) -(defn- make-job! - "A job row as the substrate writes it: running, owned by a profile and - holding the package it consumes." - [profile-id resource-id] - (let [id (uuid/next)] - (th/db-insert! :job {:id id - :name "import-binfile" - :tenant (cf/get :tenant) - :queue "binfile" - :params (db/json {}) - :priority 100 - :max-retries 0 - :retry-num 0 - :status "running" - :profile-id profile-id - :resource-id resource-id - :scheduled-at (ct/now) - :created-at (ct/now) - :modified-at (ct/now)}) - id)) - (defn- run-import "Run the import handler of a job, as the runner does." [job-id params] @@ -119,7 +97,8 @@ (t/deftest the-import-handler-imports-the-package-of-its-job (let [profile (th/create-profile* 1) staged (stage-package! profile (fixture-path)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) result (run-import job-id {:project-id (:default-project-id profile) :name "imported" :version 3})] @@ -142,7 +121,8 @@ (let [profile (th/create-profile* 1) project-id (:default-project-id profile) staged (stage-package! profile (fixture-path)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) before (project-modified-at project-id)] (run-import job-id {:project-id project-id @@ -157,7 +137,8 @@ (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) staged (stage-package! profile (export-to-v1! file-id)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) result (run-import job-id {:project-id (:default-project-id profile) :name "imported-v1" :version 1})] @@ -171,7 +152,8 @@ staged (stage-package! other (fixture-path)) ;; the package belongs to `other`, but the destination project ;; belongs to `owner`: an import there is not allowed - job-id (make-job! (:id other) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id other) + :resource-id (:resource-id staged)}) before (project-files (:default-project-id owner))] (t/testing "a job that cannot edit the destination does not import it" @@ -189,7 +171,8 @@ (t/deftest a-job-that-was-already-cancelled-never-imports (let [profile (th/create-profile* 1) staged (stage-package! profile (fixture-path)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) before (project-files (:default-project-id profile))] (th/db-update! :job {:status "cancelled"} {:id job-id}) @@ -207,7 +190,8 @@ (t/deftest an-import-cancelled-while-it-runs-leaves-nothing-behind (let [profile (th/create-profile* 1) staged (stage-package! profile (fixture-path)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) before (project-files (:default-project-id profile))] ;; the user cancels while the import is running: the first progress @@ -230,7 +214,8 @@ (t/deftest a-package-that-is-not-a-penpot-file-fails-without-writing (let [profile (th/create-profile* 1) staged (stage-package! profile (fixture-path)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) before (project-files (:default-project-id profile))] ;; the bytes of the resource are replaced by something that is not a @@ -252,3 +237,32 @@ (t/testing "and the broken package is released too" (t/is (some? (:deleted-at (input-row (:resource-id broken))))))))) + +(t/deftest a-result-that-does-not-conform-leaves-nothing-behind + (let [profile (th/create-profile* 1) + project-id (:default-project-id profile) + staged (stage-package! profile (fixture-path)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) + before (project-files project-id)] + + ;; the core does its work inside the transaction and answers with a result + ;; the schema of the task does not accept: the check runs before the + ;; transaction commits, so the work is rolled back with it + (with-redefs [bfj/import-files + (fn [tx-cfg _context _params] + (th/create-file* tx-cfg 7 {:profile-id (:id profile) + :project-id project-id + :is-shared false + :name "half imported"}) + {:file-ids [(uuid/next)] + :resolution "not-a-map"})] + + (t/testing "the handler refuses the result" + (t/is (thrown-with-msg? Exception #"invalid result of an import job" + (run-import job-id {:project-id project-id + :name "imported" + :version 3})))) + + (t/testing "and the import is rolled back: the destination keeps no files" + (t/is (= before (project-files project-id))))))) diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index e03d67dbe9..347364ff67 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -156,6 +156,20 @@ 0 resolution)) +(defn- pending-library-links + "The library links the wizard must create for the files of a resolution: + one per pending entry the user chose a candidate for. The file id comes + from the entry and never from the key of the map: what a key holds depends + on the result schema of the job-def that wrote it, while the wizard indexes + its selection by the id of the entry." + [resolution selection] + (->> (vals resolution) + (mapcat (fn [{:keys [id pending]}] + (keep (fn [entry] + (when-let [library-id (get-in selection [id (:id entry)])] + {:file-id id :library-id library-id})) + pending))))) + (defn- analyze-entries [state entries] (->> (imp/analyze entries) @@ -866,22 +880,17 @@ (mf/deps resolution selection on-finish-import) (fn [event] (dom/prevent-default event) - (let [slc selection] - ;; For each file with pending candidates, link it to the selected libraries - (->> (rx/from (seq resolution)) - (rx/merge-map - (fn [[file-id resolution-file]] - (->> (rx/from (:pending resolution-file)) - (rx/merge-map - (fn [{:keys [id]}] - (when-let [selected-lib (get-in slc [file-id id])] - (link-files-to-library! [file-id] selected-lib))))))) - (rx/subs! (constantly nil) - (constantly nil) - (fn [] - (st/emit! (modal/hide)) - (when (fn? on-finish-import) - (on-finish-import)))))))) + ;; For each file with pending candidates, link it to the selected libraries + (->> (rx/from (pending-library-links resolution selection)) + (rx/merge-map + (fn [{:keys [file-id library-id]}] + (link-files-to-library! [file-id] library-id))) + (rx/subs! (constantly nil) + (constantly nil) + (fn [] + (st/emit! (modal/hide)) + (when (fn? on-finish-import) + (on-finish-import))))))) on-wizard-next (mf/use-fn diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index f742f81093..93bae2969f 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -115,6 +115,7 @@ [frontend-tests.ui.comment-input-ime-test] [frontend-tests.ui.comments-clustering-test] [frontend-tests.ui.comments-position-modifier-test] + [frontend-tests.ui.dashboard-import-linking-test] [frontend-tests.ui.drawarea-test] [frontend-tests.ui.ds-controls-numeric-input-test] [frontend-tests.ui.error-boundary-test] @@ -271,6 +272,7 @@ 'frontend-tests.ui.comment-input-ime-test 'frontend-tests.ui.comments-clustering-test 'frontend-tests.ui.comments-position-modifier-test + 'frontend-tests.ui.dashboard-import-linking-test 'frontend-tests.ui.drawarea-test 'frontend-tests.ui.ds-controls-numeric-input-test 'frontend-tests.ui.error-boundary-test diff --git a/frontend/test/frontend_tests/ui/dashboard_import_linking_test.cljs b/frontend/test/frontend_tests/ui/dashboard_import_linking_test.cljs new file mode 100644 index 0000000000..84d8585719 --- /dev/null +++ b/frontend/test/frontend_tests/ui/dashboard_import_linking_test.cljs @@ -0,0 +1,128 @@ +;; 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.dashboard-import-linking-test + "The links the import wizard creates for a file of the package whose + library the auto-link could not resolve. The resolution map comes from the + job result, so the type of its keys is not something the wizard can rely + on: the file id of a link must come from the entry and never from the key + of the map." + (:require + [app.common.uuid :as uuid] + [app.main.ui.dashboard.import :as import] + [cljs.test :as t :include-macros true])) + +(def ^:private pending-library-links + @#'import/pending-library-links) + +(defn- pending-entry + "One library of the package the user has to resolve, with a candidate." + [library-id] + {:id library-id + :name "Tokens" + :candidates [{:id (uuid/next) + :name "Tokens" + :project-id (uuid/next) + :project-name "Design system"}]}) + +(defn- file-resolution + "One file of a resolution, with a single library pending resolution." + [file-id library-id] + {:id file-id + :name "card.penpot" + :done [] + :pending [(pending-entry library-id)]}) + +(t/deftest the-file-id-of-a-link-comes-from-the-entry-and-not-from-the-key + (let [file-id (uuid/next) + old-lib (uuid/next) + new-lib (uuid/next) + ;; the shape a result without a declared schema has: the key of the + ;; map is a keyword and the entry carries the same id as text + resolution {(keyword (str file-id)) (file-resolution (str file-id) (str old-lib))} + selection {(str file-id) {(str old-lib) (str new-lib)}}] + + (t/is (= [{:file-id (str file-id) :library-id (str new-lib)}] + (pending-library-links resolution selection))))) + +(t/deftest the-links-are-the-same-with-a-typed-resolution + (let [file-id (uuid/next) + old-lib (uuid/next) + new-lib (uuid/next) + resolution {file-id (file-resolution file-id old-lib)} + selection {file-id {old-lib new-lib}}] + + (t/is (= [{:file-id file-id :library-id new-lib}] + (pending-library-links resolution selection))))) + +(t/deftest a-file-with-several-pending-libraries-creates-one-link-each + (let [file-id (uuid/next) + lib-a (uuid/next) + lib-b (uuid/next) + new-a (uuid/next) + new-b (uuid/next) + resolution {file-id {:id file-id + :name "card.penpot" + :done [] + :pending [(pending-entry lib-a) + (pending-entry lib-b)]}} + selection {file-id {lib-a new-a + lib-b new-b}}] + + (t/testing "both entries the user resolved become a link" + (t/is (= #{{:file-id file-id :library-id new-a} + {:file-id file-id :library-id new-b}} + (set (pending-library-links resolution selection))))))) + +(t/deftest a-partial-selection-creates-only-the-chosen-link + (let [file-id (uuid/next) + lib-a (uuid/next) + lib-b (uuid/next) + new-a (uuid/next) + resolution {file-id {:id file-id + :name "card.penpot" + :done [] + :pending [(pending-entry lib-a) + (pending-entry lib-b)]}} + selection {file-id {lib-a new-a}}] + + (t/testing "the entry with no candidate chosen emits nothing" + (t/is (= [{:file-id file-id :library-id new-a}] + (pending-library-links resolution selection)))))) + +(t/deftest a-done-entry-creates-no-link + (let [file-id (uuid/next) + library (uuid/next)] + + (t/testing "a file whose libraries were auto-linked has nothing to emit" + (t/is (= [] (pending-library-links + {file-id {:id file-id + :name "card.penpot" + :done [{:id library + :name "Tokens" + :linked-to library}] + :pending []}} + {file-id {library (uuid/next)}})))))) + +(t/deftest pending-entries-without-a-selection-create-no-link + (let [file-id (uuid/next) + old-lib (uuid/next)] + + (t/testing "an entry the user skipped creates nothing" + (t/is (= [] (pending-library-links + {file-id (file-resolution file-id old-lib)} + {file-id {}})))) + + (t/testing "a file with nothing pending creates nothing" + (t/is (= [] (pending-library-links + {file-id {:id file-id + :name "card.penpot" + :done [] + :pending []}} + {file-id {old-lib (uuid/next)}})))) + + (t/testing "an empty resolution creates nothing" + (t/is (= [] (pending-library-links {} {})))))) From 414553278ae36b4e213cbd56a56e4e09a5f441eb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 19:50:06 +0200 Subject: [PATCH 026/167] :sparkles: Count the application logs as a metric, by logger and level (#12313) * :sparkles: Count the application logs as a metric, by logger and level The error panels had no metric to read: they counted log lines in Loki, because the backend published nothing about the errors of the application (the only thing alive in Prometheus was the http listener error count, which measures another thing). Now there is `penpot_log_events_total{logger, level}`. The counter comes from a new component, `app.loggers.metrics/reporter`, which hooks a watch on the log record like the database and mattermost reporters do, but without a thread or a channel: the increment is in memory, so it runs in the watcher itself. Only `warn`, `error` and `fatal` are counted, and only the records that go through `app.common.logging`: the lines a library writes straight to log4j2 are not counted (one in a thousand today). The body of the watcher is wrapped in a `try/catch` that swallows the error on purpose: the watcher fires from the `swap!` of the log record, inside the logging path, so an exception there would reach whoever was logging and would take the logging executor down with it. A test pins that a failing publication does not propagate. The suite removes the reporter (it would count the logs of the tests and the recording of their `mtx/run!`), so a test of its own pins the wiring. The memories carry the counter, its two limits and why the `try/catch` is not noise; the grafana one also brings the notes of this afternoon about the service account, the dashboard search and Cloudflare, which were waiting for a commit. AI-assisted-by: deepseek-v4.1-flash * :books: Address the review findings on the log-events metric The review of the counter found that the reason written for the `try/catch` was not the real one, so the memory and the comments say now what happens if it is removed: with `::l/sync?` the exception reaches whoever was logging, and on the async path it dies in the uncaught handler of the logging executor -- outside the application's logging -- where the pool replaces the worker, so logging continues and that line is lost. They also say that a publication failing every time is silent: the counter stays flat and nothing is written about it. The rest are small things the review asked for: the test of the `fatal` level (it was in the counted set and nothing verified it), the HELP of the metric naming the three levels, the require of the test without the unused alias that clj-kondo flagged, and the note that the `level` is lowercase here and uppercase in Loki. The grafana memory loses what was said twice: the two pairs of bullets about reading the API (the service account 403s and the search that lies) are merged, the CLIMIT notes are one bullet instead of two, and the four bullets about editing dashboards are grouped in their own section. Its host name is gone as well, which the first line of that file forbids. AI-assisted-by: deepseek-v4.1-flash --- .serena/memories/backend/subtleties.md | 4 + .serena/memories/prod-infra/grafana.md | 24 +++- backend/src/app/loggers/metrics.clj | 46 ++++++ backend/src/app/main.clj | 11 +- backend/test/backend_tests/helpers.clj | 1 + .../backend_tests/loggers_metrics_test.clj | 135 ++++++++++++++++++ 6 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 backend/src/app/loggers/metrics.clj create mode 100644 backend/test/backend_tests/loggers_metrics_test.clj diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index 241d1e1567..ed7f390af6 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -57,6 +57,10 @@ - The xnio worker MXBean can return transient `-1` (e.g. on the queue or the pool size); negative samples are discarded (gauge keeps its previous value). Undertow exposes absolute request/error totals, so the sampler keeps a watermark atom and publishes deltas; a counter reset (decreasing totals) skips the negative delta and moves the watermark forward. - The `process_*` families (`process_open_fds`, `process_max_fds`, `process_cpu_seconds_total`, …) come from the prometheus client `StandardExports`, registered by `app.metrics/create-registry`. They read the OS MXBean reflectively and need the `jdk.management` module: on a pruned `jlink` JRE the MXBean is `sun.management.BaseOperatingSystemImpl`, the getters throw `NoSuchMethodException` and `StandardExports#collect` swallows it, so those families silently vanish from `/metrics`. `docker/images/Dockerfile.backend` keeps `jdk.management` in the `--add-modules` list, and `backend-tests.metrics-test` pins the contract. +## Log-derived metrics + +- `app.loggers.metrics/reporter` counts the log records the application emits and publishes them as `penpot_log_events_total{logger, level}` (counter), so the error panels do not have to count text in Loki. It is a component that hooks `add-watch` on `app.common.logging/log-record`, like `app.loggers.database`, but with no thread and no channel: the increment is in memory, so it goes straight in the watcher. Only `warn`, `error` and `fatal` are counted, and only the records that go through `app.common.logging` (the application's own logs; a library writing straight to log4j2 is not counted, and today that is one line in a thousand). The body of the watcher is wrapped in a `try/catch` that swallows the error on purpose: the watcher fires from the `swap!` of the log record, inside the logging path. With `::l/sync?` (`app.binfile.v1`, `app.msgbus`) an exception there would reach whoever was logging; on the async path it escapes to the logging executor worker, whose trace goes to the thread's uncaught handler -- outside the application's logging -- and the pool replaces the dead worker, so logging continues and that line is lost. The catch also makes a publication that fails every time invisible: `penpot_log_events_total` stays flat and nothing is written about it. The published `level` values are lowercase (`warn`), unlike the `level` label of the Loki logs (`ERROR`), so a panel that moves from one source to the other has to change it. Cardinality is `logger` (fixed names taken from the code) times `level`; a logger built from a dynamic value would break that. + ## Storage and media - Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`. diff --git a/.serena/memories/prod-infra/grafana.md b/.serena/memories/prod-infra/grafana.md index fbc40ec6e5..10af277db2 100644 --- a/.serena/memories/prod-infra/grafana.md +++ b/.serena/memories/prod-infra/grafana.md @@ -6,15 +6,15 @@ Keep this memory free of internal names: no host names, no deployed versions, no ## Datasources -- Metrics — **Prometheus**, uid `xkRmyFBGk`. PromQL. Metric families: `penpot_*` (RPC, task timings, CLIMIT, HTTP, websocket, storage), `hikaricp_*`, `jvm_*` and `process_*` on the backend (`job="app"`); `pg_*` from the Postgres exporter (`job="postgres"`); `redis_*` from the Redis exporter (`job="redis"`); `node_*` and `container_*` from node-exporter and cAdvisor. Backend histograms are in **milliseconds**. -- Logs — **Loki**, uid `h3CGKhEMk`. LogQL, and filter by the structured metadata `logger`/`level` (`{job="penpot"} | logger="..." | level="ERROR"`). Signals that only exist as logs: the profile behind a CLIMIT queue (`logger="app.rpc.climit"`, whose `id` carries the key). CLIMIT rejections have their own counters (`penpot_rpc_climit_rejected_total`, by `name` and `reason`), and their `hint="rejected"` line goes out at WARN only when something stays queued, so counting the lines is not counting the rejections. +- Metrics — **Prometheus**, uid `xkRmyFBGk`. PromQL. Metric families: `penpot_*` (RPC, task timings, CLIMIT, HTTP, websocket, storage, and the application log events of the backend), `hikaricp_*`, `jvm_*` and `process_*` on the backend (`job="app"`); `pg_*` from the Postgres exporter (`job="postgres"`); `redis_*` from the Redis exporter (`job="redis"`); `node_*` and `container_*` from node-exporter and cAdvisor. Backend histograms are in **milliseconds**. +- Logs — **Loki**, uid `h3CGKhEMk`. LogQL, and filter by the structured metadata `logger`/`level` (`{job="penpot"} | logger="..." | level="ERROR"`). Two things to remember when counting from it: the CLIMIT rejections have their own counters (`penpot_rpc_climit_rejected_total`, by `name` and `reason`) and their `hint="rejected"` line goes out at WARN only when something is left queued behind the call (a lone caller that times out is written at TRACE), so counting lines is not counting rejections -- the same goes for `hint="enqueued"` and `hint="acquired"`; and the only signal that exists nowhere else is the profile behind a queue (`logger="app.rpc.climit"`, whose `id` carries the key), chased there on demand. - SQL — **Postgres**, uid `y8ea9tfMk`. Panels need `rawQuery: true`, `editorMode: "code"` and `format: "table"`. The `task` table is the only truth about the queue depth. It is a **read-only replica**: it rejects any write, `CREATE TEMP TABLE` included, so queries through it must be plain `SELECT`s. It also cuts long-running queries (the MCP path has a short timeout), so bound them (e.g. a list of ids); an empty answer there is not proof that there is no data. - **Postgres (telemetry)**, uid `9YAUuM_Gz`. - `$__rate_interval` and `$__interval` are substituted by Grafana; use them instead of fixed windows in graphs. ## Main dashboard -- `Production status`, uid `penpot-production-status`. Sections: general state and deploy, RPC and CLIMIT, PostgreSQL and pool, Redis/msgbus, tasks, JVM/process. Variables `$env` and `$host` (custom), no auto refresh. +- `Production status`, uid `penpot-production-status`. Sections: general state and deploy, `RPC y CLIMIT`, PostgreSQL and pool, Redis/msgbus, tasks, JVM/process. Variables `$env` and `$host` (custom), no auto refresh. Every section opens collapsed except `RPC y CLIMIT`, which is the one read on a normal day. - Per-host investigation dashboards are separate, and the alert rules deeplink into them. ## Conventions @@ -26,6 +26,20 @@ Keep this memory free of internal names: no host names, no deployed versions, no - Cron "did it run" comes from the task metrics, not from the cron registry table (it keeps rows from old versions). - Dashboard thresholds must match the alert rule thresholds. +## Editing dashboards through the API + +- Saving through the API normalizes the dashboard: submit the native form, rows with `panels: []` and their panels top-level with contiguous `y`. A row that has to start collapsed (or open) is sent holding its children in `panels` with `collapsed` set, and the top-level `y` are recomputed by hand: one unit per collapsed row, with the expanded block kept contiguous. Grafana accepts it and returns the same shape. +- A `remove` operation on a panel leaves its `gridPos` hole behind: Grafana does not refill the layout (the UI behaves the same), so a neighbouring panel has to be moved by hand when the gap matters. +- Check `list_dashboard_versions` before writing and prefer `operations` patches: the user edits dashboards by hand and a full-JSON overwrite silently reverts that work. + +## Reading the API by hand + +- The MCP authenticates with a service account token (`GRAFANA_SERVICE_ACCOUNT_TOKEN`, set in the environment of the MCP server), and a service account has no user behind it: a 403 is never the token being bad. On `/api/user` and `/api/user/orgs` it is normal; on a single dashboard (`Access denied to this dashboard`, which is not a 404) it means that identity cannot see it, a permission on that dashboard or its folder. Restarting the MCP or rotating the token changes nothing, and `X-Grafana-Org-Id` does not help when the identity belongs to another organization. +- Listing is reliable, filtering is not: a dashboard search with a text query answers empty in this instance (through the tool and through `/api/search?query=...` alike), and `list_prometheus_metric_names` also lists names that survive only in the retained series of a metric that stopped being published. Use the listing without a query, and `count()` to tell whether a metric is alive today. +- The MCP Prometheus tool can answer empty for every query, `up` included, while the datasource itself is fine: run PromQL by hand with `grafana_api_request` against `/api/datasources/uid//resources/api/v1/query?query=...`. +- `curl` and `python` from the shell cannot reach the Grafana host of the deployment: Cloudflare blocks them (`error code: 1010`, which looks like a 403 and has nothing to do with Grafana). Probe the API through the MCP (`grafana_api_request`), which is what the deployment lets through. +- A Loki query by hand goes through `GET /api/datasources/proxy/uid//loki/api/v1/query_range?query=&start=&end=&step=` (the range comes back as `resultType: matrix`, one series per group). For Loki the `/api/datasources/uid//resources/...` route answers 500, and `/api/ds/query` is not reachable from `grafana_api_request` at all (404), even though that is the path the dashboards use. + ## Gotchas - PromQL literal dots need `\\.`; simpler to use `[.]` or a plain dot. @@ -34,9 +48,5 @@ Keep this memory free of internal names: no host names, no deployed versions, no - Prometheus: `label_replace(, "host", "$1", "instance", "([^.]+)[.][^.]+.*")` plus `legendFormat: "... {{host}}"`. - Loki metric query: `| label_format host="{{ regexReplaceAll "[.][^.]*[.][^.]*[.][^.]*$" .instance "" }}"` plus `legendFormat: "{{host}}"`. Quotes inside the template must be escaped as `\"`, or LogQL closes the string early (`not a valid duration string`). - Tables from a Prometheus raw selector carry a `__name__` column; hide it with `organize`. -- Saving through the API normalizes the dashboard (rows may be flattened, `y` recomputed): submit the native form (rows with `panels: []`, panels top-level, contiguous `y`). - Panel images cannot be rendered (no image renderer installed) and there is no browser here: verify a dashboard by running its queries with the variables substituted. - Signals that exist only as logs are counted with Loki, and the count is then cross-checked against the nginx access log for the same window and the same status code (`job="frontend"`, e.g. a `429`): two independent sources must agree before a number is trusted. -- The app writes its own logs at WARN only when something is left queued behind the call and at TRACE otherwise, so production Loki holds just the under-pressure lines: a count of `hint="enqueued"` or `hint="acquired"` is not the number of calls, and neither is a count of `hint="rejected"` lines, because a rejection that leaves the queue empty (a lone caller that times out) is written at TRACE. The rejection counters are the real number. -- The MCP Prometheus tool can answer empty for every query, `up` included, while the datasource itself is fine: an empty result from it is not proof that a panel has no data, and blaming the datasource for it is wrong. Run PromQL by hand with `grafana_api_request` against `/api/datasources/uid//resources/api/v1/query?query=...` (or `POST /api/ds/query`, the path dashboards use), and check when a series is expected but the tool says nothing. -- **Check `list_dashboard_versions` before writing.** The user edits dashboards by hand and a full-JSON overwrite silently reverts that work; prefer `operations` patches per panel. diff --git a/backend/src/app/loggers/metrics.clj b/backend/src/app/loggers/metrics.clj new file mode 100644 index 0000000000..07c97debd1 --- /dev/null +++ b/backend/src/app/loggers/metrics.clj @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.loggers.metrics + "A reporter that counts the log records of this application, by logger and + level, and publishes them as a counter." + (:require + [app.common.logging :as l] + [app.metrics :as mtx] + [integrant.core :as ig])) + +(def ^:private counted-levels + "The levels worth counting: `info` is emitted often and says nothing about + the health of the application." + #{:warn :error :fatal}) + +(defmethod ig/assert-key ::reporter + [_ params] + (assert (mtx/metrics? (::mtx/metrics params)) "expect valid metrics instance")) + +(defmethod ig/init-key ::reporter + [_ {::mtx/keys [metrics]}] + (add-watch l/log-record ::reporter + (fn [_ _ _ record] + ;; This runs inside the logging path: the watcher fires from + ;; the `swap!` of the log record, so an exception here would + ;; reach whoever was logging on the synchronous path, and on + ;; the async one it would die in the executor's uncaught + ;; handler, taking that log line out of Loki. Swallowed on + ;; purpose either way. + (try + (let [level (::l/level record)] + (when (contains? counted-levels level) + (mtx/run! metrics + :id :log-events + :inc 1 + :labels (into-array String [(::l/logger record) (name level)])))) + (catch Throwable _ nil)))) + {::mtx/metrics metrics}) + +(defmethod ig/halt-key! ::reporter + [_ _] + (remove-watch l/log-record ::reporter)) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 86ce7883be..221c20af3a 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -50,7 +50,13 @@ (:gen-class)) (def default-metrics - {:update-file-changes + {:log-events + {::mdef/name "penpot_log_events_total" + ::mdef/help "Log records at WARN, ERROR and FATAL emitted by the application, by logger and level." + ::mdef/labels ["logger" "level"] + ::mdef/type :counter} + + :update-file-changes {::mdef/name "penpot_rpc_update_file_changes_total" ::mdef/help "A total number of changes submitted to update-file." ::mdef/type :counter} @@ -534,6 +540,9 @@ :app.loggers.database/reporter {::db/pool (ig/ref ::db/pool)} + :app.loggers.metrics/reporter + {::mtx/metrics (ig/ref ::mtx/metrics)} + ::sto/storage {::db/pool (ig/ref ::db/pool) ::sto/backends diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 89c0d33084..2771470506 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -126,6 +126,7 @@ :app.notifications/handler :app.loggers.mattermost/reporter :app.loggers.database/reporter + :app.loggers.metrics/reporter :app.worker/cron :app.worker/dispatcher [:app.main/default :app.worker/runner] diff --git a/backend/test/backend_tests/loggers_metrics_test.clj b/backend/test/backend_tests/loggers_metrics_test.clj new file mode 100644 index 0000000000..984ad3b0f8 --- /dev/null +++ b/backend/test/backend_tests/loggers_metrics_test.clj @@ -0,0 +1,135 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.loggers-metrics-test + "The reporter that turns log records into a counter." + (:require + [app.common.logging :as l] + [app.loggers.metrics] ;; registers the integrant methods of the reporter key + [app.main :as main] + [app.metrics :as mtx] + [clojure.test :as t] + [integrant.core :as ig]) + (:import + io.prometheus.client.CollectorRegistry)) + +(def ^:private logger + "The logger of a record is the namespace of the code that logs, and that is + this namespace." + (str *ns*)) + +(defn- init-metrics + [] + (ig/init-key :app.metrics/metrics {:default main/default-metrics})) + +(defn- init-reporter + [metrics] + (ig/init-key :app.loggers.metrics/reporter {::mtx/metrics metrics})) + +(defn- halt-reporter! + [reporter] + (ig/halt-key! :app.loggers.metrics/reporter reporter)) + +(defn- sample + "Value of the counter for one logger and level, read from the registry. Nil + when that combination has never been published." + [metrics logger level] + (.getSampleValue ^CollectorRegistry (mtx/get-registry metrics) + "penpot_log_events_total" + (into-array String ["logger" "level"]) + (into-array String [logger level]))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; COUNTING +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest warn-and-error-are-counted-by-logger-and-level + (let [metrics (init-metrics) + reporter (init-reporter metrics)] + (try + ;; `:sync?` so the record is emitted on this thread and the assertion + ;; does not depend on the logging executor. + (l/warn :hint "una prueba" ::l/sync? true) + (l/error :hint "otra prueba" ::l/sync? true) + + (t/is (= 1.0 (sample metrics logger "warn"))) + (t/is (= 1.0 (sample metrics logger "error"))) + + (l/error :hint "y otra mas" ::l/sync? true) + (t/is (= 2.0 (sample metrics logger "error"))) + + (finally + (halt-reporter! reporter))))) + +(t/deftest info-is-not-counted + (let [metrics (init-metrics) + reporter (init-reporter metrics)] + (try + (l/info :hint "un mensaje normal" ::l/sync? true) + (t/is (nil? (sample metrics logger "info"))) + + (finally + (halt-reporter! reporter))))) + +(t/deftest fatal-is-counted + (let [metrics (init-metrics) + reporter (init-reporter metrics)] + (try + ;; No macro of the logging emits `:fatal`; this is the only way a record + ;; gets that level. + (l/log :fatal :hint "algo muy malo" ::l/sync? true) + (t/is (= 1.0 (sample metrics logger "fatal"))) + + (finally + (halt-reporter! reporter))))) + +(t/deftest halting-the-reporter-stops-counting + (let [metrics (init-metrics) + reporter (init-reporter metrics)] + + (halt-reporter! reporter) + + (l/error :hint "despues de parar" ::l/sync? true) + (t/is (nil? (sample metrics logger "error"))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SAFETY +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest a-failing-publication-does-not-break-the-logging + ;; The watcher fires from the `swap!` of the log record, inside the logging + ;; path. With `::l/sync?` an exception here would reach whoever was logging; + ;; on the async path it would end in the executor's uncaught handler, which + ;; is outside the application's logging, taking that log line out of Loki and + ;; out of this counter. + (let [metrics (init-metrics) + reporter (init-reporter metrics)] + (try + (with-redefs [mtx/run! (fn [& _] (throw (ex-info "publicando mal" {})))] + (t/is (nil? (l/error :hint "no debe propagar" ::l/sync? true)))) + + (finally + (halt-reporter! reporter))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; DEFINITION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest the-log-events-metric-is-defined + (let [metrics (init-metrics)] + (t/is (= ["logger" "level"] + (:app.metrics.definition/labels (mtx/get-collector metrics :log-events)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; WIRING +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest the-system-declares-the-reporter + ;; The reporter hooks itself to the logging, so declaring it in the system is + ;; all the wiring there is. The tests of the suite remove it, so this is the + ;; only place that pins it. + (t/is (= {::mtx/metrics (ig/ref ::mtx/metrics)} + (:app.loggers.metrics/reporter main/system-config)))) From 47f0e1574434c4ab04b1f1a2dcb0d8e94126d9a1 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 20:05:58 +0200 Subject: [PATCH 027/167] :recycle: Use DS icon buttons in the align options (#12366) * :recycle: Use DS icon buttons in the align options Replace the eight raw align and distribute buttons with icon-button* and DS icons. The title text moves to aria-label, so the DS tooltip shows the same text and shortcut. Disabled distribute buttons now use the DS disabled color. Add a visual test that selects two shapes so the align options show. AI-assisted-by: claude-opus-5-5 * :recycle: Remove deprecated button styles from the align options The align buttons are DS icon buttons now, so the .align-button rules and their placeholders go away. Move the row height to a DS size token and drop the refactor/ import. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 24 ++++ .../sidebar/options/menus/align.cljs | 107 ++++++++---------- .../sidebar/options/menus/align.scss | 35 +----- 3 files changed, 76 insertions(+), 90 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index df766736a0..6604c94ffc 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -84,6 +84,30 @@ test.describe("Design tab", () => { await expect(workspace.page).toHaveScreenshot(); }); + + test("Shows the align options when selecting two shapes", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + await workspace.rectShapeButton.click(); + await workspace.clickWithDragViewportAt(128, 128, 200, 100); + await workspace.clickLeafLayer("Rectangle"); + await page.keyboard.press("Control+D"); + await expect(workspace.layers.getByText("Rectangle")).toHaveCount(2); + await page.keyboard.press("Control+A"); + + await expect( + workspace.rightSidebar.getByRole("button", { name: /^Align left/ }), + ).toBeVisible(); + // The align options are a small part of the sidebar: compare every pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); }); test.describe("Assets tab", () => { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs index 3ae778553f..7897e4c81e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs @@ -11,7 +11,8 @@ [app.main.data.workspace.path :as dwdp] [app.main.data.workspace.shortcuts :as sc] [app.main.store :as st] - [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.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -57,67 +58,59 @@ (when (or path-edit? (not (and disabled-align disabled-distribute))) [:div {:class (stl/css :align-options)} [:div {:class (stl/css :align-group-horizontal)} - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.hleft" (sc/get-tooltip :align-left)) - :data-value "hleft" - :on-click align-objects} - deprecated-icon/align-left] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.hleft" (sc/get-tooltip :align-left)) + :icon i/align-left + :disabled disabled-align + :data-value "hleft" + :on-click align-objects}] - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.hcenter" (sc/get-tooltip :align-hcenter)) - :data-value "hcenter" - :on-click align-objects} - deprecated-icon/align-horizontal-center] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.hcenter" (sc/get-tooltip :align-hcenter)) + :icon i/align-horizontal-center + :disabled disabled-align + :data-value "hcenter" + :on-click align-objects}] - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.hright" (sc/get-tooltip :align-right)) - :data-value "hright" - :on-click align-objects} - deprecated-icon/align-right] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.hright" (sc/get-tooltip :align-right)) + :icon i/align-right + :disabled disabled-align + :data-value "hright" + :on-click align-objects}] - [:button {:class (stl/css-case :align-button true - :disabled disabled-distribute) - :disabled disabled-distribute - :title (tr "workspace.align.hdistribute" (sc/get-tooltip :h-distribute)) - :data-value "horizontal" - :on-click distribute-objects} - deprecated-icon/distribute-horizontally]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.hdistribute" (sc/get-tooltip :h-distribute)) + :icon i/distribute-horizontally + :disabled disabled-distribute + :data-value "horizontal" + :on-click distribute-objects}]] [:div {:class (stl/css :align-group-vertical)} - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.vtop" (sc/get-tooltip :align-top)) - :data-value "vtop" - :on-click align-objects} - deprecated-icon/align-top] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.vtop" (sc/get-tooltip :align-top)) + :icon i/align-top + :disabled disabled-align + :data-value "vtop" + :on-click align-objects}] - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.vcenter" (sc/get-tooltip :align-vcenter)) - :data-value "vcenter" - :on-click align-objects} - deprecated-icon/align-vertical-center] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.vcenter" (sc/get-tooltip :align-vcenter)) + :icon i/align-vertical-center + :disabled disabled-align + :data-value "vcenter" + :on-click align-objects}] - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.vbottom" (sc/get-tooltip :align-bottom)) - :data-value "vbottom" - :on-click align-objects} - deprecated-icon/align-bottom] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.vbottom" (sc/get-tooltip :align-bottom)) + :icon i/align-bottom + :disabled disabled-align + :data-value "vbottom" + :on-click align-objects}] - [:button {:title (tr "workspace.align.vdistribute" (sc/get-tooltip :v-distribute)) - :class (stl/css-case :align-button true - :disabled disabled-distribute) - :disabled disabled-distribute - :data-value "vertical" - :on-click distribute-objects} - deprecated-icon/distribute-vertical-spacing]]]))) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.vdistribute" (sc/get-tooltip :v-distribute)) + :icon i/distribute-vertical-spacing + :disabled disabled-distribute + :data-value "vertical" + :on-click distribute-objects}]]]))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss index 135544aa70..4386af7d6b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss @@ -4,13 +4,13 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_sizes.scss" as *; @use "../../../sidebar/common/sidebar.scss" as sidebar; .align-options { @include sidebar.option-grid-structure; - height: deprecated.$s-32; + block-size: $sz-32; } .align-group-horizontal, @@ -27,34 +27,3 @@ .align-group-vertical { grid-column: 5 / span 4; } - -.align-button { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-32; - padding: 0; - border-radius: deprecated.$br-8; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - - &.disabled { - cursor: default; - - svg { - stroke: var(--button-foreground-color-disabled); - } - - &:hover { - background-color: var(--panel-background-color); - - svg { - stroke: var(--button-foreground-color-disabled); - } - } - } -} From d26a40163786aa64e4af7b31d68bf2620e632dd3 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 20:06:29 +0200 Subject: [PATCH 028/167] :recycle: Use DS buttons for more colors in the selected colors (#12369) * :recycle: Use DS buttons for more colors in the selected colors Replace the three raw "more colors" buttons (library colors, colors and color tokens) with the DS secondary button. Add a visual test that selects a board with more than three colors so the button shows. AI-assisted-by: claude-opus-5-5 * :recycle: Move selected colors styles to DS tokens The "more colors" buttons are DS buttons now, so their placeholder and typography mixin go away; the class only centers the label, since the column stretches the button. Delete the unused .add-fill rule, move spacing to DS tokens and logical properties, and drop the refactor/ import. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 24 ++++++++++++++ .../options/menus/color_selection.cljs | 16 ++++++---- .../options/menus/color_selection.scss | 32 ++++++------------- 3 files changed, 44 insertions(+), 28 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 6604c94ffc..a99ea0b020 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -188,3 +188,27 @@ test.describe("Palette", () => { ).toBeVisible(); }); }); + +test.describe("Selected colors", () => { + test("Shows the more colors button", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickToggableLayer("Board"); + await page.keyboard.press("Control+A"); + + await expect( + workspace.rightSidebar.getByRole("button", { name: "More colors" }), + ).toBeVisible(); + // The button is a small part of the sidebar: compare every pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index 5db6007cf7..d72f730775 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -14,6 +14,7 @@ [app.main.data.workspace.tokens.application :as dwta] [app.main.store :as st] [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row*]] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -247,8 +248,9 @@ :origin :color-selection :on-close on-close}])) (when (and (false? @expand-lib-color) (< 3 (count library-colors))) - [:button {:class (stl/css :more-colors-btn) - :on-click #(reset! expand-lib-color true)} + [:> button* {:variant "secondary" + :class (stl/css :more-colors-btn) + :on-click #(reset! expand-lib-color true)} (tr "workspace.options.more-lib-colors")])] [:div {:class (stl/css :selected-color-group)} @@ -265,8 +267,9 @@ :on-close on-close}]) (when (and (false? @expand-color) (< 3 (count colors))) - [:button {:class (stl/css :more-colors-btn) - :on-click #(reset! expand-color true)} + [:> button* {:variant "secondary" + :class (stl/css :more-colors-btn) + :on-click #(reset! expand-color true)} (tr "workspace.options.more-colors")])] [:div {:class (stl/css :selected-color-group)} @@ -288,6 +291,7 @@ (when (and (false? @expand-token-color) (< 3 (count token-colors))) - [:button {:class (stl/css :more-colors-btn) - :on-click #(reset! expand-token-color true)} + [:> button* {:variant "secondary" + :class (stl/css :more-colors-btn) + :on-click #(reset! expand-token-color true)} (tr "workspace.options.more-token-colors")])]])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss index 15da32944b..f49e29e66f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss @@ -4,7 +4,6 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; .element-set { @@ -16,36 +15,25 @@ } .title-spacing-selected-colors { - padding-left: deprecated.$s-2; + padding-inline-start: var(--sp-xxs); margin: 0; } -.add-fill { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - } -} - .element-content { grid-column: span 8; - - @include deprecated.flex-column; - - margin-bottom: deprecated.$s-8; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin-block-end: var(--sp-s); } .selected-color-group { - @include deprecated.flex-column; + display: flex; + flex-direction: column; + gap: var(--sp-xs); } +// The DS button is stretched by the column; center its label. .more-colors-btn { - @extend %button-secondary; - @include deprecated.uppercase-title-typography; - - height: deprecated.$s-32; + justify-content: center; } From 539983db2fd3033303d7c86be161aed94f6448bd Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 20:07:04 +0200 Subject: [PATCH 029/167] :recycle: Use a DS icon button for flatten in the boolean options (#12368) * :recycle: Use a DS icon button for flatten in the boolean options Replace the raw flatten button and its deprecated icon with icon-button* and the DS boolean-flatten icon. The title text moves to aria-label, so the DS tooltip shows it. The grid column goes on the tooltip wrapper through tooltip-class. Add a visual test that selects two shapes so the boolean options show. AI-assisted-by: claude-opus-5-5 * :recycle: Remove deprecated button styles from the boolean options The flatten button is a DS icon button now, so its placeholders, sizes and icon colors go away; only the grid column stays, on the tooltip wrapper. Use block-size and drop the refactor/ import. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 25 +++++++++++++++++ .../workspace/sidebar/options/menus/bool.cljs | 19 +++++-------- .../workspace/sidebar/options/menus/bool.scss | 28 +------------------ 3 files changed, 33 insertions(+), 39 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index a99ea0b020..bff404fdd3 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -54,6 +54,31 @@ test.describe("Viewport", () => { }); test.describe("Design tab", () => { + test("Shows the boolean options when selecting two shapes", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + await workspace.rectShapeButton.click(); + await workspace.clickWithDragViewportAt(128, 128, 200, 100); + await workspace.clickLeafLayer("Rectangle"); + await page.keyboard.press("Control+D"); + await expect(workspace.layers.getByText("Rectangle")).toHaveCount(2); + await page.keyboard.press("Control+A"); + + await expect( + workspace.rightSidebar.getByRole("button", { name: "Flatten" }), + ).toBeVisible(); + // The boolean options are a small part of the sidebar: compare every + // pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); + test("Shows the design tab when selecting a shape", async ({ page }) => { const workspace = new WorkspacePage(page); await workspace.setupEmptyFile(); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs index 46801115ad..84345ef667 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs @@ -16,14 +16,11 @@ [app.main.features :as features] [app.main.store :as st] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [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.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) -(def ^:private flatten-icon - (deprecated-icon/icon-xref :boolean-flatten (stl/css :flatten-icon))) - (mf/defc bool-options* [{:keys [total-selected shapes shapes-with-children]}] (let [head (first shapes) @@ -100,11 +97,9 @@ :title (str (tr "workspace.shape.menu.exclude") " (" (sc/get-tooltip :bool-exclude) ")") :id "bool-opt-exclude"}]]] - [:button - {:title (tr "workspace.shape.menu.flatten") - :class (stl/css-case - :flatten-button true - :disabled disabled-flatten) - :disabled disabled-flatten - :on-click flatten-objects} - flatten-icon]]))) + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :flatten-button) + :aria-label (tr "workspace.shape.menu.flatten") + :icon i/boolean-flatten + :disabled disabled-flatten + :on-click flatten-objects}]]))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss index 50c8ae0625..2a03d0fd51 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss @@ -4,13 +4,12 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; .boolean-options { @include sidebar.option-grid-structure; - height: var(--sp-xxxl); + block-size: var(--sp-xxxl); } .bool-group { @@ -20,32 +19,7 @@ } .flatten-button { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-32; - border-radius: deprecated.$br-8; grid-column: 5 / span 1; - - --flatten-icon-foreground-color: var(--icon-foreground); - - &.disabled { - cursor: default; - - --flatten-icon-foreground-color: var(--button-foreground-color-disabled); - - &:hover { - background-color: var(--panel-background-color); - - --flatten-icon-foreground-color: var(--button-foreground-color-disabled); - } - } -} - -.flatten-icon { - @extend %button-icon; - - stroke: var(--flatten-icon-foreground-color); } .boolean-radio-btn { From ab7a994d6c04ab5ea0b66009f5c401b6b9f816fa Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 20:08:48 +0200 Subject: [PATCH 030/167] :bug: Fix libraries modal visual test (#12367) The libraries modal now opens on the "This file" tab, so the visual test timed out looking for the shared library. Open the "Libraries" tab first, as the functional test does, mock the file with the library linked, and check the "Added" label instead of the old "File library" text. AI-assisted-by: claude-opus-5-5 --- frontend/playwright/ui/visual-specs/workspace.spec.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index bff404fdd3..f074bc20a2 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -158,10 +158,15 @@ test.describe("Assets tab", () => { await workspace.openLibrariesModal(); await expect(workspace.page).toHaveScreenshot(); + await workspace.librariesModal + .getByRole("tab", { name: "Libraries" }) + .click(); + await expect(workspace.page).toHaveScreenshot(); + + // Now the get-file call should return a library + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-library.json"); await workspace.clickLibrary("Testing library 1"); - await expect( - workspace.librariesModal.getByText("File library"), - ).toBeVisible(); + await expect(workspace.librariesModal.getByText("Added")).toBeVisible(); await expect(workspace.page).toHaveScreenshot(); }); From 122d4ebcd38913cb8499122445829489f3eb0269 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 18:14:50 +0000 Subject: [PATCH 031/167] :fire: Remove unused requires flagged by the Clojure linter The dashboard required app.common.exceptions without ever using it, and the plugin shape namespace still referred to token-attr-plugin->token-attr after the per-side stroke width work replaced its only use with expand-token-attrs. Both are leftovers of earlier refactors; dropping them leaves clj-kondo with no warnings. AI-assisted-by: deepseek-v4.1-flash --- frontend/src/app/main/ui/dashboard.cljs | 1 - frontend/src/app/plugins/shape.cljs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 0f53bd0a96..c83a33e97d 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -8,7 +8,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.common.exceptions :as ex] [app.config :as cf] [app.main.data.dashboard :as dd] [app.main.data.dashboard.shortcuts :as sc] diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index b34b329598..9f8f7fbda7 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -66,7 +66,7 @@ [app.plugins.strokes :as strokes] [app.plugins.system-events :as se] [app.plugins.text :as text] - [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens expand-token-attrs token-attr-plugin->token-attr token-attr? valid-token-resolution?]] + [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens expand-token-attrs token-attr? valid-token-resolution?]] [app.plugins.utils :as u] [app.util.http :as http] [app.util.object :as obj] From 296ad9704f911727dbb80eed8d134320b1175fe4 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Thu, 15 Oct 2026 21:41:36 +0200 Subject: [PATCH 032/167] :bug: Fix bad message changing team to another organization (#12354) --- frontend/src/app/main/data/common.cljs | 8 ++-- .../frontend_tests/data/dashboard_test.cljs | 44 ++++++++++++++++++- frontend/translations/ca.po | 4 +- frontend/translations/en.po | 4 +- frontend/translations/es.po | 4 +- frontend/translations/fa.po | 4 +- frontend/translations/fr.po | 4 +- frontend/translations/fr_CA.po | 4 +- frontend/translations/he.po | 4 +- frontend/translations/it.po | 4 +- frontend/translations/jpn_JP.po | 4 +- frontend/translations/nl.po | 4 +- frontend/translations/sv.po | 4 +- frontend/translations/tr.po | 4 +- frontend/translations/zh_Hant.po | 4 +- 15 files changed, 74 insertions(+), 30 deletions(-) diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs index 6c948eef09..b04228694c 100644 --- a/frontend/src/app/main/data/common.cljs +++ b/frontend/src/app/main/data/common.cljs @@ -257,6 +257,8 @@ (watch [_ state _] (let [current-team-id (:current-team-id state) organization (:organization team) + team-name (or (:name team) + (dm/get-in state [:teams (:id team) :name])) current-team? (= (:id team) current-team-id)] (when (and (contains? cf/flags :admin-console) current-team?) @@ -266,14 +268,14 @@ ;; (tr "dashboard.team-belong-organization") ;; (tr "dashboard.team-no-longer-belong-organization") ;; notification is a server-provided key, dynamic by design + ;; args: team name, organization name #_{:clj-kondo/ignore [:penpot/tr-dynamic]} - (rx/of (ntf/show {:content (tr notification (:name organization)) + (rx/of (ntf/show {:content (tr notification team-name (:name organization)) :type :toast :level :info :timeout nil}))) (when (:id organization) - (check-team-sso current-team-id)))))) - ptk/UpdateEvent + (check-team-sso current-team-id)))))) ptk/UpdateEvent (update [_ state] (if (contains? cf/flags :admin-console) (let [team-id (:id team) diff --git a/frontend/test/frontend_tests/data/dashboard_test.cljs b/frontend/test/frontend_tests/data/dashboard_test.cljs index 12fefdb476..f59b5f83e7 100644 --- a/frontend/test/frontend_tests/data/dashboard_test.cljs +++ b/frontend/test/frontend_tests/data/dashboard_test.cljs @@ -10,14 +10,57 @@ [app.config :as cf] [app.main.data.common :as dcm] [app.main.data.dashboard :as dd] + [app.main.data.notifications :as ntf] [app.main.data.websocket :as dws] [app.main.repo :as rp] [app.main.router :as rt] + [app.util.i18n :as i18n] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) +(defn- tr-args-for-team-org-toast + "Runs handle-change-team-organization and returns recorded i18n/tr calls." + [notification organization] + (let [team-id (uuid/next) + current-url (str "https://penpot.example.com/#/dashboard/recent?team-id=" team-id) + state {:current-team-id team-id + :teams {team-id {:id team-id :name "Team-Apple"}}} + event (dcm/handle-change-team-organization + {:team {:id team-id :organization organization} + :notification notification}) + tr-calls (atom [])] + (-> (mock/with-mocks* + {cf/flags (conj cf/flags :admin-console) + ;; plain variadic fn: mock/stub does not cover IFn variadic dispatch + i18n/tr (fn [key & args] + (swap! tr-calls conj [key (vec args)]) + (str key ":" (pr-str args))) + rp/cmd! (mock/stub + (fn [cmd params] + (if (= [:check-nitrate-sso + {:team-id team-id :url current-url}] + [cmd params]) + (rx/of {:authorized true}) + (rx/throw (ex-info "unexpected RPC" {:cmd cmd :params params}))))) + rt/get-current-href (mock/stub (constantly current-url))} + (await (async/observe (ptk/watch event state nil) + :on-next (fn [ev] + (t/is (= ::ntf/show (ptk/type ev))))))) + (.then (fn [_] @tr-calls))))) (t/deftest ^:async team-org-change-toast-names-team-when-joining-org + (let [tr-calls (await (tr-args-for-team-org-toast + "dashboard.team-belong-organization" + {:id (uuid/next) :name "OrgB"}))] + (t/is (= [["dashboard.team-belong-organization" ["Team-Apple" "OrgB"]]] + tr-calls)))) + +(t/deftest ^:async team-org-change-toast-names-team-when-leaving-org + (let [tr-calls (await (tr-args-for-team-org-toast + "dashboard.team-no-longer-belong-organization" + {:name "OrgA"}))] + (t/is (= [["dashboard.team-no-longer-belong-organization" ["Team-Apple" "OrgA"]]] + tr-calls)))) (t/deftest moving-current-team-into-sso-organization-redirects (t/async done @@ -51,7 +94,6 @@ (fn [] (done'))))) done)))) - (t/deftest organization-sso-activation-redirects-current-team (t/async done (let [team-id (uuid/next) diff --git a/frontend/translations/ca.po b/frontend/translations/ca.po index 2c3702dc0c..900e2048d1 100644 --- a/frontend/translations/ca.po +++ b/frontend/translations/ca.po @@ -1238,7 +1238,7 @@ msgstr "S'ha mogut el projecte" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Aquest equip ara forma part de l'organització %s" +msgstr "%s ara forma part de %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1251,7 +1251,7 @@ msgstr "Membres de l'equip" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Aquest equip ja no forma part de l'organització %s" +msgstr "%s ja no forma part de %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 68f7eb7cbf..5c10b8d23b 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1250,7 +1250,7 @@ msgstr "Your project has been moved successfully" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "This team is now part of the organization %s" +msgstr "%s now belongs to %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1263,7 +1263,7 @@ msgstr "Team members" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "This team is no longer part of the organization %s" +msgstr "%s is no longer part of %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 794f34f57f..4207845025 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1264,7 +1264,7 @@ msgstr "Tu proyecto ha sido movido con éxito" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Este equipo ahora es parte de la organización %s" +msgstr "%s ahora pertenece a %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1277,7 +1277,7 @@ msgstr "Integrantes del equipo" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Este equipo ya no es parte de la organización %s" +msgstr "%s ya no forma parte de %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/fa.po b/frontend/translations/fa.po index 2acda65b41..bf01868ef6 100644 --- a/frontend/translations/fa.po +++ b/frontend/translations/fa.po @@ -1220,7 +1220,7 @@ msgstr "پروژه شما با موفقیت منتقل شد" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "این تیم اکنون به %s تعلق دارد" +msgstr "%s اکنون به %s تعلق دارد" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1233,7 +1233,7 @@ msgstr "اعضای تیم" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "این تیم دیگر به سازمان %s تعلق ندارد" +msgstr "%s دیگر به %s تعلق ندارد" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index 507dfc85bb..5686f05331 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -1248,7 +1248,7 @@ msgstr "Votre projet a bien été déplacé" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Cette équipe appartient désormais à %s" +msgstr "%s appartient désormais à %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1261,7 +1261,7 @@ msgstr "Membres de l’équipe" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Cette équipe n'appartient plus à l'organisation %s" +msgstr "%s n'appartient plus à %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/fr_CA.po b/frontend/translations/fr_CA.po index 65470f996a..79fb1a0323 100644 --- a/frontend/translations/fr_CA.po +++ b/frontend/translations/fr_CA.po @@ -1122,7 +1122,7 @@ msgstr "Le projet a été déplacé avec succès" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Cette équipe appartient maintenant à l'organisation %s" +msgstr "%s appartient maintenant à %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1135,7 +1135,7 @@ msgstr "Membres de l'équipe" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Cette équipe n'appartient plus à l'organisation %s" +msgstr "%s n'appartient plus à %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/he.po b/frontend/translations/he.po index 468980a6f6..0e9eabca97 100644 --- a/frontend/translations/he.po +++ b/frontend/translations/he.po @@ -1093,7 +1093,7 @@ msgstr "המיזם שלך הועבר בהצלחה" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "הצוות הזה שייך מעתה ל־%s" +msgstr "%s שייך מעתה ל־%s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1106,7 +1106,7 @@ msgstr "חברי הצוות" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "הצוות הזה לא שייך יותר לארגון %s" +msgstr "%s לא שייך יותר ל־%s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/it.po b/frontend/translations/it.po index 992dc61a58..3512c5d007 100644 --- a/frontend/translations/it.po +++ b/frontend/translations/it.po @@ -1103,7 +1103,7 @@ msgstr "Il tuo progetto è stato spostato con successo" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Questo team ora appartiene a %s" +msgstr "%s ora appartiene a %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1116,7 +1116,7 @@ msgstr "Membri del team" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Questo team non appartiene più all'organizzazione %s" +msgstr "%s non appartiene più a %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/jpn_JP.po b/frontend/translations/jpn_JP.po index 1d633bb75d..7b0a3d78b9 100644 --- a/frontend/translations/jpn_JP.po +++ b/frontend/translations/jpn_JP.po @@ -1202,7 +1202,7 @@ msgstr "プロジェクトを移動しました" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "このチームは組織「%s」に所属しました" +msgstr "%s は組織「%s」に所属しました" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1215,7 +1215,7 @@ msgstr "チームのメンバー" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "このチームは組織「%s」に所属しなくなりました" +msgstr "%s は組織「%s」に所属しなくなりました" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 1de2d611ec..3d55474017 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -1230,7 +1230,7 @@ msgstr "Je project is succesvol verplaatst" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Dit team behoort nu tot %s" +msgstr "%s behoort nu tot %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1243,7 +1243,7 @@ msgstr "Teamleden" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Dit team behoort niet langer tot de organisatie %s" +msgstr "%s behoort niet langer tot %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/sv.po b/frontend/translations/sv.po index b74e6d85a0..293b654895 100644 --- a/frontend/translations/sv.po +++ b/frontend/translations/sv.po @@ -1221,7 +1221,7 @@ msgstr "Ditt projekt har flyttats" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Detta team tillhör nu %s" +msgstr "%s tillhör nu %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1234,7 +1234,7 @@ msgstr "Teammedlemmar" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Detta team tillhör inte längre organisation %s" +msgstr "%s tillhör inte längre %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/tr.po b/frontend/translations/tr.po index 82cd5fe9a6..adbc51754c 100644 --- a/frontend/translations/tr.po +++ b/frontend/translations/tr.po @@ -1226,7 +1226,7 @@ msgstr "Projen başarıyla taşındı" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Bu takım artık %s organizasyonuna ait" +msgstr "%s artık %s organizasyonuna ait" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1239,7 +1239,7 @@ msgstr "Takım üyeleri" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Bu takım artık %s organizasyonuna ait değil" +msgstr "%s artık %s organizasyonuna ait değil" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/zh_Hant.po b/frontend/translations/zh_Hant.po index 94bb9de6fa..ebb8577137 100644 --- a/frontend/translations/zh_Hant.po +++ b/frontend/translations/zh_Hant.po @@ -1163,7 +1163,7 @@ msgstr "已成功移動您的專案" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "此團隊現已隸屬於組織 %s" +msgstr "%s 現已隸屬於 %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1176,7 +1176,7 @@ msgstr "團隊成員" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "此團隊已不再隸屬於組織 %s" +msgstr "%s 已不再隸屬於 %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" From 0afe1e193d7c6579752ecd886bd294e7fd4a91d0 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Fri, 16 Oct 2026 09:13:03 +0200 Subject: [PATCH 033/167] :bug: Fix team switcher height (#12371) --- .../app/main/ui/dashboard/organization_team_switch.scss | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 c2aef5e875..3fee83b7a4 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss @@ -208,7 +208,12 @@ gap: var(--sp-s); top: calc(var(--sp-s) + #{$sz-64} + var(--sp-xs)); left: var(--sp-s); - height: px2rem(416); + + // Floor matches the previous fixed height; grow for ~6 org rows + left + // footer; scroll + fade kick in above the cap. + block-size: fit-content; + min-block-size: px2rem(416); + max-block-size: $sz-520; overflow: hidden; } From a5f75c73940c1a0257315a88d8c137984a5d0ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Fri, 16 Oct 2026 10:59:57 +0200 Subject: [PATCH 034/167] :bug: Fix repair bad variants (#11983) * :bug: Ensure normalized names in components and variants We have a rule of that shape names that include / separator should have it normalized, with one space before and one after. But this rule is being enforced only in the UI and the plugins API. There may be other ways of creating shapes that does not follow it. For normal shapes this is not really a problem. But for main instances it may cause that the name of the shape does not match the joined path+name of the component, and this may generate some problems. We have decided to add a migration to fix this in all main instances and variant containers and enforce it in the future with integrity validations (normal shapes may be left as is). * :bug: Fix bad variant-id in the component and the main instance * :wrench: Fix some issues detected in PR review * :bug: Add nil guard --- common/src/app/common/files/builder.cljc | 4 +- common/src/app/common/files/migrations.cljc | 70 +++++++++- common/src/app/common/files/repair.cljc | 67 ++++++++- common/src/app/common/files/validate.cljc | 67 ++++++--- .../src/app/common/test_helpers/variants.cljc | 3 - .../test/common_tests/files/repair_test.cljc | 127 ++++++++++++++++-- .../test/common_tests/files_builder_test.cljc | 17 +++ .../common_tests/files_migrations_test.cljc | 103 ++++++++++++++ .../logic/comp_creation_test.cljc | 20 ++- 9 files changed, 429 insertions(+), 49 deletions(-) diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 49d93407a0..2e02166a93 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -16,6 +16,7 @@ [app.common.files.helpers :as cph] [app.common.files.migrations :as fmig] [app.common.geom.shapes :as gsh] + [app.common.path-names :as cpn] [app.common.schema :as sm] [app.common.svg :as csvg] [app.common.time :as dt] @@ -102,7 +103,8 @@ (str/capital (d/name type))))) :always - (update :name unique-name state))) + (-> (update :name cpn/clean-path) + (update :name unique-name state)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SCHEMAS diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index adc25e6cf6..d0312f5a56 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -21,21 +21,24 @@ [app.common.geom.shapes.text :as gsht] [app.common.logging :as l] [app.common.math :as mth] + [app.common.path-names :as cpn] [app.common.schema :as sm] [app.common.svg :as csvg] [app.common.types.color :as types.color] [app.common.types.component :as ctk] + [app.common.types.components-list :as ctkl] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.fills :as types.fills] [app.common.types.path :as path] [app.common.types.path.segment :as path.segment] [app.common.types.shape :as cts] + [app.common.types.shape-tree :as ctt] [app.common.types.shape.interactions :as ctsi] [app.common.types.shape.shadow :as ctss] [app.common.types.shape.text :as ctst] [app.common.types.text :as types.text] - [app.common.types.token :as ctt] + [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.common.types.variant :as ctv] [app.common.uuid :as uuid] @@ -2169,7 +2172,7 @@ (if (some? token) (update shape :applied-tokens (fn [applied] - (-> (zipmap ctt/per-side-stroke-width-keys (repeat token)) + (-> (zipmap cto/per-side-stroke-width-keys (repeat token)) (merge applied) (dissoc :stroke-width)))) shape))) @@ -2219,6 +2222,66 @@ (update :pages-index d/update-vals update-container) (d/update-when :components d/update-vals update-container)))) +(defmethod migrate-data "0033-normalize-main-instance-names" + [data _] + ;; In penpot shape names always have "/" characters normalized with one space between and after, + ;; to form paths. But this is not enforced and may be places that still admit names like + ;; "Group/Name" or "Group / Name". With normal shapes this is not a problem. But for main + ;; instances, the name of the root shape must match exactly the path+name of the component. + ;; Here we normalize the names of all main instances, and a new file integrity validation has + ;; been added to enforce this from now on. + ;; Main instances in variants are a special case because they need to match the variant name. + (letfn [(update-shape [container shape-id] + (let [shape (ctn/get-shape container shape-id) + + shape (cond + ;; Variant container must have a normalized name + (ctk/is-variant-container? shape) + (let [name (:name shape) + clean-name (cpn/clean-path name) + clean-name (if (str/blank? clean-name) + "(empty)" ;; this may occur if the name contains only separators + clean-name)] + (if (= name clean-name) + shape + (assoc shape :name clean-name))) + + ;; Main instance inside a variant must have the same name as the container + (ctk/main-instance? shape) + (if (ctk/is-variant? shape) + (let [variant-container (ctn/get-shape container (:parent-id shape))] + (if (or (= (:name shape) (:name variant-container)) + (nil? variant-container)) + shape + (assoc shape :name (:name variant-container)))) + + ;; Other main instances must have the same name as the path+name of the component + (let [component (ctkl/get-component data (:component-id shape) true) + expected-name (when component + (cpn/merge-path-item (:path component) (:name component)))] + (if (or (= expected-name (:name shape)) + (nil? expected-name)) + shape + (assoc shape :name expected-name)))) + + ;; Any other shape does not need processing + :else shape)] + + (reduce update-shape + (ctt/set-shape container shape) + (:shapes shape)))) + + (update-container + [container] + (let [root (ctn/get-container-root container)] + (if root + (update-shape container (:id root)) + container)))] + + (-> data + (update :pages-index d/update-vals update-container) + (d/update-when :components d/update-vals update-container)))) + (def available-migrations (into (d/ordered-set) ["legacy-2" @@ -2308,4 +2371,5 @@ "0029-move-background-blur-out-of-blur" "0030-remove-stroke-per-side-attr" "0031-migrate-stroke-width-token-attr" - "0032-fix-children-not-in-parent"])) + "0032-fix-children-not-in-parent" + "0033-normalize-main-instance-names"])) diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc index a8d98a2d87..4756f8a758 100644 --- a/common/src/app/common/files/repair.cljc +++ b/common/src/app/common/files/repair.cljc @@ -7,6 +7,7 @@ (ns app.common.files.repair (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.tokens :as cfo] @@ -215,6 +216,20 @@ (pcb/with-library-data file-data) (pcb/update-component (:component-id shape) repair-component)))) +(defmethod repair-error :invalid-main-instance-name + [_ {:keys [shape page-id args] :as error} file-data _] + (let [repair-shape + (fn [shape] + ;; Change the name to match the component + (let [component-name (:component-name args)] + (log/debug :hint (str " -> rename shape " (:id shape) " to " component-name)) + (assoc shape :name component-name)))] + + (log/debug :hint "repairing shape :invalid-main-instance-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) + (defmethod repair-error :invalid-main-instance [_ {:keys [shape page-id] :as error} file-data _] (let [repair-shape @@ -729,6 +744,42 @@ (pcb/with-file-data file-data) (pcb/update-shapes [parent-id] repair-fn)))) +(defmethod repair-error :variant-container-bad-name + [_ {:keys [shape page-id args]} file-data _] + (let [clean-name (:clean-name args) + + repair-shape + (fn [shape] + (log/debug :hint " -> set :name of shape" :id (:id shape) :name clean-name) + (assoc shape :name clean-name)) + + repair-component + (fn [component] + (let [[path name] (cpn/split-group-name clean-name)] + (log/debug :hint " -> set :path and :name of component" :id (:id component) :path path :name name) + (assoc component :path path :name name))) + + all-shape-ids + (cons (:id shape) (:shapes shape)) + + objects + (dm/get-in file-data [:pages-index page-id :objects]) + + all-main-instances + (map (d/getf objects) (:shapes shape)) + + all-component-ids + (map :component-id all-main-instances)] + + (log/debug :hint "repairing shape :variant-container-bad-name" :id (:id shape) :name (:name shape) :page-id page-id) + (as-> (pcb/empty-changes nil page-id) $ + (pcb/with-file-data $ file-data) + (pcb/with-library-data $ file-data) + (pcb/update-shapes $ all-shape-ids repair-shape) + (reduce #(pcb/update-component %1 %2 repair-component) + $ + all-component-ids)))) + (defmethod repair-error :variant-main-bad-name [_ {:keys [shape page-id args]} file-data _] (let [repair-fn @@ -771,14 +822,22 @@ (fn [shape] (let [variant-id (:variant-id args)] ;; Set the desired variant-id - (log/debug :hint (str " -> set variant-id to " variant-id)) - (assoc shape - :variant-id variant-id)))] + (log/debug :hint (str " -> set variant-id in shape to " variant-id)) + (assoc shape :variant-id variant-id))) + + repair-component + (fn [component] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id in component to " variant-id)) + (assoc component :variant-id variant-id)))] (log/debug :hint "repairing shape :variant-component-bad-id" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) - (pcb/update-shapes [(:id shape)] repair-shape)))) + (pcb/with-library-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape) + (pcb/update-component (:component-id shape) repair-component)))) (defmethod repair-error :missing-tokens-status [_ _ file-data _] diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index 72a1e112ff..6654e28508 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -41,6 +41,7 @@ :duplicate-slot :invalid-main-instance-id :invalid-main-instance-page + :invalid-main-instance-name :invalid-main-instance :invalid-parent :component-main @@ -70,6 +71,7 @@ :invalid-variant-properties :variant-not-main :parent-not-variant + :variant-container-bad-name :variant-main-bad-name :variant-main-bad-variant-name :variant-component-bad-name @@ -302,7 +304,14 @@ shape file page) (report-error :invalid-main-instance-page (str/ffmt "Main instance page of component % is not valid" (:component-id shape)) - shape file page)))))))) + shape file page)))) + (when-not (ctk/is-variant? component) ;; Variants are checked elsewhere + (let [component-name (cpn/merge-path-item (:path component) (:name component))] + (when-not (= (:name shape) component-name) + (report-error :invalid-main-instance-name + (str/ffmt "Main instance % has an invalid name" (:id shape)) + shape file page + :component-name component-name)))))))) (defn- check-component-not-main-head "Validate shape is a not-main instance head, component @@ -570,14 +579,25 @@ (defn- check-variant-container "Shape is a variant container, so: -all its children should be variants with variant-id equals to the shape-id - -all the components should have the same properties" + -all the components should have the same properties + -the name of the container should be normalized for paths (separator / with one space at each side)." [shape file page] - (let [shape-id (:id shape) - shapes (:shapes shape) - objects (:objects page) - file-data (:data file) - first-child (get objects (first shapes)) - prop-names (cfv/extract-properties-names first-child file-data)] + (let [shape-id (:id shape) + container-name (:name shape) + shapes (:shapes shape) + objects (:objects page) + file-data (:data file) + first-child (get objects (first shapes)) + prop-names (cfv/extract-properties-names first-child file-data) + clean-path (cpn/clean-path container-name) + clean-path (if (str/blank? clean-path) + "(empty)" ;; this may occur if the name contains only separators + clean-path)] + (when-not (= container-name clean-path) + (report-error :variant-container-bad-name + (str/ffmt "Variant container % has a bad name %" shape-id container-name) + shape file page + :clean-name clean-path)) (run! (fn [child-id] (when-let [child (get objects child-id)] (if (not (ctk/is-variant? child)) @@ -598,6 +618,11 @@ :prop-names prop-names)))))) shapes))) +(defn- renamed-container? + [container] + (and (some? container) + (not= (:name container) (cpn/clean-path (:name container))))) + (defn- check-variant "Shape is a variant, so -it should be a main component @@ -622,21 +647,23 @@ (str/ffmt "Variant % has an invalid variant-name" (:id shape)) shape file page :variant-name variant-name)) - (when-not (= (:name parent) (:name shape)) - (report-error :variant-main-bad-name - (str/ffmt "Main instance inside variant % has an invalid name" (:id shape)) - shape file page - :variant-name (:name parent))) - (when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component))) - (report-error :variant-component-bad-name - (str/ffmt "Component % has an invalid name" (:id shape)) - shape file page - :variant-container-name (:name parent))) + ;; If the container is going to be renamed all main and components will be renamed, too + (when-not (renamed-container? parent) + (when-not (= (:name parent) (:name shape)) + (report-error :variant-main-bad-name + (str/ffmt "Main instance inside variant % has an invalid name" (:id shape)) + shape file page + :variant-name (:name parent))) + (when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component))) + (report-error :variant-component-bad-name + (str/ffmt "Component % has an invalid name" (:id shape)) + shape file page + :variant-container-name (:name parent)))) (when-not (= (:variant-id component) (:variant-id shape)) (report-error :variant-component-bad-id - (str/ffmt "Variant % has adifferent variant-id than its component" (:id shape)) + (str/ffmt "Variant % has a :variant-id that does not point to the container" (:id shape)) shape file page - :variant-id (:variant-id component))))) + :variant-id (:parent-id shape))))) (defn- check-shape "Validate referential integrity and semantic coherence of diff --git a/common/src/app/common/test_helpers/variants.cljc b/common/src/app/common/test_helpers/variants.cljc index 8bbadf4c40..34f0cc8467 100644 --- a/common/src/app/common/test_helpers/variants.cljc +++ b/common/src/app/common/test_helpers/variants.cljc @@ -62,7 +62,6 @@ (thc/make-component component2-label root2-label) (thc/update-component component2-label {:variant-id variant-id :variant-properties [{:name "Property 1" :value "Value2"}]})))) - (defn add-variant-with-copy [file variant-label component1-label root1-label component2-label root2-label child1-label child2-label component-copy-label] (let [file (ths/add-sample-shape file variant-label :type :frame :is-variant-container true) @@ -77,8 +76,6 @@ (thc/make-component component2-label root2-label) (thc/update-component component2-label {:variant-id variant-id :variant-properties [{:name "Property 1" :value "Value2"}]})))) - - (defn add-variant-with-text [file variant-label component1-label root1-label component2-label root2-label child1-label child2-label text1 text2 & {:keys [text1-params text2-params]}] diff --git a/common/test/common_tests/files/repair_test.cljc b/common/test/common_tests/files/repair_test.cljc index d5aba47c98..27f6964de2 100644 --- a/common/test/common_tests/files/repair_test.cljc +++ b/common/test/common_tests/files/repair_test.cljc @@ -15,6 +15,7 @@ [app.common.files.repair :as cfr] [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] @@ -24,6 +25,48 @@ (t/use-fixtures :each thi/test-fixture) +(t/deftest repair-invalid-main-instance-name + (t/testing "detect and repair a main instance whose name doesn't match the component's name" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (tho/add-simple-component-with-copy :component1 :root1 :child1 :copy1) + (tho/add-simple-component-with-copy :component2 :root2 :child2 :copy2) + + ;; Update component paths and names + (thc/update-component :component1 {:path "Group / Subgroup" :name "Component1"}) + (thc/update-component :component2 {:path "Group / Subgroup" :name "Component2"}) + + ;; Update component1 shapes name to have path structure + (ths/update-shape :root1 :name "Group / Subgroup / Component1") + (ths/update-shape :copy1 :name "Group / Subgroup / Component1") + + ;; Update component2's name to have unnormalized path + ;; Note that the copy name is allowed to be different + (ths/update-shape :root2 :name "Group/Subgroup / Component2") + (ths/update-shape :copy2 :name "Group/Subgroup / Component2")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + root1' (ths/get-shape file' :root1) + child1' (ths/get-shape file' :child1) + copy1' (ths/get-shape file' :copy1) + root2' (ths/get-shape file' :root2) + child2' (ths/get-shape file' :child2) + copy2' (ths/get-shape file' :copy2)] + + (t/is (= 1 (count errors))) + (t/is (= :invalid-main-instance-name (:code (first errors)))) + (t/is (nil? errors')) + + (t/is (= "Group / Subgroup / Component1" (:name root1'))) + (t/is (= "Rect1" (:name child1'))) + (t/is (= "Group / Subgroup / Component1" (:name copy1'))) + + (t/is (= "Group / Subgroup / Component2" (:name root2'))) + (t/is (= "Rect1" (:name child2'))) + (t/is (= "Group/Subgroup / Component2" (:name copy2')))))) + (t/deftest repair-main-instance-not-a-variant (t/testing "detect and repair a variant component whose root shape is not a variant" (let [file (-> (thf/sample-file :file1 :page-label :page1) @@ -44,7 +87,7 @@ (t/is (= (thi/id :variant1) (:variant-id root1')))))) (t/deftest repair-invalid-variant-id-variant-component-bad-id - (t/testing "detect and repair a variant component whose variant id does not match the container's id" + (t/testing "detect and repair a variant main whose variant id does not match the container's id" (let [file (-> (thf/sample-file :file1 :page-label :page1) (thv/add-variant :variant1 :component1 :root1 :component2 :root2) (ths/update-shape :root1 :variant-id (uuid/next))) @@ -61,7 +104,25 @@ (t/is (= :variant-component-bad-id (:code (second errors)))) (t/is (nil? errors')) - (t/is (= (thi/id :variant1) (:variant-id root1')))))) + (t/is (= (thi/id :variant1) (:variant-id root1'))))) + + (t/testing "detect and repair a variant component whose variant id does not match the container's id" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (thc/update-component :component1 {:variant-id (uuid/next)})) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + component1' (thc/get-component file' :component1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-component-bad-id (:code (first errors)))) + + (t/is (nil? errors')) + (t/is (= (thi/id :variant1) (:variant-id component1')))))) (t/deftest repair-invalid-variant-properties (t/testing "detect and repair a second variant component whose properties do not match the first variant component's properties" @@ -161,23 +222,60 @@ (t/is (true? (:is-variant-container container')))))) +(t/deftest repair-variant-container-bad-name + (t/testing "detect and repair a variant container who has an unnormalized name" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Variant container has a name that is not normalized and thus it cannot + ;; match the component names, that are separated in path and group. + (ths/update-shape :variant1 :name "Group / Subgroup/Component") + (ths/update-shape :root1 :name "Group / Subgroup/Component") + (ths/update-shape :root2 :name "Group / Subgroup/Component") + (thc/update-component :component1 {:path "Group / Subgroup" :name "Component"}) + (thc/update-component :component2 {:path "Group/Subgroup" :name "Component"})) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + var1' (ths/get-shape file' :variant1) + root1' (ths/get-shape file' :root1) + comp1' (thc/get-component file' :component1) + root2' (ths/get-shape file' :root2) + comp2' (thc/get-component file' :component2)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-container-bad-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Group / Subgroup / Component" (:name var1'))) + (t/is (= "Group / Subgroup / Component" (:name root1'))) + (t/is (= "Group / Subgroup" (:path comp1'))) + (t/is (= "Component" (:name comp1'))) + (t/is (= "Group / Subgroup / Component" (:name root2'))) + (t/is (= "Group / Subgroup" (:path comp2'))) + (t/is (= "Component" (:name comp2')))))) + (t/deftest repair-variant-main-bad-name (t/testing "detect and repair a main instance whose name doesn't match the variant container's name" (let [file (-> (thf/sample-file :file1 :page-label :page1) (thv/add-variant :variant1 :component1 :root1 :component2 :root2) - ;; Change root1's name so it doesn't match the container + ;; Change component's name so it doesn't match the container + (thc/update-component :component1 :name "WrongName") (ths/update-shape :root1 :name "WrongName")) errors (cfv/validate-file file {}) changes (cfr/repair-file file {} errors) file' (thf/apply-changes file {:redo-changes changes} :validate? false) errors' (cfv/validate-file file' {}) - root1' (ths/get-shape file' :root1)] + root1' (ths/get-shape file' :root1) + comp1' (thc/get-component file' :component1)] - (t/is (= 1 (count errors))) + (t/is (= 2 (count errors))) (t/is (= :variant-main-bad-name (:code (first errors)))) + (t/is (= :variant-component-bad-name (:code (second errors)))) (t/is (nil? errors')) - (t/is (= "Board" (:name root1')))))) + (t/is (= "Board" (:name root1'))) + (t/is (= "Board" (:name comp1')))))) (t/deftest repair-variant-main-bad-variant-name (t/testing "detect and repair a variant shape whose :variant-name doesn't match the component's properties" @@ -211,23 +309,28 @@ (ths/update-shape :variant1 :name "Group / Subgroup / Component") (ths/update-shape :root1 :name "Group / Subgroup / Component") (ths/update-shape :root2 :name "Group / Subgroup / Component") - ;; Update component paths and names (thc/update-component :component1 {:path "Group / Subgroup" :name "Component"}) - (thc/update-component :component2 {:path "Group / Subgroup" :name "Component"}) - ;; Break component1's name - (thc/update-component :component1 {:name "WrongName"})) + ;; Break component2's name + (thc/update-component :component2 {:name "WrongName"})) errors (cfv/validate-file file {}) changes (cfr/repair-file file {} errors) file' (thf/apply-changes file {:redo-changes changes} :validate? false) errors' (cfv/validate-file file' {}) - comp1' (thc/get-component file' :component1)] + root1' (ths/get-shape file' :root1) + comp1' (thc/get-component file' :component1) + root2' (ths/get-shape file' :root2) + comp2' (thc/get-component file' :component2)] (t/is (= 1 (count errors))) (t/is (= :variant-component-bad-name (:code (first errors)))) (t/is (nil? errors')) + (t/is (= "Group / Subgroup / Component" (:name root1'))) (t/is (= "Group / Subgroup" (:path comp1'))) - (t/is (= "Component" (:name comp1')))))) + (t/is (= "Component" (:name comp1'))) + (t/is (= "Group / Subgroup / Component" (:name root2'))) + (t/is (= "Group / Subgroup" (:path comp2'))) + (t/is (= "Component" (:name comp2')))))) (t/deftest repair-child-not-in-parent (t/testing "detect and repair shapes whose parent exists but does not list them" diff --git a/common/test/common_tests/files_builder_test.cljc b/common/test/common_tests/files_builder_test.cljc index 707d56d3f0..23891a583b 100644 --- a/common/test/common_tests/files_builder_test.cljc +++ b/common/test/common_tests/files_builder_test.cljc @@ -70,3 +70,20 @@ (t/is (= "Image" (:name media))) (t/is (= 128 (:width media))) (t/is (= 64 (:height media))))) + +(t/deftest add-shape-normalizes-path-name + (let [file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-shape {:id shape-id + :type :frame + :name "Group/Subgroup / Name" + :x 0 + :y 0 + :width 10 + :height 10})) + shape (fb/get-shape state shape-id)] + (t/is (= "Group / Subgroup / Name" (:name shape))))) diff --git a/common/test/common_tests/files_migrations_test.cljc b/common/test/common_tests/files_migrations_test.cljc index b76f4f369c..61ba0e7486 100644 --- a/common/test/common_tests/files_migrations_test.cljc +++ b/common/test/common_tests/files_migrations_test.cljc @@ -9,12 +9,20 @@ [app.common.data :as d] [app.common.files.migrations :as cfm] [app.common.files.tokens :as cfo] + [app.common.test-helpers.components :as thk] + [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.test-helpers.variants :as thv] [app.common.types.file :as ctf] [app.common.types.shape :as cts] [app.common.types.token :as ctt] [app.common.uuid :as uuid] [clojure.test :as t])) +(t/use-fixtures :each thi/test-fixture) + (defmethod cfm/migrate-data "test/1" [data _] (update data :sum inc)) (defmethod cfm/migrate-data "test/2" [data _] (update data :sum inc)) (defmethod cfm/migrate-data "test/3" [data _] (update data :sum inc)) @@ -647,3 +655,98 @@ (t/is (contains? (:migrations file') migration-id) "migration id persisted") (t/is (= 4 (count (get-in file' [:data :pages-index page-id :objects shape-id :points]))) "path points repaired before schema validation"))) + +(t/deftest migration-0033-normalize-main-instance-diff-from-component + (let [migration-id "0033-normalize-main-instance-names" + file (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 :root1 :child1 + :component-params {:path "Group / Subgroup" + :name "Name"} + :root-params {:name "Group/Subgroup / Name"}) + (tho/add-simple-component :component2 :root2 :child2 + :component-params {:path "Group / Subgroup" + :name "Other Name"} + :root-params {:name "Group / Subgroup / Other Name"}) + (ths/add-sample-shape :shape1 :name "Other Group/Other Subgroup / Other Name")) + + data (ctf/file-data file) + data' (cfm/migrate-data data migration-id) + file' (assoc file :data data') + + root1' (ths/get-shape file' :root1) + component1' (thk/get-component file' :component1) + root2' (ths/get-shape file' :root2) + component2' (thk/get-component file' :component2) + shape1' (ths/get-shape file' :shape1)] + + (t/is (= "Group / Subgroup" (:path component1'))) + (t/is (= "Name" (:name component1'))) + (t/is (= "Group / Subgroup / Name" (:name root1'))) + (t/is (= "Group / Subgroup" (:path component2'))) + (t/is (= "Other Name" (:name component2'))) + (t/is (= "Group / Subgroup / Other Name" (:name root2'))) + (t/is (= "Other Group/Other Subgroup / Other Name" (:name shape1'))))) + +(t/deftest migration-0033-normalize-variant-instance-diff-from-container + (let [migration-id "0033-normalize-main-instance-names" + file (-> (thf/sample-file :file1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (ths/update-shape :variant1 :name "Group / Subgroup / Component") + (ths/update-shape :root1 :name "Group / Subgroup / Component") + (ths/update-shape :root2 :name "Group / Subgroup/Component") ;; Bad path + (thk/update-component :component1 {:path "Group / Subgroup" :name "Component"}) + (thk/update-component :component2 {:path "Group / Subgroup" :name "Component"}) + (thk/instantiate-component :component2 :copy2) + (ths/update-shape :copy2 :name "Group / Subgroup/Component")) ;; Allowed name in copies + + data (ctf/file-data file) + data' (cfm/migrate-data data migration-id) + file' (assoc file :data data') + + variant1' (ths/get-shape file' :variant1) + root1' (ths/get-shape file' :root1) + component1' (thk/get-component file' :component1) + root2' (ths/get-shape file' :root2) + component2' (thk/get-component file' :component2) + copy2' (ths/get-shape file' :copy2)] + + (t/is (= "Group / Subgroup / Component" (:name variant1'))) + (t/is (= "Group / Subgroup / Component" (:name root1'))) + (t/is (= "Group / Subgroup / Component" (:name root2'))) + (t/is (= "Group / Subgroup" (:path component1'))) + (t/is (= "Group / Subgroup" (:path component2'))) + (t/is (= "Component" (:name component1'))) + (t/is (= "Component" (:name component2'))) + (t/is (= "Group / Subgroup/Component" (:name copy2'))))) + +(t/deftest migration-0033-normalize-variant-name-not-normalized + (let [migration-id "0033-normalize-main-instance-names" + file (-> (thf/sample-file :file1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (ths/update-shape :variant1 :name "Group/Subgroup / Component") + (ths/update-shape :root1 :name "Group/Subgroup / Component") + (ths/update-shape :root2 :name "Group/Subgroup / Component") ;; Bad path + (thk/update-component :component1 {:path "Group / Subgroup" :name "Component"}) + (thk/update-component :component2 {:path "Group / Subgroup" :name "Component"}) + (thk/instantiate-component :component2 :copy2) + (ths/update-shape :copy2 :name "Group / Subgroup/Component")) ;; Allowed name in copies + + data (ctf/file-data file) + data' (cfm/migrate-data data migration-id) + file' (assoc file :data data') + + variant1' (ths/get-shape file' :variant1) + root1' (ths/get-shape file' :root1) + component1' (thk/get-component file' :component1) + root2' (ths/get-shape file' :root2) + component2' (thk/get-component file' :component2) + copy2' (ths/get-shape file' :copy2)] + + (t/is (= "Group / Subgroup / Component" (:name variant1'))) + (t/is (= "Group / Subgroup / Component" (:name root1'))) + (t/is (= "Group / Subgroup / Component" (:name root2'))) + (t/is (= "Group / Subgroup" (:path component1'))) + (t/is (= "Group / Subgroup" (:path component2'))) + (t/is (= "Component" (:name component1'))) + (t/is (= "Component" (:name component2'))) + (t/is (= "Group / Subgroup/Component" (:name copy2'))))) diff --git a/common/test/common_tests/logic/comp_creation_test.cljc b/common/test/common_tests/logic/comp_creation_test.cljc index d28e5f3b31..95f158e2a3 100644 --- a/common/test/common_tests/logic/comp_creation_test.cljc +++ b/common/test/common_tests/logic/comp_creation_test.cljc @@ -19,6 +19,7 @@ [app.common.test-helpers.shapes :as ths] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] + [app.common.types.file :as ctf] [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] [clojure.test :as t])) @@ -250,18 +251,25 @@ component (thc/get-component file :component1) ;; ==== Action - changes (cll/generate-rename-component (pcb/empty-changes) - (:id component) - "Test component after" - (:data file)) + changes (-> (pcb/empty-changes nil (thf/current-page-id file)) + (pcb/with-file-data (ctf/file-data file)) + (cll/generate-rename-component + (:id component) + "Test component after" + (:data file)) + (pcb/update-shapes + [(thi/id :main1-root)] + #(assoc % :name "Test component after"))) file' (thf/apply-changes file changes) ;; ==== Get - component' (thc/get-component file' :component1)] + component' (thc/get-component file' :component1) + main1-root' (ths/get-shape file' :main1-root)] ;; ==== Check - (t/is (= (:name component') "Test component after")))) + (t/is (= (:name component') "Test component after")) + (t/is (= (:name main1-root') "Test component after")))) (t/deftest test-duplicate-component (let [;; ==== Setup From 7322cd8ec5aefc406cd9604113c548b9aed8d97d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 16 Oct 2026 11:43:42 +0200 Subject: [PATCH 035/167] :books: Update changelog --- .serena/memories/scripts/gh.md | 1 + CHANGES.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.serena/memories/scripts/gh.md b/.serena/memories/scripts/gh.md index 7182120524..363dc612ea 100644 --- a/.serena/memories/scripts/gh.md +++ b/.serena/memories/scripts/gh.md @@ -119,6 +119,7 @@ python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 ## Key principles +- `issues --compare` collects every `#NNNN` token in the file regardless of its link target, so a same-numbered reference to another tracker (e.g. `[Taiga #12312](...)`) or an inline prose number falsely marks that GitHub issue as already listed; verify against `changelog.py cross-ref` when a number collides. - Changelog-specific checks (merge-status verification, milestone-vs-changelog cross-reference, anomaly report) live in `scripts/changelog.py`, which calls this script via subprocess — see `mem:scripts/changelog`. - All output is JSON — pipe into `jq` or other tools for further processing. diff --git a/CHANGES.md b/CHANGES.md index c59713b1db..3a2b8dab1c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,6 +14,7 @@ - Add a graceful instance drain through the /readyz endpoint [#12281](https://github.com/penpot/penpot/issues/12281) (PR: [#12269](https://github.com/penpot/penpot/pull/12269)) - Add an accumulated queue gauge to the CLIMIT metrics [#12282](https://github.com/penpot/penpot/issues/12282) (PR: [#12269](https://github.com/penpot/penpot/pull/12269)) +- Expose the backend log events as a metric by logger and level [#12312](https://github.com/penpot/penpot/issues/12312) (PR: [#12313](https://github.com/penpot/penpot/pull/12313)) ## 2.18.3 From ccdfaffbef489f290cd92dc0ac1ba397c87a7cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 16 Oct 2026 12:36:23 +0200 Subject: [PATCH 036/167] :bug: Fix stroke to path for strokes with different widths per side (#12356) --- render-wasm/src/render.rs | 1 + render-wasm/src/render/strokes.rs | 151 +++++++++++++++++++++++++----- render-wasm/src/wasm/paths.rs | 40 +++++--- 3 files changed, 155 insertions(+), 37 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 322319796b..4f84df8599 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -24,6 +24,7 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use options::RenderOptions; +pub(crate) use strokes::per_side_stroke_outline; pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 48efeada92..0b12f33482 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -128,22 +128,22 @@ fn inner_corners_collapse(stroke: &Stroke, rect: &Rect, radii: &Option) .any(|(shape, (want, got))| shape.x > 0.0 && (want.x <= 0.0 || want != got)) } -/// Draws a rect/frame stroke whose sides have different widths as the area -/// between an outer and an inner (rounded) rect, mitered like CSS borders. -/// Dashed/dotted styles cut that band per side, each side fitting a whole -/// number of dashes the way a browser fits a dashed border. -#[allow(clippy::too_many_arguments)] -fn draw_per_side_stroke_on_rect( - canvas: &skia::Canvas, +struct PerSideGeometry { + band: skia::Path, + pattern: Option, + wedges: Option, + trim_to_band: bool, +} + +fn per_side_geometry( stroke: &Stroke, + style: StrokeStyle, rect: &Rect, corners: &Option, - paint: &skia::Paint, widths: [f32; 4], - scale: f32, miter: Option<[f32; 4]>, antialias: bool, -) { +) -> PerSideGeometry { // Strokes sharing a border box are shaped by the whole box: a side this one // does not paint still sets the radii its corners curve to, as in CSS. let band_widths: [f32; 4] = match miter { @@ -182,11 +182,6 @@ fn draw_per_side_stroke_on_rect( // and the whole outer rect is filled, like CSS collapsing borders. let has_hole = inner.width() > 0.0 && inner.height() > 0.0; - let mut fill_paint = paint.clone(); - fill_paint.set_style(skia::PaintStyle::Fill); - fill_paint.set_path_effect(None); - fill_paint.set_anti_alias(antialias); - let mut pb = skia::PathBuilder::new(); let (outer_radii, inner_radii) = match corners { Some(radii) => { @@ -231,7 +226,6 @@ fn draw_per_side_stroke_on_rect( // Solid is the common case and the whole band is one fill. A collapsed // band has no sides left to walk, so it fills solid too. - let style = stroke.with_width(stroke.max_width()).style_at_scale(scale); let pattern = if style == StrokeStyle::Solid || !has_hole { None } else { @@ -251,8 +245,68 @@ fn draw_per_side_stroke_on_rect( // so each one keeps only its own wedge of the corner. let wedges = miter.and_then(|profile| side_wedges(&outer, outer_radii, profile, widths)); - if pattern.is_none() && wedges.is_none() { - canvas.draw_path(&band, &fill_paint); + // Square corners need no clip: the side strips tile the band exactly. + // Rounded corners curve past them, and dots straddle the boundary. + let rounded = corners.is_some_and(|radii| radii.iter().any(|r| r.x > 0.0 || r.y > 0.0)); + let trim_to_band = rounded || style == StrokeStyle::Dotted; + + PerSideGeometry { + band, + pattern, + wedges, + trim_to_band, + } +} + +pub(crate) fn per_side_stroke_outline( + stroke: &Stroke, + rect: &Rect, + corners: &Option, + widths: [f32; 4], + miter: Option<[f32; 4]>, +) -> skia::Path { + let geometry = per_side_geometry(stroke, stroke.style, rect, corners, widths, miter, true); + let outline = match geometry.pattern { + Some(pattern) if geometry.trim_to_band => pattern + .op(&geometry.band, skia::PathOp::Intersect) + .unwrap_or(pattern), + Some(pattern) => pattern, + None => geometry.band, + }; + match &geometry.wedges { + Some(wedges) => outline + .op(wedges, skia::PathOp::Intersect) + .unwrap_or(outline), + None => outline, + } +} + +/// Draws a rect/frame stroke whose sides have different widths as the area +/// between an outer and an inner (rounded) rect, mitered like CSS borders. +/// Dashed/dotted styles cut that band per side, each side fitting a whole +/// number of dashes the way a browser fits a dashed border. +#[allow(clippy::too_many_arguments)] +fn draw_per_side_stroke_on_rect( + canvas: &skia::Canvas, + stroke: &Stroke, + rect: &Rect, + corners: &Option, + paint: &skia::Paint, + widths: [f32; 4], + scale: f32, + miter: Option<[f32; 4]>, + antialias: bool, +) { + let mut fill_paint = paint.clone(); + fill_paint.set_style(skia::PaintStyle::Fill); + fill_paint.set_path_effect(None); + fill_paint.set_anti_alias(antialias); + + let style = stroke.with_width(stroke.max_width()).style_at_scale(scale); + let geometry = per_side_geometry(stroke, style, rect, corners, widths, miter, antialias); + + if geometry.pattern.is_none() && geometry.wedges.is_none() { + canvas.draw_path(&geometry.band, &fill_paint); return; } @@ -271,22 +325,19 @@ fn draw_per_side_stroke_on_rect( } } - if let Some(wedges) = &wedges { + if let Some(wedges) = &geometry.wedges { canvas.clip_path(wedges, skia::ClipOp::Intersect, antialias); } - match &pattern { + match &geometry.pattern { Some(pattern) => { - // Square corners need no clip: the side strips tile the band exactly. - // Rounded corners curve past them, and dots straddle the boundary. - let rounded = corners.is_some_and(|radii| radii.iter().any(|r| r.x > 0.0 || r.y > 0.0)); - if rounded || style == StrokeStyle::Dotted { - canvas.clip_path(&band, skia::ClipOp::Intersect, antialias); + if geometry.trim_to_band { + canvas.clip_path(&geometry.band, skia::ClipOp::Intersect, antialias); } canvas.draw_path(pattern, &fill_paint); } None => { - canvas.draw_path(&band, &fill_paint); + canvas.draw_path(&geometry.band, &fill_paint); } } canvas.restore(); @@ -1538,3 +1589,51 @@ mod per_side_tests { assert!(!pattern.contains((227.0, 50.0)), "right gap was filled"); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn per_side_stroke_is_an_evenodd_band() { + let stroke = Stroke::new_center_stroke(2., StrokeStyle::Solid, None, None, None, None); + let rect = Rect::from_xywh(0., 0., 100., 50.); + let path = per_side_stroke_outline(&stroke, &rect, &None, [2., 4., 6., 8.], None); + + assert_eq!(path.fill_type(), skia::PathFillType::EvenOdd); + assert_eq!( + path.compute_tight_bounds(), + Rect::from_ltrb(-4., -1., 102., 53.) + ); + assert!(!path.contains(Point::new(50., 25.))); + assert!(path.contains(Point::new(-2., 25.))); + } + + #[test] + fn per_side_dashed_stroke_keeps_its_gaps() { + let stroke = Stroke::new_center_stroke(4., StrokeStyle::Dashed, None, None, None, None); + let rect = Rect::from_xywh(0., 0., 200., 100.); + let path = per_side_stroke_outline(&stroke, &rect, &None, [4., 4., 4., 8.], None); + + let hits = (10..190) + .filter(|x| path.contains(Point::new(*x as f32, 0.))) + .count(); + assert!(hits > 0 && hits < 180, "hits: {hits}"); + } + + #[test] + fn per_side_stroke_miters_against_its_siblings() { + let stroke = Stroke::new_center_stroke(4., StrokeStyle::Solid, None, None, None, None); + let rect = Rect::from_xywh(0., 0., 100., 50.); + let path = per_side_stroke_outline( + &stroke, + &rect, + &None, + [4., 0., 0., 0.], + Some([4., 4., 0., 0.]), + ); + + assert!(path.contains(Point::new(101., -1.5))); + assert!(!path.contains(Point::new(99., 1.5))); + } +} diff --git a/render-wasm/src/wasm/paths.rs b/render-wasm/src/wasm/paths.rs index b035ee4f81..28b2193964 100644 --- a/render-wasm/src/wasm/paths.rs +++ b/render-wasm/src/wasm/paths.rs @@ -5,7 +5,8 @@ use std::mem::size_of; use std::sync::{Mutex, OnceLock}; use crate::error::{Error, Result}; -use crate::shapes::{stroke_to_path, Path, Segment, ToPath}; +use crate::render::per_side_stroke_outline; +use crate::shapes::{stroke_to_path, Path, Segment, Stroke, ToPath, Type}; use crate::{mem, with_current_shape, with_current_shape_mut}; const RAW_SEGMENT_DATA_SIZE: usize = size_of::(); @@ -247,17 +248,34 @@ pub extern "C" fn convert_stroke_to_path(stroke_index: i32) -> *mut u8 { with_current_shape!(state, |shape: &Shape| { let idx = stroke_index as usize; if let Some(stroke) = shape.strokes.get(idx) { - let shape_path = shape.to_path(&state.shapes); - let path_transform = shape.to_path_transform(); + let per_side = stroke + .per_side_widths() + .filter(|_| matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_))); - if let Some(path) = stroke_to_path( - stroke, - &shape_path, - path_transform.as_ref(), - &shape.selrect, - shape.svg_attrs.as_ref(), - false, - ) { + let path = match per_side { + Some(widths) => { + let outline = per_side_stroke_outline( + stroke, + &shape.selrect, + &shape.shape_type.corners(), + widths, + Stroke::per_side_profile(shape.visible_strokes()), + ); + Some(Path::from_skia_path_accurate( + outline.make_transform(&shape.centered_transform()), + )) + } + None => stroke_to_path( + stroke, + &shape.to_path(&state.shapes), + shape.to_path_transform().as_ref(), + &shape.selrect, + shape.svg_attrs.as_ref(), + false, + ), + }; + + if let Some(path) = path { even_odd = path.is_even_odd(); result = path .segments() From 03134d35ae666565020f9fd88e439eef02a90377 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 16 Oct 2026 12:51:48 +0200 Subject: [PATCH 037/167] :bug: Fix silent failure when uploading custom fonts (#12315) * :bug: Fix silent failure when uploading custom fonts Custom font upload crashed with re-seq must match against a string when opentype.js returned nil subfamily names, leaving the user with no feedback. Unknown signatures and unparseable files were also silently dropped. Make variant parsing nil-safe, harden entry preparation for missing tables, report every per-file failure with a bad-font notification, and show a generic error if the stream itself fails. Closes #12304 AI-assisted-by: muse-spark-1.3-contributor * :recycle: Rename font mtype helper and mark helpers private Avoid confusion with app.worker.import/parse-mtype, which detects binfile signatures. The font helper now lives as parse-font-mtype, and the upload helpers only used internally are private, with tests reaching them through var references. AI-assisted-by: muse-spark-1.3-contributor * :lipstick: Fix formatting in font upload tests Realign map keys shifted by the var-quote prefix so cljfmt check passes again. No behavior change. AI-assisted-by: muse-spark-1.3-contributor * :bug: Fix infer warnings in fonts metrics code Add missing ^js type hints to the hhea and os2 metric lookups in prepare-font so shadow-cljs can infer the interop targets and the release build reports zero warnings. AI-assisted-by: muse-spark-1.3-contributor --- common/src/app/common/media.cljc | 40 ++- common/test/common_tests/media_test.cljc | 14 +- frontend/src/app/main/data/fonts.cljs | 281 ++++++++++-------- frontend/src/app/main/ui/dashboard/fonts.cljs | 1 + frontend/test/frontend_tests/fonts_test.cljs | 109 ++++++- 5 files changed, 310 insertions(+), 135 deletions(-) diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index ea0c11b530..a82759a7bd 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -99,26 +99,34 @@ (str/replace filename image-extensions-re ""))) (defn parse-font-weight + "Parses a font variant/subfamily string into a numeric weight. + A nil or non-string variant means the font carries no subfamily + metadata, so it defaults to 400 (regular), the same as a blank string." [variant] - (cond - (re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b))" variant) 100 - (re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b))" variant) 200 - (re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b))" variant) 300 - (re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b))" variant) 400 - (re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b))" variant) 500 - (re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 600 - (re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 800 - (re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b))" variant) 700 - (re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b))" variant) 950 - (re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b))" variant) 900 - :else 400)) + (let [variant (if (string? variant) variant "")] + (cond + (re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b))" variant) 100 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b))" variant) 200 + (re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b))" variant) 300 + (re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b))" variant) 400 + (re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b))" variant) 500 + (re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 600 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 800 + (re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b))" variant) 700 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b))" variant) 950 + (re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b))" variant) 900 + :else 400))) (defn parse-font-style + "Parses a font variant/subfamily string into \"italic\" or \"normal\". + A nil or non-string variant means the font carries no subfamily + metadata, so it defaults to \"normal\"." [variant] - (if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant) - (re-seq #"(?i)italic$" variant)) - "italic" - "normal")) + (let [variant (if (string? variant) variant "")] + (if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant) + (re-seq #"(?i)italic$" variant)) + "italic" + "normal"))) (defn font-weight->name [weight] diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index 00bbe932ef..f94525cae7 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -35,7 +35,12 @@ (t/is (= 400 (media/parse-font-weight "Lighthaus"))) (t/is (= 400 (media/parse-font-weight "Blackwood"))) (t/is (= 400 (media/parse-font-weight "Thinker"))) - (t/is (= 400 (media/parse-font-weight "Mediaeval"))))) + (t/is (= 400 (media/parse-font-weight "Mediaeval")))) + + (t/testing "defaults to regular when the variant metadata is missing" + (t/is (= 400 (media/parse-font-weight nil))) + (t/is (= 400 (media/parse-font-weight ""))) + (t/is (= 400 (media/parse-font-weight " "))))) (t/deftest test-parse-font-style (t/testing "matches italic with proper boundaries" @@ -47,7 +52,12 @@ (t/testing "does not match italic embedded in words" (t/is (= "normal" (media/parse-font-style "Italica"))) - (t/is (= "normal" (media/parse-font-style "Roboto-Regular"))))) + (t/is (= "normal" (media/parse-font-style "Roboto-Regular")))) + + (t/testing "defaults to normal when the variant metadata is missing" + (t/is (= "normal" (media/parse-font-style nil))) + (t/is (= "normal" (media/parse-font-style ""))) + (t/is (= "normal" (media/parse-font-style " "))))) (t/deftest test-strip-image-extension (t/testing "removes extension from supported image files" diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index 2ec056487d..dca3c54263 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -115,6 +115,128 @@ :font-style font-style :uploads uploads}))))) +(defn- parse-font-mtype + "Detects a font mime-type from the first four bytes of an ArrayBuffer. + + Returns the `font/*` mime-type string, or nil when the buffer is + missing, too short, or carries an unknown signature. Unknown + signatures are treated as errors by `read-blob`, never silently + dropped." + [ba] + (when (and (some? ba) + (>= (.-byteLength ^js ba) 4)) + (let [u8 (js/Uint8Array. ba 0 4) + sg (areduce u8 i ret "" (str ret (if (zero? i) "" " ") (.toString (aget u8 i) 8)))] + (case sg + "117 124 124 117" "font/otf" + "0 1 0 0" "font/ttf" + "167 117 106 106" "font/woff" + "167 117 106 62" "font/woff2" + nil)))) + +(defn- parse-font + "Associates the opentype.js font object for TTF/OTF/WOFF blobs. + + Takes a `read-blob` result map. Passes `{:error ...}` maps through + untouched. WOFF2 blobs cannot be parsed by opentype.js, so they keep + `:font nil` and metadata is derived from the filename later. Any other + blob is parsed with opentype.js; when parsing fails an + `{:error filename}` map is returned so the caller can notify the user + instead of silently dropping the file." + [{:keys [error data type name] :as params}] + (cond + (some? error) + params + + (= type "font/woff2") + ;; woff2 cannot be parsed by opentype.js, extract metadata from filename + (assoc params :font nil) + + :else + (try + (assoc params :font (ot/parse data)) + (catch :default _e + (log/warn :msg (str/fmt "skipping file %s, unsupported format" name)) + {:error name})))) + +(defn- prepare-font + "Builds a ready-to-upload font entry from a parsed blob. + + Never throws for missing font metadata: a nil subfamily (opentype.js + returns nil when the font carries no English preferred/subfamily + names, as seen with some Fontshare families) defaults to regular + weight/style, and missing vertical-metric tables default to no + height warning." + [{:keys [font type name data] :as _params}] + (let [name (or name "")] + (if (some? font) + ;; Font was parsed with opentype.js (ttf, otf, woff) + (let [family (or (.getEnglishName ^js font "preferredFamily") + (.getEnglishName ^js font "fontFamily") + "") + variant (or (.getEnglishName ^js font "preferredSubfamily") + (.getEnglishName ^js font "fontSubfamily") + "") + + ;; Vertical metrics determine the baseline in a text and the space between lines of + ;; text. For historical reasons, there are three pairs of ascender/descender + ;; values, known as hhea, OS/2 and uSWin metrics. Depending on the font, operating + ;; system and application a different set will be used to render text on the + ;; screen. On Mac, Safari and Chrome use the hhea values to render text. Firefox + ;; will respect the useTypoMetrics setting and will use the OS/2 if it is set. If + ;; the useTypoMetrics is not set, Firefox will also use metrics from the hhea + ;; table. On Windows, all browsers use the usWin metrics, but respect the + ;; useTypoMetrics setting and if set will use the OS/2 values. + + hhea (some-> ^js font .-tables .-hhea) + os2 (some-> ^js font .-tables .-os2) + + hhea-ascender (some-> ^js hhea .-ascender abs) + hhea-descender (some-> ^js hhea .-descender abs) + + win-ascent (some-> ^js os2 .-usWinAscent abs) + win-descent (some-> ^js os2 .-usWinDescent abs) + + os2-ascent (some-> ^js os2 .-sTypoAscender abs) + os2-descent (some-> ^js os2 .-sTypoDescender abs) + + ;; useTypoMetrics can be read from the 7th bit + f-selection (some-> ^js os2 .-fsSelection (bit-test 7)) + + height-warning? (boolean + (and hhea-ascender hhea-descender win-ascent win-descent + (or (not= hhea-ascender win-ascent) + (not= hhea-descender win-descent) + (and f-selection os2-ascent os2-descent + (or (not= hhea-ascender os2-ascent) + (not= hhea-descender os2-descent)))))) + data (js/Uint8Array. data)] + {:content {:data data + :name name + :type type} + :font-family (or family "") + :font-weight (cm/parse-font-weight variant) + :font-style (cm/parse-font-style variant) + :variant-name variant + :height-warning? height-warning?}) + ;; Font could not be parsed (woff2), extract metadata from filename + (let [base-name (str/replace name #"\.[^.]+$" "") + ;; Strip known weight/style tokens and separators to derive family name + ;; Use word boundaries to avoid matching substrings (e.g. "Boldini" should not match "bold") + raw-family-name (-> base-name + (str/replace #"(?i)(^|[-_\s])(extra\s*black|ultra\s*black|extra\s*bold|ultra\s*bold|semi\s*bold|demi\s*bold|extra\s*light|ultra\s*light|hairline|thin|light|normal|regular|medium|bold|black|heavy|solid|italic)([-_\s]|$)" "$1$3") + (str/replace #"[-_\s]+" " ") + (str/trim)) + family-name (if (str/blank? raw-family-name) base-name raw-family-name) + data (js/Uint8Array. data)] + {:content {:data data + :name name + :type type} + :font-family family-name + :font-weight (cm/parse-font-weight base-name) + :font-style (cm/parse-font-style base-name) + :height-warning? false})))) + (defn process-upload "Given a seq of blobs and the team id, creates a ready-to-use fonts map with temporal ID's associated to each font entry. @@ -122,71 +244,16 @@ Each font entry's `:data` is a map of `{mtype -> ArrayBuffer}`. The raw `ArrayBuffer` is kept as-is so that `upload-font-variant` can wrap it in a `Blob` and hand it directly to `upload-blob-chunked` - without any intermediate client-side chunking." + without any intermediate client-side chunking. + + Files that cannot be read, carry an unknown signature, fail + opentype.js parsing, or fail entry preparation are collected and + reported with a `bad-font` notification instead of silently dropped, + so the user always gets feedback. The returned observable never + errors for per-file failures; it emits the map of successfully + processed fonts (possibly empty)." [blobs team-id] - (letfn [(prepare [{:keys [font type name data] :as params}] - (if font - ;; Font was parsed with opentype.js (ttf, otf, woff) - (let [family (or (.getEnglishName ^js font "preferredFamily") - (.getEnglishName ^js font "fontFamily")) - variant (or (.getEnglishName ^js font "preferredSubfamily") - (.getEnglishName ^js font "fontSubfamily")) - - ;; Vertical metrics determine the baseline in a text and the space between lines of - ;; text. For historical reasons, there are three pairs of ascender/descender - ;; values, known as hhea, OS/2 and uSWin metrics. Depending on the font, operating - ;; system and application a different set will be used to render text on the - ;; screen. On Mac, Safari and Chrome use the hhea values to render text. Firefox - ;; will respect the useTypoMetrics setting and will use the OS/2 if it is set. If - ;; the useTypoMetrics is not set, Firefox will also use metrics from the hhea - ;; table. On Windows, all browsers use the usWin metrics, but respect the - ;; useTypoMetrics setting and if set will use the OS/2 values. - - hhea-ascender (abs (-> ^js font .-tables .-hhea .-ascender)) - hhea-descender (abs (-> ^js font .-tables .-hhea .-descender)) - - win-ascent (abs (-> ^js font .-tables .-os2 .-usWinAscent)) - win-descent (abs (-> ^js font .-tables .-os2 .-usWinDescent)) - - os2-ascent (abs (-> ^js font .-tables .-os2 .-sTypoAscender)) - os2-descent (abs (-> ^js font .-tables .-os2 .-sTypoDescender)) - - ;; useTypoMetrics can be read from the 7th bit - f-selection (-> ^js font .-tables .-os2 .-fsSelection (bit-test 7)) - - height-warning? (or (not= hhea-ascender win-ascent) - (not= hhea-descender win-descent) - (and f-selection (or - (not= hhea-ascender os2-ascent) - (not= hhea-descender os2-descent)))) - data (js/Uint8Array. data)] - {:content {:data data - :name name - :type type} - :font-family (or family "") - :font-weight (cm/parse-font-weight variant) - :font-style (cm/parse-font-style variant) - :variant-name variant - :height-warning? height-warning?}) - ;; Font could not be parsed (woff2), extract metadata from filename - (let [base-name (str/replace name #"\.[^.]+$" "") - ;; Strip known weight/style tokens and separators to derive family name - ;; Use word boundaries to avoid matching substrings (e.g. "Boldini" should not match "bold") - raw-family-name (-> base-name - (str/replace #"(?i)(^|[-_\s])(extra\s*black|ultra\s*black|extra\s*bold|ultra\s*bold|semi\s*bold|demi\s*bold|extra\s*light|ultra\s*light|hairline|thin|light|normal|regular|medium|bold|black|heavy|solid|italic)([-_\s]|$)" "$1$3") - (str/replace #"[-_\s]+" " ") - (str/trim)) - family-name (if (str/blank? raw-family-name) base-name raw-family-name) - data (js/Uint8Array. data)] - {:content {:data data - :name name - :type type} - :font-family family-name - :font-weight (cm/parse-font-weight base-name) - :font-style (cm/parse-font-style base-name) - :height-warning? false}))) - - (join [res {:keys [content] :as font}] + (letfn [(join [res {:keys [content] :as font}] (let [key-fn (juxt :font-family :font-weight :font-style) existing (d/seek #(= (key-fn font) (key-fn %)) (vals res))] (if existing @@ -206,60 +273,42 @@ (:data content)}) (dissoc :content))))))) - (parse-mtype [ba] - (let [u8 (js/Uint8Array. ba 0 4) - sg (areduce u8 i ret "" (str ret (if (zero? i) "" " ") (.toString (aget u8 i) 8)))] - (case sg - "117 124 124 117" "font/otf" - "0 1 0 0" "font/ttf" - "167 117 106 106" "font/woff" - "167 117 106 62" "font/woff2"))) - - (parse-font [{:keys [data type name] :as params}] - (if (= type "font/woff2") - ;; woff2 cannot be parsed by opentype.js, extract metadata from filename - (assoc params :font nil) - (try - (assoc params :font (ot/parse data)) - (catch :default _e - (log/warn :msg (str/fmt "skipping file %s, unsupported format" name)) - nil)))) - (read-blob [blob] - (->> (wa/read-file-as-array-buffer blob) - (rx/map (fn [data] - {:data data - :name (.-name blob) - :type (parse-mtype data)})) - (rx/catch (fn [] - (rx/of {:error (.-name blob)}))) - (rx/mapcat (fn [{:keys [type, error] :as font}] - (if (or type error) - (rx/of font) - (rx/empty))))))] + (let [blob-name (.-name blob)] + (->> (wa/read-file-as-array-buffer blob) + (rx/map (fn [data] + {:data data + :name blob-name + :type (parse-font-mtype data)})) + (rx/catch (fn [] + (rx/of {:error blob-name}))) + (rx/mapcat (fn [{:keys [type error] :as font}] + (if (or type error) + (rx/of font) + ;; Unknown signature: report it instead of silently dropping it. + (rx/of {:error blob-name})))))))] - (let [fonts (->> (rx/from blobs) - (rx/mapcat read-blob)) - errors (->> fonts - (rx/filter #(some? (:error %))) - (rx/reduce (fn [acc font] - (conj acc (str "'" (:error font) "'"))) - []))] - - (rx/subscribe errors - #(when - (not-empty %) - (st/emit! - (ntf/error - (if (> (count %) 1) - (tr "errors.bad-font-plural" (str/join ", " %)) - (tr "errors.bad-font" (first %))))))) - (->> fonts - (rx/filter #(nil? (:error %))) - (rx/map parse-font) - (rx/filter some?) - (rx/map prepare) - (rx/reduce join {}))))) + (->> (rx/from blobs) + (rx/mapcat read-blob) + (rx/map parse-font) + (rx/filter some?) + (rx/reduce (fn [acc item] + (if (:error item) + (update acc :errors conj (str "'" (:error item) "'")) + (try + (update acc :fonts #(join % (prepare-font item))) + (catch :default cause + (log/warn :msg "skipping font file" :cause cause :name (:name item)) + (update acc :errors conj (str "'" (:name item) "'")))))) + {:fonts {} :errors []}) + (rx/mapcat (fn [{:keys [fonts errors]}] + (when (seq errors) + (st/emit! + (ntf/error + (if (> (count errors) 1) + (tr "errors.bad-font-plural" (str/join ", " errors)) + (tr "errors.bad-font" (first errors)))))) + (rx/of fonts)))))) (defn- calculate-family-to-id-mapping [existing] diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 703a162e25..5574f21572 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -114,6 +114,7 @@ (rx/subs! (fn [result] (swap! fonts* df/merge-and-group-fonts installed-fonts result)) (fn [error] + (st/emit! (ntf/error (tr "errors.generic"))) (js/console.error "error" error)))))) on-upload diff --git a/frontend/test/frontend_tests/fonts_test.cljs b/frontend/test/frontend_tests/fonts_test.cljs index 11ba4d9573..dad9adf043 100644 --- a/frontend/test/frontend_tests/fonts_test.cljs +++ b/frontend/test/frontend_tests/fonts_test.cljs @@ -6,13 +6,18 @@ (ns frontend-tests.fonts-test (:require + [app.common.uuid :as uuid] + [app.main.data.fonts :as df] [app.main.fonts :as fonts] + [app.main.store :as st] [app.util.globals :as globals] [app.util.http :as http] + [app.util.webapi :as wa] [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.mock :as mock] + [potok.v2.core :as ptk])) (def sample-font {:id "sourcesanspro" @@ -244,3 +249,105 @@ ;; Trailing settle: the body is synchronous, but `with-mocks*` ;; evaluates to a promise, so the body must settle one. (await (async/settle)))))) + +;; --- custom font upload ------------------------------------------------------ +;; +;; Regression tests for https://github.com/penpot/penpot/issues/12304: +;; uploading a font whose opentype.js names are missing (nil subfamily, +;; as seen with some Fontshare families) crashed `process-upload` with +;; `re-seq must match against a string` and left the user with no +;; feedback. Unknown signatures and unparseable files were silently +;; dropped as well. + +(defn- bytes->buffer + [bytes] + (.-buffer (js/Uint8Array. (clj->js bytes)))) + +(defn- fake-ot-font + [{:keys [family variant hhea os2]}] + (let [names {"preferredFamily" family + "fontFamily" family + "preferredSubfamily" variant + "fontSubfamily" variant}] + #js {:getEnglishName (fn [k] (get names k)) + :tables #js {:hhea hhea :os2 os2}})) + +(t/deftest parse-font-mtype-detects-font-signatures + (t/is (= "font/ttf" (#'df/parse-font-mtype (bytes->buffer [0 1 0 0])))) + (t/is (= "font/otf" (#'df/parse-font-mtype (bytes->buffer [79 84 84 79])))) + (t/is (= "font/woff" (#'df/parse-font-mtype (bytes->buffer [119 79 70 70])))) + (t/is (= "font/woff2" (#'df/parse-font-mtype (bytes->buffer [119 79 70 50])))) + (t/is (nil? (#'df/parse-font-mtype (bytes->buffer [1 2 3 4])))) + (t/is (nil? (#'df/parse-font-mtype nil))) + (t/is (nil? (#'df/parse-font-mtype (bytes->buffer [0 1]))))) + +(t/deftest prepare-font-defaults-missing-subfamily + (let [font (fake-ot-font {:family "Satoshi" + :variant nil + :hhea #js {:ascender 1000 :descender 300} + :os2 #js {:usWinAscent 1000 :usWinDescent 300 + :sTypoAscender 800 :sTypoDescender 200 + :fsSelection 0}}) + entry (#'df/prepare-font {:font font + :type "font/otf" + :name "Satoshi-Regular.otf" + :data (bytes->buffer [79 84 84 79 0 0 0 0])})] + (t/is (= "Satoshi" (:font-family entry))) + (t/is (= 400 (:font-weight entry))) + (t/is (= "normal" (:font-style entry))) + (t/is (false? (:height-warning? entry))))) + +(t/deftest prepare-font-tolerates-missing-tables + (let [font (fake-ot-font {:family "Satoshi" :variant "Regular" :hhea nil :os2 nil}) + entry (#'df/prepare-font {:font font + :type "font/ttf" + :name "Satoshi-Regular.ttf" + :data (bytes->buffer [0 1 0 0 0 0 0 0])})] + (t/is (= "Satoshi" (:font-family entry))) + (t/is (false? (:height-warning? entry))))) + +(t/deftest parse-font-returns-error-on-unparseable + (t/testing "read errors pass through" + (t/is (= {:error "x.ttf"} (#'df/parse-font {:error "x.ttf"})))) + (t/testing "woff2 keeps a nil font for filename-based metadata" + (let [result (#'df/parse-font {:data (bytes->buffer [119 79 70 50]) + :type "font/woff2" + :name "Satoshi-Bold.woff2"})] + (t/is (nil? (:font result))) + (t/is (nil? (:error result))))) + (t/testing "opentype.js failures become errors instead of silent drops" + (let [garbage (bytes->buffer (into [0 1 0 0] (repeat 96 0))) + result (#'df/parse-font {:data garbage :type "font/ttf" :name "bad.ttf"})] + (t/is (= "bad.ttf" (:error result)))))) + +(t/deftest ^:async process-upload-reports-unknown-signature + (let [team-id (uuid/next) + blob #js {:name "mystery.bin"} + emitted (atom []) + result (atom nil)] + (await (mock/with-mocks* + {wa/read-file-as-array-buffer (mock/stub (fn [_] (rx/of (bytes->buffer [1 2 3 4])))) + st/emit! (mock/stub (fn [& evts] (swap! emitted into evts) nil))} + (await (async/observe (df/process-upload [blob] team-id) + :on-next #(reset! result %))))) + (t/is (= {} @result)) + (t/is (= 1 (count @emitted))) + (t/is (= :app.main.data.notifications/show (ptk/type (first @emitted)))))) + +(t/deftest ^:async process-upload-woff2-succeeds-from-filename + (let [team-id (uuid/next) + blob #js {:name "Satoshi-Bold.woff2"} + emitted (atom []) + result (atom nil)] + (await (mock/with-mocks* + {wa/read-file-as-array-buffer (mock/stub (fn [_] (rx/of (bytes->buffer [119 79 70 50 0 0 0 0])))) + st/emit! (mock/stub (fn [& evts] (swap! emitted into evts) nil))} + (await (async/observe (df/process-upload [blob] team-id) + :on-next #(reset! result %))) + (await (async/settle)))) + (t/is (= 1 (count (vals @result)))) + (let [entry (first (vals @result))] + (t/is (= "Satoshi" (:font-family entry))) + (t/is (= 700 (:font-weight entry))) + (t/is (= "normal" (:font-style entry)))) + (t/is (empty? @emitted)))) From 01aacde0a889cd2173b25e7f2b8909ef2675f0e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 16 Oct 2026 14:01:48 +0200 Subject: [PATCH 038/167] :bug: Refresh board bounds after toggling clip content in WASM SVG export (#12363) --- render-wasm/src/shapes.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 940e3d724b..3f75443a16 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -416,6 +416,7 @@ impl Shape { pub fn set_clip(&mut self, value: bool) { self.clip_content = value; + self.invalidate_extrect(); } pub fn set_rotation(&mut self, angle: f32) { From 4b668acf96acd030979f3848f8c77f7ebfbd976f Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 16 Oct 2026 13:07:34 +0200 Subject: [PATCH 039/167] :bug: Retry saves that a busy backend refused or rolled back A save waits for the lock that another save holds on the same file. A slow save, such as one on a large file, blocks the other saves to that file. The waiting request also holds the user's only save slot on the backend, so the user's saves to other files wait behind it too. Those saves ended in answers that stopped autosave for good: - a 429 after 60s, when no save slot freed up in time; - a 504 written by the backend after 300s, with :statement-timeout, when Postgres cancelled the wait for the lock. In both cases the backend never applied the save, so sending it again is safe. transient-error? now treats them as temporary, and the save takes the retry path. A statement timeout takes 300s per attempt, so the slow failure limit still bounds it. GET requests and error reports are unchanged: the check lives in the save pipeline, not in retryable-types. Both answers were reproduced in the devenv by holding the file lock from a separate database session. --- .../workspace-state-persistence-subtleties.md | 2 +- frontend/src/app/main/data/persistence.cljs | 22 +++++-- .../data/persistence_retry_test.cljs | 62 +++++++++++++++++++ .../frontend_tests/data/persistence_test.cljs | 14 +++++ 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md index 57094e1994..2ca4855b58 100644 --- a/.serena/memories/frontend/workspace-state-persistence-subtleties.md +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -26,7 +26,7 @@ - A terminal failure halts the queue on purpose: the edits stay queued so `wait-persisted-or-error` keeps rejecting, and `workspace/versions.cljs` relies on that to avoid snapshotting a file whose edits were never saved. `recover-persistence` lifts the halt when a file is opened again or the role changes. A transport failure still inside its retry window (`slow-retry-pending?`) is resent once (`resume-persistence true`), never released; any other halt emits `release-failed-file` (drops every queued commit of the file that failed) then `resume-persistence`. Opening a file also replays the queued edits of its version onto the loaded data (`pending-file-changes` → `restore-pending-changes` in `bundle-fetched`); a replay that throws keeps the server data and the queue. That is what unblocks the editor without a reload; because the queue is shared, dropping the failed file is also what lets other files' pending edits drain. `release-failed-file` runs at recovery time, never at failure time — clearing the error eagerly would make waiters resolve. - The split between `initialize-persistence-state` and `recover-persistence` is deliberate: initialization deliberately does NOT lift a halt, so undeliverable edits are released when the user acts, not when the view mounts. - **Every non-transport terminal save failure is permanent.** There is no "stale but valid" category: permission lost, file gone, and server-rejected changes all fail again on a resend. Losing edit permission is not temporary — the backend raises `:not-found` for it (`check-edition-permissions!` → `perms/make-check-fn`), and `:not-found` routes to the exception page. So `:save-permission-denied` (a client-side `can-edit` check) must never be resumed: replaying the save to discover that routes the user to the exception page. Release it instead. -- Save failures split transient vs terminal (`transient-error?`: the repo retryable types `:network`/`:offline`/`:bad-gateway`/`:service-unavailable`/`:gateway-error` plus `:invalid-save-response`; everything else is terminal). Terminal keeps the `:error` halt + `flash-persistence` path; transient enters a `:retrying` episode: the head commit stays queued and resends with backoff 2s/8s/20s (3 retries, then the `:error` path). A transient failure that outlasts the backoff arms `slow-retry-cycle`: one silent resend (`:recovering`) every `slow-retry-delay-ms` (30s) until a save lands, `retry-give-up-ms` (24h since `:failing-since`) passes, or the head commit collects `max-slow-failures` (3) attempts that failed after `slow-failure-ms` (90s) or more (`:slow-failures`, counted in `persistence-transient-failure` from the attempt's elapsed time): a gateway timeout may leave the backend applying the save, so a save that keeps failing slowly must not be replayed for a day, while fast failures (offline) never count; the cycle stops on `::resume-persistence`, `::initialize-persistence` and `finalize-workspace`. Leaving the workspace also emits `suspend-retries` (`:suspended` in `:persistence`, drops `:retry-token` so a pending backoff timer goes stale): no backoff, slow cycle or `online` resend runs outside the workspace; only `workspace-initialized` (not a role change) lifts it via `recover-persistence` (`opened-file?`), which also resends a `:retrying` episode whose backoff was cut short. Its toast is `errors.save-still-retrying` without a reload link; a landing save hides the failure toast (`errors/persistence-failed-tag`). Replaying a save is safe because changes are idempotent, so there is no unknown-outcome guard: a commit whose earlier request left `active-requests` is simply sent again under the same `:commit-id`; only a request still in flight blocks a resend (`:in-flight` stays silent). Tests hold the slow cycle by stubbing `rx/timer` to never fire for `slow-retry-delay-ms`, or instant timers loop forever. Retry timers carry the episode token; a superseded token stays silent. Status stays `:retrying` through re-entries (`next-status` refuses `:pending`/`:saving` from it); waiters (`wait-persisted-or-error`) wait through it and reject only on `:error`. +- Save failures split transient vs terminal (`transient-error?`: the repo retryable types `:network`/`:offline`/`:bad-gateway`/`:service-unavailable`/`:gateway-error`, plus `:invalid-save-response`, plus save-only `busy-server-error?`: `:rate-limit` (429 from rlimit/climit — a save parked on another save's file lock holds the profile's single `:update-file/by-profile` permit, so the user's saves to other files 429 after 60 s) and backend-written 504 `:server-error` with `:statement-timeout`/`:idle-in-transaction-timeout` (Postgres cancels the lock wait at 300 s, before nginx; rolled back, so resend is safe). Kept out of `rp/retryable-types` on purpose so GETs do not retry them; everything else is terminal). Terminal keeps the `:error` halt + `flash-persistence` path; transient enters a `:retrying` episode: the head commit stays queued and resends with backoff 2s/8s/20s (3 retries, then the `:error` path). A transient failure that outlasts the backoff arms `slow-retry-cycle`: one silent resend (`:recovering`) every `slow-retry-delay-ms` (30s) until a save lands, `retry-give-up-ms` (24h since `:failing-since`) passes, or the head commit collects `max-slow-failures` (3) attempts that failed after `slow-failure-ms` (90s) or more (`:slow-failures`, counted in `persistence-transient-failure` from the attempt's elapsed time): a gateway timeout may leave the backend applying the save, so a save that keeps failing slowly must not be replayed for a day, while fast failures (offline) never count; the cycle stops on `::resume-persistence`, `::initialize-persistence` and `finalize-workspace`. Leaving the workspace also emits `suspend-retries` (`:suspended` in `:persistence`, drops `:retry-token` so a pending backoff timer goes stale): no backoff, slow cycle or `online` resend runs outside the workspace; only `workspace-initialized` (not a role change) lifts it via `recover-persistence` (`opened-file?`), which also resends a `:retrying` episode whose backoff was cut short. Its toast is `errors.save-still-retrying` without a reload link; a landing save hides the failure toast (`errors/persistence-failed-tag`). Replaying a save is safe because changes are idempotent, so there is no unknown-outcome guard: a commit whose earlier request left `active-requests` is simply sent again under the same `:commit-id`; only a request still in flight blocks a resend (`:in-flight` stays silent). Tests hold the slow cycle by stubbing `rx/timer` to never fire for `slow-retry-delay-ms`, or instant timers loop forever. Retry timers carry the episode token; a superseded token stays silent. Status stays `:retrying` through re-entries (`next-status` refuses `:pending`/`:saving` from it); waiters (`wait-persisted-or-error`) wait through it and reject only on `:error`. - One reconnect notice per episode: sticky toast tagged `:persistence-reconnecting` (single-toast store, re-show replaces), hidden by tag on drain (`:saved`) and on terminal failure; recovery is silent. Header indicator has a `:retrying` state (`workspace.header.retrying`). - Resume triggers: backoff timer and the browser `online` event (guarded by `exists? js/window`; re-enters the runner only for a live `:retrying` episode). New local edits during `:retrying` only join the queue: the episode keeps its `:run-id`, so `append-commit` does not re-enter, and the live runner (still waiting on the head's `commit-persisted`) sends them after the head. Re-entering on edits would bypass the backoff and spend an attempt per batched commit. - Tests instant-trigger retries by stubbing `rx/timer` (recording delays to assert the schedule); dynamic bindings do not survive `await` continuations, so no dynamic var for delays. diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 76031e7c85..0a857da12c 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -138,17 +138,31 @@ (#{:error :saved} status) (dissoc :run-id :last-progress-at :stall-reported)))))))) +(def ^:private busy-server-codes + "Server error codes for a database timeout that rolled the save back." + #{:statement-timeout :idle-in-transaction-timeout}) + +(defn- busy-server-error? + "True when the backend was too busy to apply the save: it refused it + (429) or a database timeout rolled it back. Resending it is safe." + [{:keys [type cause-type code]}] + (let [type (or cause-type type)] + (or (= :rate-limit type) + (and (= :server-error type) + (contains? busy-server-codes code))))) + (defn transient-error? "True when a save failure is worth retrying with backoff: a transient transport failure (from the shared `repo/retryable-types` set, which says - nothing about the edits themselves) or an unusable save response. - Everything else (auth, validation, state) is terminal and keeps the - `:error` path. Checked against both `:type` and `:cause-type` because + nothing about the edits themselves), a busy backend, or an unusable save + response. Everything else (auth, validation, state) is terminal and keeps + the `:error` path. Checked against both `:type` and `:cause-type` because `persistence-failed` wraps the original cause under `:type :persistence`." - [{:keys [type cause-type code]}] + [{:keys [type cause-type code] :as error}] (boolean (or (contains? rp/retryable-types type) (contains? rp/retryable-types cause-type) + (busy-server-error? error) (= :invalid-save-response code)))) (def ^:private retry-delays-ms diff --git a/frontend/test/frontend_tests/data/persistence_retry_test.cljs b/frontend/test/frontend_tests/data/persistence_retry_test.cljs index 6bbfbedf47..1eba1bd1b5 100644 --- a/frontend/test/frontend_tests/data/persistence_retry_test.cljs +++ b/frontend/test/frontend_tests/data/persistence_retry_test.cljs @@ -199,6 +199,68 @@ (t/deftest ^:async cloudflare-origin-timeout-save-is-retried (await (check-gateway-response-retries 524))) +(defn- check-busy-server-response-retries + "Answers the first send with `response`, a refusal the backend wrote + because it was busy, built by the real `rp/handle-response`, and the retry + with a valid revision. The save was never applied, so it must take the + retry path and land, never halt in `:error`." + [response] + (let [calls (atom 0)] + (with-persistence + (^:async fn [{:keys [file-id failures requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub instant-backoff)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + (str "a busy " (:status response) " is retried and the save lands"))) + (t/is (= 2 (count @requests)) "the busy answer plus one retry") + (t/is (empty? @failures) "a retried busy answer never reaches the failure toast")))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rp/handle-response (assoc response :uri "https://penpot.example.com/api/main/methods/update-file")) + (rx/of {:revn 1})))))) + +;; Variant: the save waited too long for a concurrency slot (no body). +(t/deftest ^:async concurrency-limit-save-is-retried + (await (check-busy-server-response-retries {:status 429 :body nil}))) + +;; Variant: the database cancelled the save while it waited for the file lock. +(t/deftest ^:async statement-timeout-save-is-retried + (await (check-busy-server-response-retries + {:status 504 + :body {:type :server-error + :code :statement-timeout + :hint "The operation took too long and was cancelled"}}))) + +;; Variant: the database ended the save's transaction after it sat idle. +(t/deftest ^:async idle-transaction-timeout-save-is-retried + (await (check-busy-server-response-retries + {:status 504 + :body {:type :server-error + :code :idle-in-transaction-timeout + :hint "The transaction was idle too long and was cancelled"}}))) + +;; Scenario: the backend fails the save for any other reason. Proves: only +;; the busy-server codes retry; a server defect stops saving at once. +(t/deftest ^:async other-server-errors-stay-terminal + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub instant-backoff)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the save fails")) + (t/is (= :server-error (get-in @store [:persistence :error :cause-type]))) + (t/is (= 1 (count @requests)) "no retry")))) + (fn [_ _] + (rp/handle-response {:status 500 + :body {:type :server-error :code :unhandled} + :uri "https://penpot.example.com/api/main/methods/update-file"}))))) + ;; Scenario: every send fails transiently. The 2s / 8s / 20s retries fire ;; and then the failure falls through to the exact terminal path: `:error` ;; carrying the cause, queue intact, one flash. Proves: the budget bounds diff --git a/frontend/test/frontend_tests/data/persistence_test.cljs b/frontend/test/frontend_tests/data/persistence_test.cljs index 9735771305..f6555fa165 100644 --- a/frontend/test/frontend_tests/data/persistence_test.cljs +++ b/frontend/test/frontend_tests/data/persistence_test.cljs @@ -693,6 +693,19 @@ (t/is (not (dps/transient-error? {:type :persistence :code :save-permission-denied}))) (t/is (not (dps/transient-error? {})))) +;; A busy backend refused or rolled back the save, so it is safe to resend: +;; a rate or concurrency limit, or a database timeout. Any other server +;; error stays terminal. +(t/deftest busy-server-classification + (t/is (dps/transient-error? {:type :rate-limit})) + (t/is (dps/transient-error? {:type :persistence :cause-type :rate-limit :code :rate-limited})) + (t/is (dps/transient-error? {:type :server-error :code :statement-timeout})) + (t/is (dps/transient-error? {:type :persistence :cause-type :server-error :code :statement-timeout})) + (t/is (dps/transient-error? {:type :persistence :cause-type :server-error :code :idle-in-transaction-timeout})) + (t/is (not (dps/transient-error? {:type :server-error}))) + (t/is (not (dps/transient-error? {:type :persistence :cause-type :server-error :code :unhandled}))) + (t/is (not (dps/transient-error? {:type :persistence :cause-type :validation :code :statement-timeout})))) + ;; The transient set is owned by `repo/retryable-types`: every transport ;; type retryable at the HTTP layer must also be transient for the save ;; pipeline, in both the direct and the wrapped (`persistence-failed`) @@ -988,3 +1001,4 @@ (await (async/wait-for #(not (contains? @tasks :persistence)) "the release turns the guard off")) (t/is (= :saved (get-in @store [:persistence :status]))))))) + From 781ac534e08e164afa8e26511e15cc94ea3e9e85 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 16 Oct 2026 13:07:34 +0200 Subject: [PATCH 040/167] :sparkles: Count save failures with a save-failed audit event We only learn how often users lose work from support tickets. Each time the user is told a save failed, persistence now emits a save-failed audit event with the cause type, the code, whether the queue is still retrying and how many changes are waiting. It follows the same rule as the failure toast: a repeated failure of the slow retry cycle is not counted again. Failures handed to another handler, such as an expired session, are counted too, since they also stop the save. --- .../workspace-state-persistence-subtleties.md | 1 + frontend/src/app/main/data/persistence.cljs | 45 +++++++++++++------ .../frontend_tests/data/persistence_test.cljs | 33 ++++++++++++++ 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md index 2ca4855b58..66c19f551d 100644 --- a/.serena/memories/frontend/workspace-state-persistence-subtleties.md +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -32,6 +32,7 @@ - Tests instant-trigger retries by stubbing `rx/timer` (recording delays to assert the schedule); dynamic bindings do not survive `await` continuations, so no dynamic var for delays. - `save-request-timeout-ms` is 7 minutes and wraps the `update-file` request in `rx/timeout`, failing it as a transient `:network` error so the retry path resends instead of blocking the queue forever. It is deliberately longer than `saving-stall-timeout-ms` (5 min) so the stalled report fires before the retry. - `sync-unload-guard` keeps `:persistence` in `st/ongoing-tasks` while edits are buffered or queued, which is what holds the browser leave-page warning on. It syncs on `::append-commit`, `::discard-commit` and `::release-failed-file` (every queue change). It is the one effect that must stay in `store.cljs`'s `omitset`, and `store.cljs`'s `ongoing-tasks` watcher is guarded by `exists? js/window` because tests run without a window. +- `persistence-failed` emits a `save-failed` audit event (`:cause-type :code :retrying :queued-commits`) whenever the user is notified (`notify-failure?`, same rule as the toast), including delegated auth/not-found failures. - Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50. - Undo/redo are ignored while a normal editor/drawing interaction is active, except grid-layout edition handles undo through this path. - After local commits and when render-wasm is active, text shapes get derived `:position-data` recomputed in a separate commit tagged `#{:position-data}`; that tag is excluded from the position-data watcher to avoid loops. diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 0a857da12c..f7f7779478 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -14,6 +14,7 @@ [app.common.uuid :as uuid] [app.main.data.changes :as dch] [app.main.data.common :as-alias dc] + [app.main.data.event :as ev] [app.main.data.helpers :as dsh] [app.main.data.notifications :as ntf] [app.main.data.workspace :as-alias dw] @@ -313,6 +314,23 @@ (update :queue #(into (empty %) (remove dropped) %)) (update :index #(apply dissoc % dropped))))))) +(defn- notify-failure? + "False for a failed attempt of the slow cycle: it repeats a failure the + user was already told about." + [pstate] + (not (and (:recovering pstate) + (slow-retry-pending? pstate)))) + +(defn- save-failed-event + "Audit event for a save failure the user is told about, so failed saves + can be counted." + [{:keys [error queue] :as pstate}] + (ev/event {::ev/name "save-failed" + :cause-type (:cause-type error) + :code (:code error) + :retrying (slow-retry-pending? pstate) + :queued-commits (count queue)})) + (defn- persistence-failed [commit-id cause] (ptk/reify ::persistence-failed @@ -336,14 +354,16 @@ ptk/WatchEvent (watch [_ state stream] - (rx/merge - ;; The terminal toast supersedes the reconnect notice; hide it - ;; explicitly instead of relying on the single-toast replacement. - (rx/of (ptk/data-event ::error cause) - (ntf/hide :tag reconnecting-tag)) - ;; A transport failure that outlasts the backoff can still pass, so - ;; the queue keeps trying at a slow pace. - (let [pstate (:persistence state)] + (let [pstate (:persistence state)] + (rx/merge + ;; The terminal toast replaces the reconnect notice, hidden by tag. + (rx/of (ptk/data-event ::error cause) + (ntf/hide :tag reconnecting-tag)) + (if (notify-failure? pstate) + (rx/of (save-failed-event pstate)) + (rx/empty)) + ;; A transport failure that outlasts the backoff can still pass, so + ;; the queue keeps trying at a slow pace. (if (and (not (:suspended pstate)) (slow-retry-pending? pstate)) (slow-retry-cycle stream) @@ -351,13 +371,10 @@ ptk/EffectEvent (effect [_ state _] - ;; A failed slow attempt repeats a failure the user was already warned - ;; about. Report without invoking global handlers that may reload the - ;; file or navigate away before the user can recover the retained - ;; changes. + ;; Notifies without the global handlers, which may reload the file or + ;; navigate away and lose the retained changes. (let [pstate (:persistence state)] - (when-not (and (:recovering pstate) - (slow-retry-pending? pstate)) + (when (notify-failure? pstate) (errors/flash-persistence cause :retrying (slow-retry-pending? pstate))))))) (defn- persistence-transient-failure diff --git a/frontend/test/frontend_tests/data/persistence_test.cljs b/frontend/test/frontend_tests/data/persistence_test.cljs index f6555fa165..80ba7608bd 100644 --- a/frontend/test/frontend_tests/data/persistence_test.cljs +++ b/frontend/test/frontend_tests/data/persistence_test.cljs @@ -21,6 +21,7 @@ [app.common.types.shape :as cts] [app.common.uuid :as uuid] [app.main.data.changes :as dch] + [app.main.data.event :as ev] [app.main.data.persistence :as dps] [app.main.data.render-wasm :as drw] [app.main.data.workspace :as dw] @@ -1002,3 +1003,35 @@ "the release turns the guard off")) (t/is (= :saved (get-in @store [:persistence :status]))))))) +(defn- save-failed-events + "Starts collecting the `save-failed` audit events that `store` processes." + [store] + (let [events (atom [])] + (->> (ptk/input-stream store) + (rx/filter #(= ::ev/event (ptk/type %))) + (rx/map deref) + (rx/filter #(= "save-failed" (::ev/name %))) + (rx/subs! #(swap! events conj %))) + events)) + +;; Scenario: the backend rejects a save as invalid data. Proves: the user's +;; failure toast comes with one `save-failed` audit event that records why. +(t/deftest ^:async a-failed-save-emits-one-save-failed-event + (await + (with-persistence + (^:async fn [{:keys [file-id store]}] + (let [events (save-failed-events store)] + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the save fails")) + (await (async/wait-for #(seq @events) "the event is emitted")) + (await (async/settle)) + (t/is (= 1 (count @events))) + (t/is (= {:cause-type :validation + :code :referential-integrity + :retrying false + :queued-commits 1} + (select-keys (first @events) + [:cause-type :code :retrying :queued-commits]))))) + (fn [_ _] + (rx/throw (ex-info "invalid" {:type :validation :code :referential-integrity})))))) From a1e51aacba8c572894ef0b421a314eb92cdfe825 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 16 Oct 2026 13:07:36 +0200 Subject: [PATCH 041/167] :bug: Keep the workspace open when an export fails A failed export raised the exporter error to the global handler, which replaced the workspace with the error page. Users then reloaded and lost the changes that were not saved yet. - A failed single or multiple export now shows an error toast and keeps the workspace. Exporter defects still report as unhandled. - A failed multiple export request ends its progress listeners, so the leave-page warning it turned on is released. - Before exporting, the pending changes of the exported files are sent and the export waits up to 10s for them, instead of 400ms. Exports are built from the server copy, so if some changes are still unsaved a toast warns that the export may not include them. force-persist-files-and-wait waits only for the given files, so a stuck save of another file does not delay the export. --- .../src/app/main/data/exports/assets.cljs | 106 +++++++++++------ frontend/src/app/main/data/persistence.cljs | 24 ++++ .../data/exports_assets_test.cljs | 108 +++++++++++++++++- .../frontend_tests/data/persistence_test.cljs | 67 +++++++++++ frontend/translations/en.po | 8 ++ frontend/translations/es.po | 8 ++ 6 files changed, 286 insertions(+), 35 deletions(-) diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index b1ced71ad6..d0cf4597e0 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -13,11 +13,14 @@ [app.main.data.exports.wasm :as wasm.exports] [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.persistence :as dwp] + [app.main.errors :as errors] [app.main.features :as features] [app.main.repo :as rp] [app.main.store :as st] [app.util.dom :as dom] + [app.util.i18n :refer [tr]] [app.util.websocket :as ws] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -25,6 +28,37 @@ (def default-timeout 5000) +;; Longest wait for the pending changes of the exported files to be saved. +(def save-wait-timeout-ms 10000) + +(defn- export-file-ids + [exports] + (into #{} (keep :file-id) exports)) + +(defn- warn-unsaved-changes + "Warns that the export may miss the changes of `file-ids` not saved yet." + [file-ids] + (ptk/reify ::warn-unsaved-changes + ptk/WatchEvent + (watch [_ state _] + (when (dwp/unsaved-changes? state file-ids) + (rx/of (ntf/show {:content (tr "workspace.export.unsaved-changes") + :type :toast + :level :warning + :timeout 8000})))))) + +(defn- export-failed + "Shows an export failure as a toast, keeping the workspace open." + [cause] + (ptk/reify ::export-failed + ptk/EffectEvent + (effect [_ _ _] + (errors/flash :cause cause + :type (if (#{:server-error :internal} (:type (ex-data cause))) + :unhandled + :handled) + :hint (tr "errors.export-failed"))))) + (defn normalize-export [{:keys [object-id name] :as export}] (assoc export :name (if (str/blank? name) @@ -226,16 +260,16 @@ :wait true :is-wasm (wasm-export-enabled? state)})] (rx/concat - (dwp/force-persist-and-wait 400) + (dwp/force-persist-files-and-wait (export-file-ids [export]) save-wait-timeout-ms) + (rx/of (warn-unsaved-changes (export-file-ids [export]))) (->> (rp/cmd! :export params) (rx/map (fn [{:keys [filename mtype uri]}] (dom/trigger-download-uri filename mtype uri) (clear-export-state uuid/zero))) (rx/catch (fn [cause] - (rx/concat - (rx/of (clear-export-state uuid/zero)) - (rx/throw cause)))))))))))) + (rx/of (clear-export-state uuid/zero) + (export-failed cause)))))))))))) (defn request-multiple-export [{:keys [exports cmd name] @@ -246,6 +280,8 @@ ptk/WatchEvent (watch [_ state _] (let [resource-id (volatile! nil) + ;; Emits when the export request fails. + failed-s (rx/subject) profile-id (:profile-id state) ws-conn (:ws-conn state) params (cond-> @@ -263,46 +299,48 @@ (rx/map :payload) (rx/filter #(= :export-update (:type %))) (rx/filter #(= @resource-id (:resource-id %))) + (rx/take-until failed-s) (rx/share)) stopper (rx/filter #(or (= "ended" (:status %)) (= "error" (:status %))) - progress-stream)] + progress-stream) + + file-ids + (export-file-ids exports)] (swap! st/ongoing-tasks conj :export) - (rx/merge - ;; Force that all data is persisted; best effort. - (rx/of ::dwp/force-persist) + (rx/concat + (dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms) + (rx/of (warn-unsaved-changes file-ids)) + (rx/merge + ;; Updates the export state with each progress message, until + ;; shortly after the export ends or fails. + (->> progress-stream + (rx/map update-export-status) + (rx/take-until (rx/delay 500 stopper)) + (rx/finalize (fn [] + (swap! st/ongoing-tasks disj :export)))) - ;; Launch the exportation process and stores the resource id - ;; locally. - (->> (rp/cmd! :export params) - (rx/map (fn [{:keys [id] :as resource}] - (vreset! resource-id id) - (initialize-export-status exports cmd resource)))) + ;; Clears the export state a while after the export ends. + (->> progress-stream + (rx/filter #(= "ended" (:status %))) + (rx/take 1) + (rx/delay default-timeout) + (rx/map #(clear-export-state @resource-id)) + (rx/take-until (rx/delay 6000 stopper))) - ;; We proceed to update the export state with incoming - ;; progress updates. We delay the stopper for give some time - ;; to update the status with ended or errored status before - ;; close the stream. - (->> progress-stream - (rx/map update-export-status) - (rx/take-until (rx/delay 500 stopper)) - (rx/finalize (fn [] - (swap! st/ongoing-tasks disj :export)))) - - ;; We hide need to hide the ui elements of the export after - ;; some interval. We also delay a little bit more the stopper - ;; for ensure that after some security time, the stream is - ;; completely closed. - (->> progress-stream - (rx/filter #(= "ended" (:status %))) - (rx/take 1) - (rx/delay default-timeout) - (rx/map #(clear-export-state @resource-id)) - (rx/take-until (rx/delay 6000 stopper))))))))) + ;; Sends the export request and keeps its resource id. It goes + ;; last so a failure reaches the progress listeners above. + (->> (rp/cmd! :export params) + (rx/map (fn [{:keys [id] :as resource}] + (vreset! resource-id id) + (initialize-export-status exports cmd resource))) + (rx/catch (fn [cause] + (rx/push! failed-s cause) + (rx/of (export-failed cause)))))))))))) (defn request-export [{:keys [exports] :as params}] diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index f7f7779478..82e9013cb5 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -109,6 +109,30 @@ ([timeout-ms] (rx/concat (rx/of ::force-persist) (wait-persisted timeout-ms)))) +(defn- files-queued? + "True when the queue in `pstate` still holds changes of any of `file-ids`." + [{:keys [queue index]} file-ids] + (boolean (some #(contains? file-ids (get-in index [% :file-id])) queue))) + +(defn unsaved-changes? + "True when any of `file-ids` has changes the server does not have yet." + [state file-ids] + (files-queued? (:persistence state) file-ids)) + +(defn force-persist-files-and-wait + "Sends the pending changes and waits until no change of `file-ids` is + queued, saving fails, or `timeout-ms` passes. Never raises; check + `unsaved-changes?` afterwards." + [file-ids timeout-ms] + (rx/concat + (rx/of ::force-persist) + (->> (rx/from-atom refs/persistence {:emit-current-value? true}) + (rx/filter #(or (= :error (:status %)) + (not (files-queued? % file-ids)))) + (rx/take 1) + (rx/ignore) + (rx/timeout timeout-ms (rx/empty))))) + (defn- next-status "Refuses downgrades: a save in progress stays :saving, a failed save stays :error until persistence is resumed, and a retrying episode stays diff --git a/frontend/test/frontend_tests/data/exports_assets_test.cljs b/frontend/test/frontend_tests/data/exports_assets_test.cljs index d4ae9edea8..0caae3d10e 100644 --- a/frontend/test/frontend_tests/data/exports_assets_test.cljs +++ b/frontend/test/frontend_tests/data/exports_assets_test.cljs @@ -9,12 +9,15 @@ [app.common.uuid :as uuid] [app.main.data.exports.assets :as de] [app.main.data.persistence :as dwp] + [app.main.errors :as errors] [app.main.repo :as repo] [app.main.store :as st] [app.util.dom :as dom] + [app.util.i18n :as i18n] [app.util.websocket :as ws] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] [frontend-tests.helpers.events :as the] [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) @@ -55,7 +58,7 @@ (rx/of {:filename "export.png" :mtype "image/png" :uri "blob:export"}))) - dwp/force-persist-and-wait (mock/stub (fn [_] (rx/of ::force-persisted))) + dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/of ::force-persisted))) dom/trigger-download-uri (mock/stub (fn [& _] nil))} (fn [done'] (let [completed (fn [_state] @@ -97,3 +100,106 @@ (de/request-multiple-export {:exports exports}) :the/end))) done)))) + +(defn- export-store + "A store for the export tests that records store errors in `store-errors`." + [state store-errors] + (ptk/store {:state state :on-error #(swap! store-errors conj %)})) + +(defn- unsaved-state + "Test state whose save queue still holds a change of `file-id`." + [file-id] + (let [commit-id (uuid/next)] + (assoc (test-state) + :persistence {:queue (conj #queue [] commit-id) + :index {commit-id {:id commit-id :file-id file-id}}}))) + +;; Scenario: the exporter fails a single export. Proves: the user gets a +;; toast and stays in the workspace; the failure is not raised to the global +;; error handler, which would replace the workspace with an error page. +(t/deftest ^:async failed-simple-export-shows-a-toast-instead-of-raising + (let [store-errors (atom []) + flashed (atom []) + store (export-store (test-state) store-errors)] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [_ _] + (rx/throw (ex-info "exporter failed" + {:type :server-error + :code :unhandled})))) + dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + errors/flash (fn [& {:keys [hint type]}] + (swap! flashed conj [hint type])) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (de/request-simple-export {:export export})) + (await (async/wait-for #(seq @flashed) "the failure is shown")) + (await (async/settle)) + (t/is (= [["translated:errors.export-failed" :unhandled]] @flashed) + "an exporter defect reports as unhandled") + (t/is (empty? @store-errors) "nothing reaches the global error handler") + (t/is (nil? (:export @store)) "the export state is cleared"))))) + +;; Scenario: the export request of a multiple export fails. Proves: the user +;; gets a toast, and the leave-page warning the export turned on is released +;; even though no progress message will ever arrive. +(t/deftest ^:async failed-multiple-export-shows-a-toast-and-releases-the-leave-warning + (let [store-errors (atom []) + flashed (atom []) + tasks (atom #{}) + store (export-store (test-state) store-errors)] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [_ _] + (rx/throw (ex-info "exporter failed" + {:type :server-error})))) + dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + ws/get-rcv-stream (mock/stub (fn [_] (rx/subject))) + st/ongoing-tasks tasks + errors/flash (fn [& {:keys [hint]}] (swap! flashed conj hint)) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (de/request-multiple-export {:exports [(assoc export :enabled true)]})) + (await (async/wait-for #(seq @flashed) "the failure is shown")) + (await (async/wait-for #(not (contains? @tasks :export)) + "the leave-page warning is released")) + (t/is (= ["translated:errors.export-failed"] @flashed)) + (t/is (empty? @store-errors) "nothing reaches the global error handler"))))) + +;; Scenario: an export starts while its file has a change not saved yet. +;; Proves: the user is warned that the export may miss it, and the export +;; runs anyway. +(t/deftest ^:async export-warns-when-the-file-has-unsaved-changes + (let [file-id (uuid/next) + downloaded (atom false) + store (export-store (unsaved-state file-id) (atom []))] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [_ _] + (rx/of {:filename "export.png" + :mtype "image/png" + :uri "blob:export"}))) + dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + dom/trigger-download-uri (mock/stub (fn [& _] (reset! downloaded true))) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (de/request-simple-export {:export (assoc export :file-id file-id)})) + (await (async/wait-for #(true? @downloaded) "the export runs anyway")) + (t/is (= "translated:workspace.export.unsaved-changes" + (get-in @store [:notification :content]))) + (t/is (= :warning (get-in @store [:notification :level]))))))) + +;; Scenario: an export starts with every change of its file saved. Proves: +;; no warning is shown. +(t/deftest ^:async export-shows-no-warning-when-the-file-is-saved + (let [downloaded (atom false) + store (export-store (test-state) (atom []))] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [_ _] + (rx/of {:filename "export.png" + :mtype "image/png" + :uri "blob:export"}))) + dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + dom/trigger-download-uri (mock/stub (fn [& _] (reset! downloaded true)))} + (ptk/emit! store (de/request-simple-export {:export (assoc export :file-id (uuid/next))})) + (await (async/wait-for #(true? @downloaded) "the export runs")) + (await (async/settle)) + (t/is (nil? (:notification @store))))))) diff --git a/frontend/test/frontend_tests/data/persistence_test.cljs b/frontend/test/frontend_tests/data/persistence_test.cljs index 80ba7608bd..32150ad8dd 100644 --- a/frontend/test/frontend_tests/data/persistence_test.cljs +++ b/frontend/test/frontend_tests/data/persistence_test.cljs @@ -26,6 +26,7 @@ [app.main.data.render-wasm :as drw] [app.main.data.workspace :as dw] [app.main.errors :as errors] + [app.main.refs :as refs] [app.main.repo :as rp] [app.main.store :as st] [app.util.i18n :as i18n] @@ -33,6 +34,7 @@ [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] + [okulary.core :as l] [potok.v2.core :as ptk])) (t/deftest reopening-restores-only-the-retained-edits-of-the-loaded-file @@ -1003,6 +1005,71 @@ "the release turns the guard off")) (t/is (= :saved (get-in @store [:persistence :status]))))))) +(defn- wait-files-saved + "Emits into `store` an event that runs `force-persist-files-and-wait` for + `file-ids` and then flips `done`." + [store file-ids timeout-ms done] + (ptk/emit! store + (ptk/reify ::wait-files-saved + ptk/WatchEvent + (watch [_ _ _] + (rx/concat + (dps/force-persist-files-and-wait file-ids timeout-ms) + (rx/of (ptk/reify ::files-saved + ptk/EffectEvent + (effect [_ _ _] (reset! done true))))))))) + +;; Scenario: an edit waits in the buffer when an export asks for its file +;; to be saved. Proves: the wait sends the edit and only ends once the save +;; lands. +(t/deftest ^:async waiting-for-a-file-sends-and-waits-for-its-edits + (let [done (atom false)] + (await + (with-persistence + (^:async fn [{:keys [file-id response requests store]}] + (await + (mock/with-mocks* + {refs/persistence (l/derived :persistence store)} + (ptk/emit! store (local-commit file-id)) + (wait-files-saved store #{file-id} 60000 done) + (await (async/wait-for #(= 1 (count @requests)) "the edit is sent at once")) + (await (async/settle)) + (t/is (false? @done) "the wait holds while the save is in flight") + (rx/push! response {:revn 1}) + (await (async/wait-for #(true? @done) "the landed save ends the wait")) + (t/is (not (dps/unsaved-changes? @store #{file-id})))))))))) + +;; Scenario: a save of one file hangs while another file is exported. +;; Proves: the wait only covers the exported files. +(t/deftest ^:async waiting-for-a-file-ignores-other-files + (let [done (atom false)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {refs/persistence (l/derived :persistence store)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @requests)) "the other file's save hangs")) + (wait-files-saved store #{(uuid/next)} 60000 done) + (await (async/wait-for #(true? @done) "the wait ends at once"))))))))) + +;; Scenario: the file's save hangs past the wait. Proves: the wait gives up +;; without raising, and the file still reports unsaved changes. +(t/deftest ^:async waiting-for-a-file-gives-up-after-the-timeout + (let [done (atom false)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {refs/persistence (l/derived :persistence store)} + (ptk/emit! store (local-commit file-id)) + (wait-files-saved store #{file-id} 1 done) + (await (async/wait-for #(true? @done) "the wait times out")) + (t/is (= 1 (count @requests))) + (t/is (dps/unsaved-changes? @store #{file-id}))))))))) + (defn- save-failed-events "Starts collecting the `save-failed` audit events that `store` processes." [store] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index e08161f63b..4269991302 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1589,6 +1589,10 @@ msgstr "The email «%s» has many permanent bounce reports." msgid "errors.email-spam-or-permanent-bounces" msgstr "The email «%s» has been reported as spam or permanently bounce." +#: src/app/main/data/exports/assets.cljs +msgid "errors.export-failed" +msgstr "The export failed. Try again in a moment." + #: src/app/main/errors.cljs:391 msgid "errors.feature-mismatch" msgstr "" @@ -6919,6 +6923,10 @@ msgstr "Remove guide" msgid "workspace.debug.title" msgstr "Debugging tools" +#: src/app/main/data/exports/assets.cljs +msgid "workspace.export.unsaved-changes" +msgstr "Some of your latest changes are not saved yet, so this export may not include them." + #: src/app/main/ui/workspace/sidebar/layers.cljs:841 msgid "workspace.focus.focus-mode" msgstr "Focus mode" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 3807fd81d0..2c807ed06f 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1573,6 +1573,10 @@ msgstr "El correo electrónico «%s» tiene varios reportes de rebote permanente msgid "errors.email-spam-or-permanent-bounces" msgstr "El email «%s» tiene reportes de spam o de rebote permanente." +#: src/app/main/data/exports/assets.cljs +msgid "errors.export-failed" +msgstr "La exportación ha fallado. Inténtalo de nuevo en un momento." + #: src/app/main/errors.cljs:391 msgid "errors.feature-mismatch" msgstr "" @@ -6757,6 +6761,10 @@ msgstr "Duplicar fila" msgid "workspace.debug.title" msgstr "Herramientas de depuración" +#: src/app/main/data/exports/assets.cljs +msgid "workspace.export.unsaved-changes" +msgstr "Algunos de tus últimos cambios aún no se han guardado, así que puede que esta exportación no los incluya." + #: src/app/main/ui/workspace/sidebar/layers.cljs:841 msgid "workspace.focus.focus-mode" msgstr "Modo foco" From a3fc7c84652fc03517a576b8dc5c40d9fd28994c Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 16 Oct 2026 13:07:37 +0200 Subject: [PATCH 042/167] :sparkles: Save before downloading a .penpot file and warn if behind The .penpot download is built from the server copy. Users who downloaded a file to keep their work while saves were failing got an old or empty file, with no warning. Opening the download dialog now sends the pending changes of the files and waits up to 10s for them. If some are still not saved, the dialog says the download will not include them. --- .../workspace-state-persistence-subtleties.md | 1 + frontend/src/app/main/data/exports/files.cljs | 40 ++++++++--- frontend/src/app/main/ui/exports/files.cljs | 9 ++- frontend/src/app/main/ui/exports/files.scss | 4 ++ .../data/exports_files_test.cljs | 72 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + frontend/translations/en.po | 4 ++ frontend/translations/es.po | 4 ++ 8 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 frontend/test/frontend_tests/data/exports_files_test.cljs diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md index 66c19f551d..33e977bee9 100644 --- a/.serena/memories/frontend/workspace-state-persistence-subtleties.md +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -32,6 +32,7 @@ - Tests instant-trigger retries by stubbing `rx/timer` (recording delays to assert the schedule); dynamic bindings do not survive `await` continuations, so no dynamic var for delays. - `save-request-timeout-ms` is 7 minutes and wraps the `update-file` request in `rx/timeout`, failing it as a transient `:network` error so the retry path resends instead of blocking the queue forever. It is deliberately longer than `saving-stall-timeout-ms` (5 min) so the stalled report fires before the retry. - `sync-unload-guard` keeps `:persistence` in `st/ongoing-tasks` while edits are buffered or queued, which is what holds the browser leave-page warning on. It syncs on `::append-commit`, `::discard-commit` and `::release-failed-file` (every queue change). It is the one effect that must stay in `store.cljs`'s `omitset`, and `store.cljs`'s `ongoing-tasks` watcher is guarded by `exists? js/window` because tests run without a window. +- Exports and the `.penpot` download call `force-persist-files-and-wait file-ids timeout` (waits only for those files, ends on `:error` or timeout, never raises) and then `unsaved-changes? state file-ids` to warn; it reads `refs/persistence`, so tests mock it with `(l/derived :persistence store)`. - `persistence-failed` emits a `save-failed` audit event (`:cause-type :code :retrying :queued-commits`) whenever the user is notified (`notify-failure?`, same rule as the toast), including delegated auth/not-found failures. - Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50. - Undo/redo are ignored while a normal editor/drawing interaction is active, except grid-layout edition handles undo through this path. diff --git a/frontend/src/app/main/data/exports/files.cljs b/frontend/src/app/main/data/exports/files.cljs index 73917747be..fb12f6c5d3 100644 --- a/frontend/src/app/main/data/exports/files.cljs +++ b/frontend/src/app/main/data/exports/files.cljs @@ -11,6 +11,7 @@ [app.common.schema :as sm] [app.main.data.event :as ev] [app.main.data.modal :as modal] + [app.main.data.persistence :as dps] [app.main.repo :as rp] [app.util.sse :as sse] [beicon.v2.core :as rx] @@ -33,9 +34,25 @@ (def check-export-files (sm/check-fn schema:export-files)) +;; Longest wait for the pending changes of the downloaded files to be saved. +(def save-wait-timeout-ms 10000) + +(defn- show-export-dialog + "Opens the download dialog, flagging whether the files have changes not + saved yet." + [team-id files] + (ptk/reify ::show-export-dialog + ptk/WatchEvent + (watch [_ state _] + (rx/of (modal/show {:type ::export-files + :team-id team-id + :files files + :unsaved (dps/unsaved-changes? state (into #{} (map :id) files))}))))) + (defn open-export-dialog [files] - (let [files (check-export-files files)] + (let [files (check-export-files files) + file-ids (into #{} (map :id) files)] (ptk/reify ::export-files ptk/WatchEvent (watch [_ state _] @@ -45,16 +62,17 @@ ::ev/origin "dashboard" :format "binfile-v3" :num-files (count files)})) - (->> (rx/from files) - (rx/mapcat - (fn [file] - (->> (rp/cmd! :has-file-libraries {:file-id (:id file)}) - (rx/map #(assoc file :has-libraries %))))) - (rx/reduce conj []) - (rx/map (fn [files] - (modal/show {:type ::export-files - :team-id team-id - :files files})))))))))) + ;; The download is built from the server copy: send the pending + ;; changes first. + (rx/concat + (dps/force-persist-files-and-wait file-ids save-wait-timeout-ms) + (->> (rx/from files) + (rx/mapcat + (fn [file] + (->> (rp/cmd! :has-file-libraries {:file-id (:id file)}) + (rx/map #(assoc file :has-libraries %))))) + (rx/reduce conj []) + (rx/map (partial show-export-dialog team-id)))))))))) (defn export-files [& {:keys [type files]}] diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index 526e05cfef..121f91e1fd 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -13,6 +13,7 @@ [app.main.data.exports.files :as fexp] [app.main.data.modal :as modal] [app.main.store :as st] + [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.util.dom :as dom] @@ -68,7 +69,7 @@ {::mf/register modal/components ::mf/register-as ::fexp/export-files ::mf/props :obj} - [{:keys [team-id files]}] + [{:keys [team-id files unsaved]}] (let [state* (mf/use-state (partial initialize-state files)) has-libs? (some :has-libraries files) @@ -124,6 +125,12 @@ [:button {:class (stl/css :modal-close-btn) :on-click on-cancel} deprecated-icon/close]] + (when unsaved + [:> context-notification* {:level :warning + :appearance :ghost + :class (stl/css :unsaved-warning)} + (tr "files-download-modal.unsaved-changes")]) + (cond (= status :prepare) [:* diff --git a/frontend/src/app/main/ui/exports/files.scss b/frontend/src/app/main/ui/exports/files.scss index 8959807951..83a206f288 100644 --- a/frontend/src/app/main/ui/exports/files.scss +++ b/frontend/src/app/main/ui/exports/files.scss @@ -35,6 +35,10 @@ @extend %modal-close-btn-base; } +.unsaved-warning { + margin-block-end: var(--sp-xxl); +} + .modal-content { @include deprecated.body-small-typography; diff --git a/frontend/test/frontend_tests/data/exports_files_test.cljs b/frontend/test/frontend_tests/data/exports_files_test.cljs new file mode 100644 index 0000000000..fa545900d6 --- /dev/null +++ b/frontend/test/frontend_tests/data/exports_files_test.cljs @@ -0,0 +1,72 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.data.exports-files-test + "Tests for opening the `.penpot` download dialog: the pending changes are + sent first, and the dialog is told when some are still not saved." + (:require + [app.common.uuid :as uuid] + [app.main.data.exports.files :as fexp] + [app.main.data.modal :as modal] + [app.main.data.persistence :as dps] + [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] + [potok.v2.core :as ptk])) + +(defn- file-param + [file-id] + {:id file-id :name "File" :project-id (uuid/next) :is-shared false}) + +(defn- with-queued-change + "Adds to `state` a save queue that still holds a change of `file-id`." + [state file-id] + (let [commit-id (uuid/next)] + (assoc state :persistence {:queue (conj #queue [] commit-id) + :index {commit-id {:id commit-id :file-id file-id}}}))) + +(defn- ^:async dialog-props + "Opens the download dialog for `file-id` from `state` and returns the + props the dialog is shown with, plus the file ids the save wait got." + [state file-id] + (let [store (ptk/store {:state state :on-error #(t/is false (str %))}) + waited (atom nil)] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/of false))) + dps/force-persist-files-and-wait (mock/stub (fn [file-ids _] + (reset! waited file-ids) + (rx/empty)))} + (ptk/emit! store (fexp/open-export-dialog [(file-param file-id)])) + (await (async/wait-for #(some? (get-in @store [::modal/modal :props])) + "the dialog opens")))) + (assoc (get-in @store [::modal/modal :props]) ::waited @waited))) + +;; Scenario: the user downloads a file whose changes are all saved. Proves: +;; the pending changes of that file are sent first, and the dialog shows no +;; warning. +(t/deftest ^:async download-of-a-saved-file-has-no-warning + (let [file-id (uuid/next) + props (await (dialog-props {} file-id))] + (t/is (= #{file-id} (::waited props)) "the save wait covers the downloaded file") + (t/is (false? (:unsaved props))))) + +;; Scenario: the file still has a change the server does not have after the +;; wait. Proves: the dialog is told, so it can warn that the download will +;; not include it. +(t/deftest ^:async download-of-a-file-with-unsaved-changes-warns + (let [file-id (uuid/next) + props (await (dialog-props (with-queued-change {} file-id) file-id))] + (t/is (true? (:unsaved props))))) + +;; Scenario: another file has unsaved changes. Proves: only the downloaded +;; file's changes count. +(t/deftest ^:async unsaved-changes-of-another-file-do-not-warn + (let [file-id (uuid/next) + props (await (dialog-props (with-queued-change {} (uuid/next)) file-id))] + (t/is (false? (:unsaved props))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e7a5859879..961e68b601 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -10,6 +10,7 @@ [frontend-tests.copy-as-svg-test] [frontend-tests.data.dashboard-test] [frontend-tests.data.exports-assets-test] + [frontend-tests.data.exports-files-test] [frontend-tests.data.fix-deleted-fonts-test] [frontend-tests.data.modal-test] [frontend-tests.data.nitrate-test] @@ -126,6 +127,7 @@ 'frontend-tests.data.repo-test 'frontend-tests.data.store-test 'frontend-tests.data.exports-assets-test + 'frontend-tests.data.exports-files-test 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 4269991302..3e9bdb1ecc 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2177,6 +2177,10 @@ msgstr "Include shared library assets in file libraries" msgid "files-download-modal.title" msgstr "Download files" +#: src/app/main/ui/exports/files.cljs +msgid "files-download-modal.unsaved-changes" +msgstr "Some of your latest changes are not saved yet, so this download will not include them." + #: src/app/main/ui/settings/password.cljs:31 msgid "generic.error" msgstr "An error has occurred" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 2c807ed06f..4062753ac2 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2113,6 +2113,10 @@ msgstr "Incluir librerias compartidas dentro de las librerias del fichero" msgid "files-download-modal.title" msgstr "Descargar ficheros" +#: src/app/main/ui/exports/files.cljs +msgid "files-download-modal.unsaved-changes" +msgstr "Algunos de tus últimos cambios aún no se han guardado, así que esta descarga no los incluirá." + #: src/app/main/ui/settings/password.cljs:31 msgid "generic.error" msgstr "Ha ocurrido un error" From f23eb31bd71eba191c061326360942859dc3f277 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 16 Oct 2026 13:07:44 +0200 Subject: [PATCH 043/167] :sparkles: Send pending edits when the user leaves the tab or window Edits wait up to 3s in a buffer before they are sent. Closing the laptop or switching away right after an edit could leave that batch unsent, and a crash or a discarded tab then lost it. The batch is now sent at once when the tab is hidden (another tab, a minimized window, a closed tab or laptop) or the window loses focus (another app or a plugin iframe). The 3s window itself is unchanged. pagehide and beforeunload are left out: they come too late for an async save, and the tab is already hidden by then. --- .../workspace-state-persistence-subtleties.md | 1 + frontend/src/app/main/data/persistence.cljs | 17 ++++++++++ .../frontend_tests/data/persistence_test.cljs | 34 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md index 33e977bee9..eee6de48b8 100644 --- a/.serena/memories/frontend/workspace-state-persistence-subtleties.md +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -32,6 +32,7 @@ - Tests instant-trigger retries by stubbing `rx/timer` (recording delays to assert the schedule); dynamic bindings do not survive `await` continuations, so no dynamic var for delays. - `save-request-timeout-ms` is 7 minutes and wraps the `update-file` request in `rx/timeout`, failing it as a transient `:network` error so the retry path resends instead of blocking the queue forever. It is deliberately longer than `saving-stall-timeout-ms` (5 min) so the stalled report fires before the retry. - `sync-unload-guard` keeps `:persistence` in `st/ongoing-tasks` while edits are buffered or queued, which is what holds the browser leave-page warning on. It syncs on `::append-commit`, `::discard-commit` and `::release-failed-file` (every queue change). It is the one effect that must stay in `store.cljs`'s `omitset`, and `store.cljs`'s `ongoing-tasks` watcher is guarded by `exists? js/window` because tests run without a window. +- `leave-signals` (document `visibilitychange`→hidden, window `blur`) emits `::force-persist` when edits are buffered, so leaving the tab/window sends the 3 s batch at once; tests mock `dps/leave-signals` BEFORE `initialize-persistence` runs. pagehide/beforeunload are too late for an async save. - Exports and the `.penpot` download call `force-persist-files-and-wait file-ids timeout` (waits only for those files, ends on `:error` or timeout, never raises) and then `unsaved-changes? state file-ids` to warn; it reads `refs/persistence`, so tests mock it with `(l/derived :persistence store)`. - `persistence-failed` emits a `save-failed` audit event (`:cause-type :code :retrying :queued-commits`) whenever the user is notified (`notify-failure?`, same rule as the toast), including delegated auth/not-found failures. - Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50. diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 82e9013cb5..475bf9b45c 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -838,6 +838,17 @@ (swap! st/ongoing-tasks conj :persistence) (swap! st/ongoing-tasks disj :persistence))))) +(defn leave-signals + "Emits when the tab is hidden or the window loses focus: another tab or + app, a plugin iframe, a closed laptop. Empty outside the browser." + [] + (if (and (exists? js/window) (exists? js/document)) + (rx/merge + (->> (rx/from-event js/document "visibilitychange") + (rx/filter #(= "hidden" (.-visibilityState js/document)))) + (rx/from-event js/window "blur")) + (rx/empty))) + (defn initialize-persistence [] (ptk/reify ::initialize-persistence @@ -896,6 +907,12 @@ (rx/map #(ptk/data-event ::persistence-notification)) (rx/take-until stoper-s)) + ;; Sends the buffered edits when the user leaves the tab or window. + (->> (leave-signals) + (rx/filter (fn [_] @buffered?)) + (rx/map (fn [_] ::force-persist)) + (rx/take-until stoper-s)) + ;; Syncs the leave-page warning on every local commit and on every ;; queue change (append after a flush, discard after a save, ;; release of a failed file). diff --git a/frontend/test/frontend_tests/data/persistence_test.cljs b/frontend/test/frontend_tests/data/persistence_test.cljs index 32150ad8dd..44307d42c9 100644 --- a/frontend/test/frontend_tests/data/persistence_test.cljs +++ b/frontend/test/frontend_tests/data/persistence_test.cljs @@ -1005,6 +1005,40 @@ "the release turns the guard off")) (t/is (= :saved (get-in @store [:persistence :status]))))))) +;; Scenario: an edit waits in the 3 s buffer and the user switches tabs or +;; windows. Proves: leaving sends the buffered edit at once, well before +;; the buffer would (the bounded `wait-for` gives up long before 3 s). +(t/deftest ^:async leaving-the-tab-sends-buffered-edits-at-once + (let [leave-s (rx/subject)] + (await + (mock/with-mocks* + {dps/leave-signals (fn [] leave-s)} + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (ptk/emit! store (local-commit file-id)) + (await (async/settle)) + (t/is (empty? @requests) "the edit waits in the buffer") + (rx/push! leave-s :hidden) + (await (async/wait-for #(= 1 (count @requests)) + "leaving sends the edit at once")) + (t/is (= 1 (count @requests)))))))))) + +;; Scenario: nothing is buffered when the user leaves. Proves: a leave +;; signal alone sends nothing. +(t/deftest ^:async leaving-with-nothing-buffered-sends-nothing + (let [leave-s (rx/subject)] + (await + (mock/with-mocks* + {dps/leave-signals (fn [] leave-s)} + (await + (with-persistence + (^:async fn [{:keys [requests store]}] + (rx/push! leave-s :hidden) + (await (async/settle)) + (t/is (empty? @requests)) + (t/is (nil? (get-in @store [:persistence :status])))))))))) + (defn- wait-files-saved "Emits into `store` an event that runs `force-persist-files-and-wait` for `file-ids` and then flips `done`." From d0f455d5218bb44ec9e0666796bdbf46c5d299e2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 16 Oct 2026 10:31:10 +0000 Subject: [PATCH 044/167] :zap: Chunk storage-gc-deleted reads with a keyset cursor The storage-gc-deleted task used to stream all the pending rows with db/plan inside one long read-only transaction, blocking the table vacuum for the whole run and generating bloat on long deletions. Now every chunk of 25 objects is one short read-write transaction: the SELECT reads and locks the next objects after a (deleted_at, id) keyset cursor with FOR UPDATE SKIP LOCKED, deletes the database rows grouped by backend and removes their backend content. Concurrent runs never take the same chunk, and the double SELECT of the old lock-ids step is gone. A chunk that fails aborts its transaction: nothing of the chunk is deleted, its locks are released, and the run skips it; the cursor still advances to the last row read, so a failed chunk is not visited again in the same run. AI-assisted-by: glm-5.3-flash --- .serena/memories/backend/storage.md | 2 +- backend/src/app/main.clj | 9 +- backend/src/app/storage/gc_deleted.clj | 208 +++++++++++------- .../backend_tests/storage_gc_deleted_test.clj | 64 ++++++ 4 files changed, 204 insertions(+), 79 deletions(-) diff --git a/.serena/memories/backend/storage.md b/.serena/memories/backend/storage.md index 88a0c30042..0e2abd696b 100644 --- a/.serena/memories/backend/storage.md +++ b/.serena/memories/backend/storage.md @@ -25,7 +25,7 @@ - Existing object values can remain readable until physical deletion. - `:expired-at` blocks reads after the expiration time. - `del-object!` sets `deleted_at`. It does not remove backend content. -- `storage-gc-deleted` removes the database row and backend content after the deletion delay, streaming the pending rows with `db/plan` inside a read-only transaction and deleting them in chunks of 25, each chunk in its own transaction: a chunk that fails (for example, an object a foreign key still references) is logged and skipped instead of aborting the run. +- `storage-gc-deleted` removes the database row and backend content after the deletion delay, processing the pending objects in chunks of 25 with keyset pagination (`(deleted_at, id)` cursor, matching the `storage_object(deleted_at, id)` index). Each chunk is one short read-write transaction: `SELECT ... FOR UPDATE SKIP LOCKED` (so two concurrent runs never take the same chunk; no separate read transaction exists and the run does not block the table vacuum) followed by the database delete and the backend delete grouped by backend. The cursor advances to the `(deleted_at, id)` of the last row read regardless of the deletion result, so a chunk that fails is skipped and not visited again in the same run: the error aborts the transaction (nothing of the chunk is deleted, locks are released), the handler catches it inside the transaction function and rolls it back explicitly — the commit that `tx-run!` performs afterwards is a JDBC-driver no-op on the already-rolled-back connection — and the loop continues with the returned rows. Accepted quirks, both benign and self-healing: (1) with rows of several backends in one chunk, a failure after one group's physical delete leaves those rows pending with already-removed content — pending rows are excluded from reads and the next run finishes them, since the physical delete is idempotent (S3 ignores missing keys, FS uses `deleteIfExists`); (2) rows locked by a concurrent run are skipped via `SKIP LOCKED` and deferred to the next scheduled run, and the `deleted_at <= now` cutoff is sampled per chunk. It emits the `penpot_storage_gc_deleted_objects_total{backend}` counter (one increment per deleted backend group, reported only after the chunk commits, so rolled-back chunks are never counted twice); run duration is covered by the generic `penpot_tasks_timing{name="storage-gc-deleted"}` histogram and chunk failures by the log-derived `penpot_log_events_total`. Every 5000 deleted objects the run pauses 5 seconds (throttle between chunk transactions, never holding one open). - `storage-gc-touched` finds references before it sets `deleted_at`. - `objects-gc` removes deleted domain rows and touches their storage object IDs. Each proc streams its pending keys with `db/plan` inside a read-only transaction and deletes them in chunks, each chunk in its own transaction with `FOR UPDATE SKIP LOCKED`: a chunk that fails is logged and skipped instead of aborting the run. - Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction. diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 221c20af3a..65e55047fe 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -105,6 +105,12 @@ ::mdef/labels ["name"] ::mdef/type :histogram} + :storage-gc-deleted-objects + {::mdef/name "penpot_storage_gc_deleted_objects_total" + ::mdef/help "Storage objects permanently deleted by storage-gc-deleted, by backend." + ::mdef/labels ["backend"] + ::mdef/type :counter} + :redis-eval-timing {::mdef/name "penpot_redis_eval_timing" ::mdef/help "Redis EVAL commands execution timings (ms)" @@ -230,7 +236,8 @@ ::sto.gc-deleted/handler {::db/pool (ig/ref ::db/pool) - ::sto/storage (ig/ref ::sto/storage)} + ::sto/storage (ig/ref ::sto/storage) + ::mtx/metrics (ig/ref ::mtx/metrics)} ::sto.gc-touched/handler {::db/pool (ig/ref ::db/pool)} diff --git a/backend/src/app/storage/gc_deleted.clj b/backend/src/app/storage/gc_deleted.clj index 8356e35b0e..197889a3fb 100644 --- a/backend/src/app/storage/gc_deleted.clj +++ b/backend/src/app/storage/gc_deleted.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.gc-deleted "A task responsible to permanently delete already marked as deleted @@ -17,110 +17,164 @@ [app.common.logging :as l] [app.common.time :as ct] [app.db :as db] + [app.metrics :as mtx] [app.storage :as sto] [app.storage.impl :as impl] [integrant.core :as ig])) -(def ^:private sql:lock-sobjects - "SELECT id FROM storage_object - WHERE id = ANY(?::uuid[]) - FOR UPDATE - SKIP LOCKED") - -(defn- lock-ids - "Perform a select before delete for proper object locking and - prevent concurrent operations and we proceed only with successfully - locked objects." - [conn ids] - (let [ids (db/create-array conn "uuid" ids)] - (->> (db/exec! conn [sql:lock-sobjects ids]) - (into #{} (map :id)) - (not-empty)))) - (def ^:private sql:delete-sobjects "DELETE FROM storage_object WHERE id = ANY(?::uuid[])") -(defn- delete-sobjects - [conn ids] - (let [ids (db/create-array conn "uuid" ids)] - (-> (db/exec-one! conn [sql:delete-sobjects ids]) - (db/get-update-count)))) - -(defn- delete-in-bulk - [cfg backend-id ids] - ;; Each chunk runs in its own transaction, on its own connection: a chunk - ;; that fails (for example, an object that a foreign key still references) is - ;; logged and skipped, and it cannot poison the cursor that feeds the rest. - (try - (db/tx-run! cfg - (fn [{:keys [::db/conn ::sto/storage]}] - (when-let [ids (lock-ids conn ids)] - (let [total (delete-sobjects conn ids)] - (-> (impl/resolve-backend storage backend-id) - (impl/del-objects-in-bulk ids)) - - (doseq [id ids] - (l/dbg :hint "permanently delete storage object" - :id (str id) - :backend (name backend-id))) - total)))) - (catch Throwable cause - (l/err :hint "unexpected error on bulk deletion" - :ids ids - :cause cause)))) +(defn- delete-by-backend + "Deletes the locked objects of one backend group: the database rows and + their backend content. Runs inside the caller's transaction. Returns the + number of deleted rows; reporting it (metrics) is the caller's job, once + the transaction has committed." + [{:keys [::db/conn ::sto/storage]} backend-id ids] + (let [ids-array (db/create-array conn "uuid" ids) + total (-> (db/exec-one! conn [sql:delete-sobjects ids-array]) + (db/get-update-count))] + (-> (impl/resolve-backend storage backend-id) + (impl/del-objects-in-bulk ids)) + (doseq [id ids] + (l/dbg :hint "permanently delete storage object" + :id (str id) + :backend (name backend-id))) + total)) (defn- group-by-backend - [items] - (d/group-by (comp keyword :backend) :id #{} items)) + [rows] + (d/group-by (comp keyword :backend) :id #{} rows)) + +(defn- process-chunk-deletion + "Deletes the locked rows of one chunk, grouped by backend. Runs inside the + caller's transaction: any failure here aborts it, so the whole chunk is + rolled back (nothing is deleted and the locks are released) and skipped by + the caller. Returns a map of backend id to deleted row count." + [tx rows] + (->> (group-by-backend rows) + (map (fn [[backend-id ids]] + [backend-id (delete-by-backend tx backend-id ids)])) + (into {}))) (def ^:private sql:get-deleted-sobjects - "SELECT s.* + "SELECT s.id, s.backend, s.deleted_at FROM storage_object AS s WHERE s.deleted_at IS NOT NULL AND s.deleted_at <= ? - ORDER BY s.deleted_at ASC") + AND (s.deleted_at, s.id) > (?, ?) + ORDER BY s.deleted_at ASC, s.id ASC + LIMIT ? + FOR UPDATE SKIP LOCKED") + +(def ^:private sql:get-first-deleted-sobjects + "SELECT s.id, s.backend, s.deleted_at + FROM storage_object AS s + WHERE s.deleted_at IS NOT NULL + AND s.deleted_at <= ? + ORDER BY s.deleted_at ASC, s.id ASC + LIMIT ? + FOR UPDATE SKIP LOCKED") (def ^:private chunk-size - "Pending objects deleted in each transaction." + "Pending objects read, locked and deleted in each transaction." 25) -(defn- pending-row - "Takes the columns the GC needs from a `db/plan` row. +(def ^:private throttle-every + "Deleted objects between throttle pauses." + 5000) - A plan row is a view over the open result set, valid only while the reduction - is running, so it cannot be stored for later: this has to happen inside the - reducing function." - [row] - {:id (:id row) - :backend (:backend row)}) +(def ^:private throttle-ms + "Pause applied every `throttle-every` deleted objects, letting the + database (and its replicas) breathe on huge backlogs." + 5000) + +(defn- throttle + "Sleeps `throttle-ms` when the deleted count crosses a `throttle-every` + boundary. Runs between chunk transactions, never holding one open." + [prev-total new-total] + (when (> (quot new-total throttle-every) + (quot prev-total throttle-every)) + (l/inf :hint "throttling storage gc" + :deleted new-total + :pause-ms throttle-ms) + (Thread/sleep (long throttle-ms)))) + +(defn- fetch-and-lock-chunk + "Reads and locks one chunk of pending objects in `(deleted_at, id)` order. + Objects already locked by another process are skipped, so two concurrent + runs never take the same chunk. Runs inside the caller's transaction." + [conn last-deleted-at last-id] + (let [[sql params] (if (some? last-deleted-at) + [sql:get-deleted-sobjects + [(ct/now) last-deleted-at last-id chunk-size]] + [sql:get-first-deleted-sobjects + [(ct/now) chunk-size]])] + (db/exec! conn (into [sql] params)))) (defn- clean-deleted - "Walks the pending objects with `db/plan`, a server-side cursor that streams - the rows instead of materialising them, and deletes them chunk by chunk. + "Processes the pending objects chunk by chunk with keyset pagination: each + chunk is one short transaction that reads and locks the next `chunk-size` + objects after the `(deleted_at, id)` cursor and deletes them (database rows + and backend content). - The rows are read in their own read-only transaction and every chunk of - `chunk-size` objects is deleted in its own one (see `delete-in-bulk`), so a - chunk that fails is logged and skipped instead of aborting the whole run, and - the work of the previous chunks is already committed." - [cfg] - (let [xform (comp (map pending-row) - (partition-all chunk-size) - (mapcat group-by-backend)) - rf (fn - ([total] total) - ([total [backend-id ids]] - (+ total (or (delete-in-bulk cfg backend-id ids) 0))))] - (db/tx-run! (assoc cfg ::db/read-only true) - (fn [{:keys [::db/conn]}] - (transduce xform rf 0 - (db/plan conn [sql:get-deleted-sobjects (ct/now)])))))) + A chunk that fails aborts its transaction: nothing of the chunk is deleted, + its locks are released, and it is logged and skipped. The cursor advances + to the `(deleted_at, id)` of the last row of each read chunk regardless of + the deletion result, so a failed chunk is not visited again in the same + run. There is no long-lived read transaction: each transaction lives only + as long as one chunk, letting the vacuum proceed between chunks. Rows + locked by a concurrent run are skipped and deferred to the next run; a + chunk that fails after a partial backend delete self-heals on the next + run (physical deletes are idempotent). Every `throttle-every` deleted + objects the run pauses `throttle-ms` (see `throttle`)." + [{:keys [::mtx/metrics] :as cfg}] + (loop [total 0 + last-deleted-at nil + last-id nil] + + (let [[rows counts] + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as tx}] + (let [rows (fetch-and-lock-chunk conn last-deleted-at last-id)] + (try + [rows (process-chunk-deletion tx rows)] + (catch Throwable cause + ;; Explicit rollback, then return normally (do + ;; not rethrow): the caller needs `rows` to + ;; advance the keyset cursor past the failed + ;; chunk. The commit that `tx-run!` performs + ;; afterwards is a JDBC no-op on the already + ;; rolled-back connection. + (db/rollback! conn) + (l/err :hint "unexpected error on chunk deletion" + :ids (map :id rows) + :cause cause) + [rows {}])))))] + + ;; Reported only after the chunk commits: a rolled-back chunk leaves + ;; an empty count map, so retried rows are never counted twice. + (doseq [[backend-id n] counts] + (mtx/run! metrics + :id :storage-gc-deleted-objects + :inc n + :labels [(name backend-id)])) + + (if-let [cursor (peek rows)] + (let [new-total (+ total (reduce + 0 (vals counts)))] + (throttle total new-total) + (recur new-total + (:deleted-at cursor) + (:id cursor))) + total)))) (defmethod ig/assert-key ::handler [_ params] (assert (sto/valid-storage? (::sto/storage params)) "expect valid storage") - (assert (db/pool? (::db/pool params)) "expect valid storage")) + (assert (db/pool? (::db/pool params)) "expect valid storage") + (assert (mtx/metrics? (::mtx/metrics params)) "expect valid metrics instance")) (defmethod ig/init-key ::handler [_ cfg] diff --git a/backend/test/backend_tests/storage_gc_deleted_test.clj b/backend/test/backend_tests/storage_gc_deleted_test.clj index 9e1f87f723..ecd805af73 100644 --- a/backend/test/backend_tests/storage_gc_deleted_test.clj +++ b/backend/test/backend_tests/storage_gc_deleted_test.clj @@ -10,6 +10,7 @@ [app.common.uuid :as uuid] [app.db :as db] [app.storage :as sto] + [app.storage.impl :as impl] [backend-tests.helpers :as th] [clojure.test :as t])) @@ -91,3 +92,66 @@ (t/testing "the object of the next chunk is gone" (t/is (nil? (find-object last-id)))))) + +(t/deftest failed-batch-is-not-visited-again-in-the-same-run + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile)}) + + ;; A full first batch of deletable objects. + batch1 (mapv (fn [_] (put-object! (ct/in-past "30m"))) (range chunk-size)) + + ;; The blocked object leads a second full batch, in `deleted_at` order + ;; after the first one: the whole batch fails and must be skipped, and + ;; the cursor must not come back to any of its objects. + blocked (blocked-object! (:id file)) + fillers (mapv (fn [_] (put-object! (ct/in-past "20m"))) (range (dec chunk-size))) + + ;; Objects after the failed batch: the next read batch starts right + ;; after the `(deleted_at, id)` of the last row of the failed one. + batch3 (mapv (fn [_] (put-object! (ct/in-past "5m"))) (range 5)) + + result (th/run-task! :storage-gc-deleted {})] + + (t/testing "the first and the last batches are fully deleted" + (t/is (= (+ (count batch1) (count batch3)) (:deleted result)))) + + (t/testing "the objects of the first batch are gone" + (t/is (every? #(nil? (find-object (:id %))) batch1))) + + (t/testing "the objects of the failed batch are still pending" + (t/is (some? (find-object (:id blocked)))) + (t/is (every? #(some? (find-object (:id %))) fillers))) + + (t/testing "the objects after the failed batch are gone" + (t/is (every? #(nil? (find-object (:id %))) batch3))))) + +(t/deftest physical-failure-rolls-back-the-chunk-and-continues + "Pins the chunk rollback semantics: the physical delete of the first + chunk fails (mocked) after its rows were already deleted inside the + transaction, so the transaction must roll back (the rows stay pending) + while the run skips the failed chunk and deletes the rest." + (let [;; A full first chunk of deletable objects, older in + ;; `deleted_at` order than the rest, so the failing physical + ;; delete hits exactly this chunk. + old (mapv (fn [_] (put-object! (ct/in-past "30m"))) (range chunk-size)) + recent (mapv (fn [_] (put-object! (ct/in-past "5m"))) (range 5)) + + failing (atom true) + orig impl/del-objects-in-bulk + result (with-redefs [impl/del-objects-in-bulk + (fn [backend ids] + (if @failing + (do (reset! failing false) + (throw (ex-info "simulated backend failure" {}))) + (orig backend ids)))] + (th/run-task! :storage-gc-deleted {}))] + + (t/testing "the run finishes and only the objects after the failed chunk are deleted" + (t/is (= (count recent) (:deleted result)))) + + (t/testing "the objects of the failed chunk are still pending" + (t/is (every? #(some? (find-object (:id %))) old))) + + (t/testing "the objects after the failed chunk are gone" + (t/is (every? #(nil? (find-object (:id %))) recent))))) From da1791bf007ed84f0c75e62c7511da8afe8c8c31 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 16 Oct 2026 15:46:17 +0200 Subject: [PATCH 045/167] :paperclip: Disable temporary the storage gc job from cron --- backend/src/app/main.clj | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 65e55047fe..be1ccfafb3 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -590,8 +590,9 @@ {:cron #penpot/cron "0 0 0 * * ?" ;; daily :task :objects-gc} - {:cron #penpot/cron "0 0 0 * * ?" ;; daily - :task :storage-gc-deleted} + ;; FIXME: temporary commented + ;; {:cron #penpot/cron "0 0 0 * * ?" ;; daily + ;; :task :storage-gc-deleted} {:cron #penpot/cron "0 0 0 * * ?" ;; daily :task :storage-gc-touched} From 02e10a005a9d25406195b2e397d53f48180d7021 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Fri, 16 Oct 2026 17:58:51 +0200 Subject: [PATCH 046/167] :sparkles: Add token source to plugins (#12183) * :sparkles: Add token source to plugins * :sparkles: Changes after review * :sparkles: Move plugins code to library-edit --- common/src/app/common/files/tokens.cljc | 6 +- common/src/app/common/logic/tokens.cljc | 29 +- .../test/common_tests/logic/token_test.cljc | 55 ++- .../data/workspace/tokens/library_edit.cljs | 47 ++- .../src/app/main/ui/workspace/libraries.cljs | 18 +- frontend/src/app/plugins/library.cljs | 33 ++ frontend/src/app/plugins/tokens.cljs | 18 +- frontend/src/app/plugins/utils.cljs | 6 +- .../test/frontend_tests/helpers/state.cljs | 4 +- .../frontend_tests/plugins/tokens_test.cljs | 341 +++++++++++++++++- .../tokens/logic/token_data_test.cljs | 65 +++- mcp/packages/server/data/api_types.yml | 46 ++- mcp/types-generator/prepare_api_docs.py | 2 + mcp/types-generator/test_prepare_api_docs.py | 31 ++ plugins/CHANGELOG.md | 1 + .../src/generated/api-surface.json | 16 +- plugins/libs/plugin-types/index.d.ts | 44 ++- 17 files changed, 717 insertions(+), 45 deletions(-) create mode 100644 mcp/types-generator/test_prepare_api_docs.py diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index e78adf2c47..e8a28e7ca2 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -523,7 +523,9 @@ (effective-tokens-source? file-data (:id file-data)))) (defn editable-tokens? - "Returns true if the file-data is its own tokens source." + "Returns true if the file-data is its own tokens source. + Call only with the current local file's data. This predicate does not check + which file is open; callers must reject connected libraries first." [file-data] (effective-tokens-source? file-data (:id file-data))) @@ -810,4 +812,4 @@ {:active-theme-ids active-theme-ids :active-set-ids active-set-ids}) {:active-theme-ids #{} - :active-set-ids #{}})) \ No newline at end of file + :active-set-ids #{}})) diff --git a/common/src/app/common/logic/tokens.cljc b/common/src/app/common/logic/tokens.cljc index b09145ec1c..53d2ae5396 100644 --- a/common/src/app/common/logic/tokens.cljc +++ b/common/src/app/common/logic/tokens.cljc @@ -15,15 +15,34 @@ ;; 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)] + (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 nil, the tokens source - is removed and the tokens status is cleared." + 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." [changes library] (let [library-id (:id library) - library-tokens-status (if library - (-> library ctf/file-data cfo/get-tokens-status) - (ctos/make-tokens-status))] + file-data (pcb/get-library-data changes) + library-tokens-status (cond + (nil? library) + (ctos/make-tokens-status) + + (= library-id (:id file-data)) + (own-tokens-status file-data) + + :else + (-> library ctf/file-data cfo/get-tokens-status))] (-> changes (pcb/set-tokens-source library-id) (pcb/set-tokens-status library-tokens-status)))) diff --git a/common/test/common_tests/logic/token_test.cljc b/common/test/common_tests/logic/token_test.cljc index 20ab2e59fb..799d895f44 100644 --- a/common/test/common_tests/logic/token_test.cljc +++ b/common/test/common_tests/logic/token_test.cljc @@ -177,7 +177,60 @@ (t/is (= (ctos/get-active-theme-ids file-tokens-status) (ctos/get-active-theme-ids undo-tokens-status))) (t/is (= (ctos/get-active-set-ids file-tokens-status) - (ctos/get-active-set-ids undo-tokens-status)))))) + (ctos/get-active-set-ids undo-tokens-status))))) + + (t/testing "if the library is the same file, themes and sets of the previous source are deactivated" + (let [set-id (uuid/next) + external-set-id (uuid/next) + external-theme-id (uuid/next) + library-id (uuid/next) + file (-> (tht/sample-file-with-tokens + :file-id :file1 + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id set-id + :name "one set"))) + :status-fn #(ctos/set-tokens-status % #{external-theme-id} + #{set-id external-set-id})) + (assoc-in [:data :tokens-source] library-id)) + changes (-> (pcb/empty-changes) + (pcb/with-library-data (ctf/file-data file)) + (clt/generate-set-tokens-source file)) + redo (thf/apply-changes file changes) + undo (thf/apply-undo-changes redo changes) + file-tokens-status (tht/get-tokens-status file) + redo-tokens-source (tht/get-tokens-source redo) + redo-tokens-status (tht/get-tokens-status redo) + undo-tokens-source (tht/get-tokens-source undo) + undo-tokens-status (tht/get-tokens-status undo)] + + ;; Redo: only the themes and sets of the file's own lib stay active + (t/is (nil? redo-tokens-source)) + (t/is (= #{} (ctos/get-active-theme-ids redo-tokens-status))) + (t/is (= #{set-id} (ctos/get-active-set-ids redo-tokens-status))) + + ;; Undo: source and status restored to the original state + (t/is (= library-id undo-tokens-source)) + (t/is (= (ctos/get-active-theme-ids file-tokens-status) + (ctos/get-active-theme-ids undo-tokens-status))) + (t/is (= (ctos/get-active-set-ids file-tokens-status) + (ctos/get-active-set-ids undo-tokens-status))))) + + (t/testing "if the library is the same file and it has no own tokens lib, the tokens status is cleared" + (let [set-id (uuid/next) + file (-> (thf/sample-file :file1) + (update :data dissoc :tokens-lib) + (assoc-in [:data :tokens-source] (uuid/next)) + (assoc-in [:data :tokens-status] (ctos/set-tokens-status (ctos/make-tokens-status) + #{} #{set-id}))) + changes (-> (pcb/empty-changes) + (pcb/with-library-data (ctf/file-data file)) + (clt/generate-set-tokens-source file)) + redo (thf/apply-changes file changes) + redo-tokens-status (tht/get-tokens-status redo)] + + (t/is (nil? (tht/get-tokens-source redo))) + (t/is (= #{} (ctos/get-active-theme-ids redo-tokens-status))) + (t/is (= #{} (ctos/get-active-set-ids redo-tokens-status)))))) ;; Tokens lib 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 f8b276cdf3..6b22169719 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -7,6 +7,7 @@ (ns app.main.data.workspace.tokens.library-edit (:require [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.tokens :as cfo] @@ -48,6 +49,28 @@ ;; Helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn editable-tokens-status? + "True when file-id is the current file. Its activation status is editable + even when its tokens come from an external source." + [state file-id] + (= file-id (:current-file-id state))) + +(defn editable-tokens? + "True when file-id is the current file and uses its own tokens source. + Use this predicate when the file may be a connected library." + [state file-id] + (and (editable-tokens-status? state file-id) + (cfo/editable-tokens? (dsh/lookup-file-data state file-id)))) + +(defn- check-editable-tokens! + "Throws when the tokens of the current file come from an external library, + so they can't be modified from this file." + [state] + (when-not (editable-tokens? state (:current-file-id state)) + (ex/raise :type :assertion + :code :tokens-not-editable + :hint "cannot modify the tokens of an external library"))) + ;; TODO HYMA: Copied over from workspace.cljs (defn update-shape [id attrs] @@ -268,6 +291,7 @@ (ptk/reify ::create-token-theme ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state)] (if (and tokens-lib (ctob/get-theme tokens-lib (ctob/get-id token-theme))) @@ -286,6 +310,7 @@ (ptk/reify ::update-token-theme ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state)] (if (and (not= id (ctob/get-id token-theme)) @@ -322,7 +347,7 @@ (ptk/reify ::toggle-token-theme-active ptk/WatchEvent (watch [it state _] - (let [data (dsh/lookup-tokens-source-data state) + (let [data (dsh/lookup-file-data state) tokens-status (dsh/lookup-tokens-status state) tokens-lib (dsh/lookup-tokens-lib state) changes (-> (pcb/empty-changes it) @@ -338,6 +363,7 @@ (ptk/reify ::delete-token-theme ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -352,6 +378,7 @@ (ptk/reify ::create-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -366,6 +393,7 @@ (ptk/reify ::update-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -377,7 +405,8 @@ [set-group-path set-group-fname] (ptk/reify ::rename-token-set-group ptk/WatchEvent - (watch [it _state _] + (watch [it state _] + (check-editable-tokens! state) (let [changes (-> (pcb/empty-changes it) (pcb/rename-token-set-group set-group-path set-group-fname))] (rx/of @@ -390,6 +419,7 @@ (ptk/reify ::duplicate-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state) suffix (tr "workspace.tokens.duplicate-suffix")] @@ -458,6 +488,7 @@ (ptk/reify ::import-tokens-lib ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) status (cfo/make-tokens-status-from-lib lib) changes (-> (pcb/empty-changes it) @@ -486,6 +517,7 @@ (ptk/reify ::delete-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -498,6 +530,7 @@ (ptk/reify ::delete-token-set-group ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -528,6 +561,7 @@ (ptk/reify ::drop-token-set-group ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (try (when-let [changes (clo/generate-move-token-set-group (pcb/empty-changes it) (dsh/lookup-tokens-lib state) drop-opts)] (rx/of @@ -544,6 +578,7 @@ (ptk/reify ::drop-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (try (let [tokens-lib (dsh/lookup-tokens-lib state) changes (-> (pcb/empty-changes it) @@ -559,6 +594,7 @@ (ptk/reify ::create-token-and-set ptk/WatchEvent (watch [_ state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) @@ -594,6 +630,7 @@ (ptk/reify ::create-token ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (if-let [token-set (if set-id (lookup-token-set state set-id) (lookup-token-set state))] @@ -621,6 +658,7 @@ (ptk/reify ::bulk-create-tokens ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [token-set (lookup-token-set state set-id) data (dsh/lookup-file-data state) changes (reduce (fn [changes token-id] @@ -650,6 +688,7 @@ (ptk/reify ::update-token ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [token-set (if set-id (lookup-token-set state set-id) (lookup-token-set state)) @@ -678,6 +717,7 @@ (ptk/reify ::bulk-update-tokens ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [token-set (if set-id (lookup-token-set state set-id) (lookup-token-set state)) @@ -708,6 +748,7 @@ (ptk/reify ::delete-token ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state) token-set (if set-id @@ -731,6 +772,7 @@ (ptk/reify ::bulk-delete-tokens ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (reduce (fn [changes token-id] (pcb/set-token changes set-id token-id nil)) @@ -746,6 +788,7 @@ (ptk/reify ::duplicate-token ptk/WatchEvent (watch [_ state _] + (check-editable-tokens! state) (when-let [token-set (lookup-token-set state)] (when-let [tokens-lib (dsh/lookup-tokens-lib state)] (when-let [token (ctob/get-token tokens-lib diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index 7ed01913be..cfb41dfb3d 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -489,9 +489,10 @@ (tr "common.publish")])] (for [{:keys [id name data connected-to connected-to-names] :as library} linked-libraries] - (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) - has-tokens? (and (has-tokens? library) - (contains? cf/flags :token-import-from-library))] + (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) + can-import-tokens? (and (has-tokens? library) + (cfo/editable-tokens? local-library) + (contains? cf/flags :token-import-from-library))] [:div {:class (stl/css :section-list-item-legacy) :key (dm/str id) :data-testid "library-item"} @@ -508,7 +509,7 @@ [:span ")"]])])]] [:div {:class (stl/css :library-actions)} - (when ^boolean has-tokens? + (when ^boolean can-import-tokens? [:> icon-button* {:type "button" :aria-label (tr "workspace.tokens.import-tokens") @@ -865,9 +866,10 @@ :title-class (stl/css :connected-libraries-title)}] (if (seq linked-libraries) (for [{:keys [id name data connected-to nested? parent-name] :as library} linked-libraries] - (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) - has-tokens? (and (has-tokens? library) - (contains? cf/flags :token-import-from-library))] + (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) + can-import-tokens? (and (has-tokens? library) + (cfo/editable-tokens? local-library) + (contains? cf/flags :token-import-from-library))] [:div {:class (stl/css-case :section-list-item true :section-list-item-nested nested?) :key (dm/str id) @@ -893,7 +895,7 @@ :data-library-id (dm/str id) :on-click set-as-tokens-source} (tr "workspace.libraries.set-as-tokens-source")]) - (when ^boolean has-tokens? + (when ^boolean can-import-tokens? [:> icon-button* {:type "button" :aria-label (tr "workspace.tokens.import-tokens") diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index a5a70e1cfa..803cca9ffd 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.tokens :as cfo] [app.common.files.variant :as cfv] [app.common.geom.point :as gpt] [app.common.schema :as sm] @@ -22,6 +23,7 @@ [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.data.workspace.variants :as dwv] [app.main.fonts :as fonts] [app.main.repo :as rp] @@ -1300,6 +1302,37 @@ (let [libraries (get @st/state :files)] (apply array (->> libraries keys (map (partial library-proxy plugin-id))))))} + :tokensSource + {:get + (fn [] + (let [file-id (:current-file-id @st/state) + file-data (ctf/file-data (u/locate-file file-id))] + (library-proxy plugin-id (cfo/get-effective-tokens-source file-data)))) + :set + (fn [value] + (let [file-id (:current-file-id @st/state) + library-id (when (library-proxy? value) (obj/get value "$id")) + library (some-> library-id u/locate-file) + library-data (some-> library ctf/file-data) + file-data (ctf/file-data (u/locate-file file-id))] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :tokensSource "Plugin doesn't have 'content:write' permission") + + (not (library-proxy? value)) + (u/not-valid plugin-id :tokensSource value) + + (nil? library) + (u/not-valid plugin-id :tokensSource "Library is not connected") + + (and (not= library-id file-id) + (not (cfo/tokens-provider? library-data))) + (u/not-valid plugin-id :tokensSource "Library must have its own tokens") + + (not (cfo/effective-tokens-source? file-data library-id)) + (st/emit! (-> (dwtl/set-tokens-source library-id) + (se/add-event plugin-id))))))} + :availableLibraries (fn [] (let [team-id (:current-team-id @st/state)] diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index ae0f6bf171..f1895d7efc 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -11,7 +11,6 @@ [app.common.files.tokens :as cfo] [app.common.json :as json] [app.common.schema :as sm] - [app.common.types.file :as ctf] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.common.types.tokens-status :as ctos] @@ -512,8 +511,11 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :active "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else - (st/emit! (dwtl/set-enabled-token-set id value))))} ;; This can be done even with tokens in an external library + (st/emit! (dwtl/set-enabled-token-set id value))))} :toggleActive (fn [] @@ -521,6 +523,9 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :toggleActive "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else (st/emit! (dwtl/toggle-token-set id)))) @@ -737,6 +742,9 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :active "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else (st/emit! (dwtl/set-token-theme-active id value))))} @@ -746,6 +754,9 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :toggleActive "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else (st/emit! (dwtl/toggle-token-theme-active id)))) @@ -839,8 +850,7 @@ :enumerable false :get (fn [] - (let [file (u/locate-file file-id)] - (cfo/editable-tokens? (ctf/file-data file))))} + (dwtl/editable-tokens? @st/state file-id))} :themes {:this true diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index fc6a921799..22f57d45fd 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -18,6 +18,7 @@ [app.common.types.file :as ctf] [app.common.types.tokens-lib :as ctob] [app.main.data.helpers :as dsh] + [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] [app.util.object :as obj] [cuerdas.core :as str])) @@ -288,9 +289,8 @@ (defn check-editable-tokens [file-id] - (let [file (locate-file file-id)] - (when-not (cfo/editable-tokens? (ctf/file-data file)) - (throw (js/Error. (dm/str "[PENPOT PLUGIN] Cannot modify tokens in an external library")))))) + (when-not (dwtl/editable-tokens? @st/state file-id) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Cannot modify tokens in an external library"))))) (defn throw-validation-errors? [plugin-id] diff --git a/frontend/test/frontend_tests/helpers/state.cljs b/frontend/test/frontend_tests/helpers/state.cljs index 894caa2783..82e4e3d32c 100644 --- a/frontend/test/frontend_tests/helpers/state.cljs +++ b/frontend/test/frontend_tests/helpers/state.cljs @@ -25,7 +25,7 @@ ;; profile renderer preference instead of the global feature flag alone. :profile {:props {:renderer :wasm}}}) -(defn- on-error +(defn- report-error [cause] (js/console.error "STORE ERROR" (.-stack cause)) (t/do-report {:type :error :message "Store error" :actual cause}) @@ -34,7 +34,7 @@ (defn setup-store ([file] (setup-store file nil)) - ([file {:keys [renderer libraries] :as _opts}] + ([file {:keys [renderer libraries on-error] :or {on-error report-error} :as _opts}] (let [state (-> initial-state (assoc :current-file-id (:id file) :current-page-id (cthf/current-page-id file) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index 56a3a2529d..f731c98910 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -18,17 +18,21 @@ [app.main.data.tokenscript :as ts] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.undo :as dwu] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.library :as library] [app.plugins.register :as r] [app.plugins.tokens :as ptok] [app.plugins.utils :as u] [app.util.object :as obj] [cljs.test :as t :include-macros true] - [frontend-tests.helpers.async :as tha] + [clojure.datafy :refer [datafy]] + [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.state :as ths] [frontend-tests.helpers.wasm :as thw] + [frontend-tests.tokens.helpers.state :as tohs] [potok.v2.core :as ptk])) (t/use-fixtures :each @@ -71,7 +75,7 @@ #js {:strokeColor "#000000" :strokeWidth 1}]) (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) (obj/set! (aget (.-strokes rect) index) property value) - (await (tha/settle)) + (await (async/settle)) (t/is (= (apply dissoc tokens removed) (get-in @store path))) (t/is (= value (obj/get (aget (.-strokes rect) index) property)))))))) @@ -97,15 +101,344 @@ (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) (let [^js gradient (obj/get (aget (.-strokes rect) 0) "strokeColorGradient")] (set! (.-startX gradient) 0.5) - (await (tha/settle)) + (await (async/settle)) (t/is (= (dissoc tokens :stroke-color) (get-in @store path))) (t/is (= 0.5 (obj/get (obj/get (aget (.-strokes rect) 0) "strokeColorGradient") "startX"))) (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) (obj/set! (aget (.-stops gradient) 0) "color" "#ff0000") - (await (tha/settle)) + (await (async/settle)) (t/is (= (dissoc tokens :stroke-color) (get-in @store path))) (t/is (= "#ff0000" (obj/get (aget (obj/get (obj/get (aget (.-strokes rect) 0) "strokeColorGradient") "stops") 0) "color")))))) +(defn- token-source-file + [file-label] + (let [set-id (uuid/next)] + (ctht/sample-file-with-tokens + :file-id file-label + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id set-id :name "Core")) + (ctob/add-theme (ctob/make-token-theme :name "Light" + :group "Mode" + :sets #{"Core"})) + (ctob/add-token set-id (ctob/make-token :name "color.primary" + :type :color + :value "#FF0000"))) + :status-fn #(ctos/set-tokens-status % #{} #{set-id})))) + +(defn- with-active-theme + "The tokens status of `file` with its first visible theme activated." + [file] + (let [tokens-lib (ctht/get-tokens-lib file) + theme-id (:id (first (remove ctob/hidden-theme? (ctob/get-themes tokens-lib))))] + (cfo/activate-theme (ctht/get-tokens-status file) tokens-lib theme-id))) + +(defn- tokens-unchanged? + "True when the token attributes of `file` in the store are the original ones." + [store file] + (let [attrs [:tokens-lib :tokens-status :tokens-source]] + (= (select-keys (:data file) attrs) + (select-keys (get-in @store [:files (:id file) :data]) attrs)))) + +(defn- record-not-valid + "A `u/not-valid` stub that collects each call into `errors`." + [errors] + (mock/stub (fn [plugin-id code value] (swap! errors conj [plugin-id code value])))) + +(t/deftest ^:async library-tokens-source-switches-to-connected-library + (let [file (token-source-file :local) + source (token-source-file :source) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero)) + source-proxy (library/library-proxy (str uuid/zero) (:id source))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (t/is (= (str (:id file)) (.. context -tokensSource -id))) + (set! (.-tokensSource context) source-proxy) + (await (async/wait-for #(= (:id source) (get-in @store [:files (:id file) :data :tokens-source])) + "library token source")) + (t/is (= (str (:id source)) (.. context -tokensSource -id))) + (t/is (= (datafy (ctht/get-tokens-status source)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))) + (t/is (= (ctht/get-tokens-lib source) (u/locate-tokens-lib (:id file)))) + (t/is (false? (.. context -local -tokens -isEditableTokens))) + (t/is (= (ctht/get-tokens-lib file) + (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (tokens-unchanged? store source)))))) + +(t/deftest ^:async library-tokens-source-restores-local-tokens + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (with-active-theme source))) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (set! (.-tokensSource context) (.-local context)) + (await (async/wait-for #(nil? (get-in @store [:files (:id file) :data :tokens-source])) + "local token source")) + (let [^js catalog (.. context -local -tokens)] + (t/is (= (str (:id file)) (.. context -tokensSource -id))) + (t/is (true? (.-isEditableTokens catalog))) + (t/is (= (ctht/get-tokens-lib file) (u/locate-tokens-lib (:id file)))) + (t/is (= #{} (ctos/get-active-set-ids (get-in @store [:files (:id file) :data :tokens-status])))) + (t/is (not-any? #(.-active ^js %) (.-themes catalog)))))))) + +(t/deftest ^:async library-tokens-source-supports-undo-and-redo + (let [file (token-source-file :local) + source (token-source-file :source) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero)) + source-proxy (library/library-proxy (str uuid/zero) (:id source))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (st/emit! (tohs/watch-undo-stack)) + (set! (.-tokensSource context) source-proxy) + (await (async/wait-for #(seq (get-in @store [:workspace-undo :items])) + "token source undo entry")) + (st/emit! dwu/undo) + (await (async/wait-for #(nil? (get-in @store [:files (:id file) :data :tokens-source])) + "undo token source")) + (t/is (= (datafy (ctht/get-tokens-status file)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))) + (st/emit! dwu/redo) + (await (async/wait-for #(= (:id source) (get-in @store [:files (:id file) :data :tokens-source])) + "redo token source")) + (t/is (= (datafy (ctht/get-tokens-status source)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))))))) + +(t/deftest ^:async library-tokens-source-keeps-status-when-source-is-unchanged + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source))) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero)) + source-proxy (library/library-proxy (str uuid/zero) (:id source))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (set! (.-tokensSource context) source-proxy) + (await (async/settle)) + (t/is (tokens-unchanged? store file)))))) + +(t/deftest ^:async library-tokens-source-rejects-invalid-libraries + (let [file (token-source-file :local) + empty-lib (ctht/sample-file-with-tokens :file-id :empty) + indirect (-> (token-source-file :indirect) + (assoc-in [:data :tokens-source] (uuid/next))) + store (ths/setup-store file {:libraries [empty-lib indirect]}) + plugin-id (str uuid/zero) + ^js context (library/library-subcontext plugin-id) + errors (atom [])] + (await + (mock/with-mocks* {st/state store + st/stream (ptk/input-stream store) + u/not-valid (record-not-valid errors)} + (doseq [[label value message] + [["not a library" "invalid" "invalid"] + ["not connected" (library/library-proxy plugin-id (uuid/next)) "Library is not connected"] + ["without own tokens" (library/library-proxy plugin-id (:id empty-lib)) "Library must have its own tokens"] + ["with another tokens source" (library/library-proxy plugin-id (:id indirect)) "Library must have its own tokens"]]] + (t/testing label + (reset! errors []) + (set! (.-tokensSource context) value) + (t/is (= [[plugin-id :tokensSource message]] @errors)))) + (await (async/settle)) + (t/is (tokens-unchanged? store file)))))) + +(t/deftest ^:async external-catalogs-reject-tokens-changes + (let [source (token-source-file :source) + other (token-source-file :other) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source))) + store (ths/setup-store file {:libraries [source other]}) + plugin-id (str uuid/zero)] + (swap! store assoc-in [:plugins :flags plugin-id :throw-validation-errors] true) + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (doseq [[catalog-label ^js catalog] + [["local catalog" (.. (library/library-subcontext plugin-id) -local -tokens)] + ["source catalog" (.. (library/library-subcontext plugin-id) -tokensSource -tokens)] + ["other connected catalog" (.-tokens (library/library-proxy plugin-id (:id other)))]]] + (t/testing catalog-label + (let [^js token-set (aget (.-sets catalog) 0) + ^js token (aget (.-tokens token-set) 0) + ^js theme (aget (.-themes catalog) 0)] + (t/is (false? (.-isEditableTokens catalog))) + (doseq [[label mutate] + [["create set" #(.addSet catalog #js {:name "New"})] + ["rename set" #(set! (.-name token-set) "Renamed")] + ["duplicate set" #(.duplicate token-set)] + ["remove set" #(.remove token-set)] + ["create token" #(.addToken token-set #js {:name "color.new" :type "color" :value "#00FF00"})] + ["rename token" #(set! (.-name token) "color.renamed")] + ["change token value" #(set! (.-value token) "#00FF00")] + ["change token description" #(set! (.-description token) "New description")] + ["duplicate token" #(.duplicate token)] + ["remove token" #(.remove token)] + ["create theme" #(.addTheme catalog #js {:name "Dark" :group "Mode"})] + ["rename theme" #(set! (.-name theme) "Dark")] + ["change theme group" #(set! (.-group theme) "Other")] + ["add set to theme" #(.addSet theme token-set)] + ["remove set from theme" #(.removeSet theme token-set)] + ["duplicate theme" #(.duplicate theme)] + ["remove theme" #(.remove theme)]]] + (t/testing label + (t/is (thrown-with-msg? js/Error #"Cannot modify tokens in an external library" + (mutate))) + (await (async/settle)) + (t/is (tokens-unchanged? store file)) + (t/is (tokens-unchanged? store source)) + (t/is (tokens-unchanged? store other))))))))))) + +(t/deftest ^:async external-catalogs-reject-status-changes + (let [source (token-source-file :source) + other (token-source-file :other) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (with-active-theme source))) + store (ths/setup-store file {:libraries [source other]}) + plugin-id (str uuid/zero) + message "Cannot change activation through a connected library; use penpot.library.local.tokens" + errors (atom [])] + (await + (mock/with-mocks* {st/state store + st/stream (ptk/input-stream store) + u/not-valid (record-not-valid errors)} + (let [^js context (library/library-subcontext plugin-id)] + (t/is (true? (.-active ^js (aget (.. context -local -tokens -themes) 0)))) + (doseq [[catalog-label ^js catalog] + [["source catalog" (.. context -tokensSource -tokens)] + ["other connected catalog" (.-tokens (library/library-proxy plugin-id (:id other)))]]] + (t/testing catalog-label + (let [^js token-set (aget (.-sets catalog) 0) + ^js theme (aget (.-themes catalog) 0)] + (t/testing "reports the status of its own file" + (t/is (true? (.-active token-set))) + (t/is (false? (.-active theme)))) + (reset! errors []) + (set! (.-active token-set) false) + (.toggleActive token-set) + (set! (.-active theme) true) + (.toggleActive theme) + (await (async/settle)) + (t/is (= [[plugin-id :active message] + [plugin-id :toggleActive message] + [plugin-id :active message] + [plugin-id :toggleActive message]] + @errors)) + (t/is (tokens-unchanged? store file)) + (t/is (tokens-unchanged? store source)) + (t/is (tokens-unchanged? store other)))))))))) + +(t/deftest ^:async external-token-source-allows-set-activation + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (ctht/get-tokens-status source))) + store (ths/setup-store file {:libraries [source]})] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js catalog (.. (library/library-subcontext (str uuid/zero)) -local -tokens) + ^js token-set (aget (.-sets catalog) 0)] + (t/is (true? (.-active token-set))) + (set! (.-active token-set) false) + (await (async/wait-for #(false? (.-active token-set)) "deactivate set")) + (.toggleActive token-set) + (await (async/wait-for #(true? (.-active token-set)) "toggle set")) + (t/is (= (ctht/get-tokens-lib file) (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async external-token-source-allows-theme-activation + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (ctht/get-tokens-status source))) + store (ths/setup-store file {:libraries [source]})] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js catalog (.. (library/library-subcontext (str uuid/zero)) -local -tokens) + ^js theme (aget (.-themes catalog) 0)] + (t/is (false? (.-active theme))) + (set! (.-active theme) true) + (await (async/wait-for #(true? (.-active theme)) "activate theme")) + (.toggleActive theme) + (await (async/wait-for #(false? (.-active theme)) "toggle theme")) + (t/is (= (ctht/get-tokens-lib file) (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async external-token-source-theme-toggle-supports-undo + (let [source (token-source-file :source) + status (with-active-theme source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] status)) + store (ths/setup-store file {:libraries [source]})] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js catalog (.. (library/library-subcontext (str uuid/zero)) -local -tokens) + ^js theme (aget (.-themes catalog) 0)] + (st/emit! (tohs/watch-undo-stack)) + (.toggleActive theme) + (await (async/wait-for #(seq (get-in @store [:workspace-undo :items])) + "theme toggle undo entry")) + (t/is (false? (.-active theme))) + (st/emit! dwu/undo) + (await (async/wait-for #(= (datafy status) + (datafy (get-in @store [:files (:id file) :data :tokens-status]))) + "undo external theme toggle")) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async external-token-source-allows-applying-tokens-to-shapes + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (ctht/get-tokens-status source)) + (ctho/add-rect :rect1) + (ctho/add-rect :rect2) + (ctho/add-rect :rect3)) + store (ths/setup-store file {:libraries [source]}) + page-id (cthf/current-page-id file)] + (swap! store assoc-in [:workspace-local :selected] #{(cthi/id :rect3)}) + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js context (api/create-context (str uuid/zero)) + ^js page (.-currentPage context) + ^js catalog (.. context -library -local -tokens) + ^js token-set (aget (.-sets catalog) 0) + ^js token (aget (.-tokens token-set) 0) + ^js rect1 (.getShapeById page (str (cthi/id :rect1))) + ^js rect2 (.getShapeById page (str (cthi/id :rect2)))] + (.applyToken rect1 token #js ["fill"]) + (.applyToShapes token #js [rect2] #js ["fill"]) + (.applyToSelected token #js ["fill"]) + (doseq [label [:rect1 :rect2 :rect3]] + (let [shape-path [:files (:id file) :data :pages-index page-id :objects (cthi/id label)]] + (await (async/wait-for #(= "color.primary" (get-in @store (conj shape-path :applied-tokens :fill))) + (str "apply external token to " label))) + (await (async/wait-for #(= "#ff0000" (get-in @store (conj shape-path :fills 0 :fill-color))) + (str "resolve external token on " label))))) + (t/is (= (ctht/get-tokens-lib file) (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (= (datafy (ctht/get-tokens-status file)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async library-tokens-source-requires-content-write-permission + (let [file (token-source-file :local) + source (token-source-file :source) + plugin-id "read-only-plugin" + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext plugin-id) + source-proxy (library/library-proxy plugin-id (:id source)) + errors (atom [])] + (await + (mock/with-mocks* {st/state store + st/stream (ptk/input-stream store) + u/not-valid (record-not-valid errors)} + (set! (.-tokensSource context) source-proxy) + (t/is (= [[plugin-id :tokensSource "Plugin doesn't have 'content:write' permission"]] @errors)) + (await (async/settle)) + (t/is (tokens-unchanged? store file)))))) + ;; Regression coverage for issue #9162. ;; ;; Plugin code calling `shape.applyToken(token, ["fill"])` or diff --git a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs index ee2d94a31f..fe1a145ce8 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs @@ -15,10 +15,12 @@ [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.data.workspace.undo :as dwu] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] [frontend-tests.helpers.pages :as thp] [frontend-tests.helpers.state :as ths] [frontend-tests.tokens.helpers.state :as tohs] - [frontend-tests.tokens.helpers.tokens :as toht])) + [frontend-tests.tokens.helpers.tokens :as toht] + [potok.v2.core :as ptk])) (t/use-fixtures :each {:before thp/reset-idmap!}) @@ -895,3 +897,64 @@ (t/testing "Tokens lib has been imported into file without existing lib" (t/is (some? tokens-lib')) (t/is (some? (ctob/get-set-by-name tokens-lib' "Imported Set")))))))))) + +(defn- setup-tokens-source-library + [] + (ctho/sample-file-with-tokens + :file-id :library + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :source-set) + :name "Source")) + (ctob/add-token (cthi/id :source-set) + (ctob/make-token :id (cthi/new-id! :source-token) + :name "color.source" + :type :color + :value "#FF0000")) + (ctob/add-theme (ctob/make-token-theme :id (cthi/new-id! :source-theme) + :name "Light" + :group "Mode" + :sets #{"Source"}))) + :status-fn #(ctos/set-tokens-status % #{} #{(cthi/id :source-set)}))) + +(t/deftest ^:async external-tokens-source-rejects-tokens-lib-changes + (let [source (setup-tokens-source-library) + file (-> (setup-file-with-token-lib) + (assoc-in [:data :tokens-source] (:id source))) + errors (atom []) + store (ths/setup-store file {:libraries [source] + :on-error #(swap! errors conj %)}) + tokens-lib (ctho/get-tokens-lib source) + set-id (cthi/id :source-set) + token-id (cthi/id :source-token) + theme-id (cthi/id :source-theme)] + (doseq [[label event] + [["create theme" (dwtl/create-token-theme (ctob/make-token-theme :name "Dark" :group "Mode"))] + ["update theme" (dwtl/update-token-theme theme-id (ctob/rename (ctob/get-theme tokens-lib theme-id) "Dark"))] + ["delete theme" (dwtl/delete-token-theme theme-id)] + ["create set" (dwtl/create-token-set (ctob/make-token-set :name "New"))] + ["rename set" (dwtl/rename-token-set (ctob/get-set tokens-lib set-id) "Renamed")] + ["rename set group" (dwtl/rename-token-set-group ["Source"] "Renamed")] + ["duplicate set" (dwtl/duplicate-token-set set-id)] + ["delete set" (dwtl/delete-token-set set-id)] + ["delete set group" (dwtl/delete-token-set-group ["Source"])] + ["drop set group" (dwtl/drop-token-set-group {:from-index 0 :to-index 0 :position :top})] + ["drop set" (dwtl/drop-token-set {:from-index 0 :to-index 0 :position :top})] + ["import tokens lib" (dwtl/import-tokens-lib (ctob/make-tokens-lib))] + ["create token" (dwtl/create-token set-id (ctob/make-token :name "color.new" + :type :color + :value "#00FF00"))] + ["bulk create tokens" (dwtl/bulk-create-tokens set-id [token-id] :color {:depth 0} "brand")] + ["update token" (dwtl/update-token set-id token-id {:value "#00FF00"})] + ["bulk update tokens" (dwtl/bulk-update-tokens set-id [token-id] :color "color" "brand")] + ["delete token" (dwtl/delete-token set-id token-id)] + ["bulk delete tokens" (dwtl/bulk-delete-tokens set-id [token-id])] + ["duplicate token" (dwtl/duplicate-token token-id)]]] + (t/testing label + (reset! errors []) + (ptk/emit! store event) + (await (async/wait-for #(seq @errors) label)) + (t/is (= [{:type :assertion :code :tokens-not-editable}] + (map #(select-keys (ex-data %) [:type :code]) @errors))))) + (await (async/settle)) + (t/is (= file (get-in @store [:files (:id file)]))) + (t/is (= source (get-in @store [:files (:id source)]))))) diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index e0ebcde698..cbfb243052 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -22426,10 +22426,12 @@ TokenCatalog: readonly isEditableTokens: boolean ``` - Returns true if the tokens can be edited (it can't - if the tokens of the file are in an external library). - Note that even in this case, Themes and Sets status still - can be changed and tokens can be applied to shapes. + Returns true if the tokens in this catalog can be edited. Only the + catalog of the current file (`penpot.library.local.tokens`) can be + edited, and only when the file is its own tokens source. + When the tokens come from an external library, the local catalog + can still change the active themes and sets, and its tokens can + still be applied to shapes. themes: |- ``` readonly themes: TokenTheme[] @@ -22564,7 +22566,8 @@ TokenSet: active: boolean ``` - Indicates if the set is currently active. + Indicates if the set is active in the file that owns this catalog. + Only sets from `penpot.library.local.tokens` can be changed. tokens: |- ``` readonly tokens: Token[] @@ -22584,6 +22587,7 @@ TokenSet: ``` Toggles the active status of this set. + Only sets from `penpot.library.local.tokens` can be changed. Returns void getTokenById: |- @@ -22707,7 +22711,8 @@ TokenTheme: active: boolean ``` - Indicates if the theme is currently active. + Indicates if the theme is active in the file that owns this catalog. + Only themes from `penpot.library.local.tokens` can be changed. activeSets: |- ``` readonly activeSets: TokenSet[] @@ -22721,6 +22726,7 @@ TokenTheme: ``` Toggles the active status of this theme. + Only themes from `penpot.library.local.tokens` can be changed. Returns void addSet: |- @@ -23297,6 +23303,7 @@ LibraryContext: type LibraryContext = { local: Library; connected: Library[]; + tokensSource: Library; availableLibraries(): Promise; connectLibrary(libraryId: string): Promise; } @@ -23327,6 +23334,33 @@ LibraryContext: ``` const connectedLibraries = libraryContext.connected; ``` + tokensSource: |- + ``` + tokensSource: Library + ``` + + The library that supplies tokens to the current file. + Defaults to the local library. + + Assign a connected library that has its own tokens to use it as the + source. This copies its active themes and sets to the current file. + Assign `local` to use the file's own tokens again. Themes and sets + that don't exist in the local tokens are deactivated. + Setting the source requires `content:write` permission and supports undo. + Assigning the current source again keeps the active themes and sets. + + The catalog of an external `tokensSource` is read-only and reports the + library's own active themes and sets. To change which ones are active + in the current file, use `penpot.library.local.tokens`, which lists the + tokens of the source. + + Example + ``` + const source = await penpot.library.connectLibrary('library-id'); + penpot.library.tokensSource = source; + // Restore local tokens: + penpot.library.tokensSource = penpot.library.local; + ``` Methods: availableLibraries: |- ``` diff --git a/mcp/types-generator/prepare_api_docs.py b/mcp/types-generator/prepare_api_docs.py index a01ec4e6ef..143f110c44 100644 --- a/mcp/types-generator/prepare_api_docs.py +++ b/mcp/types-generator/prepare_api_docs.py @@ -63,6 +63,8 @@ class PenpotAPIContentMarkdownConverter(MarkdownConverter): if node.name == "pre": for button in soup.find_all("button"): button.decompose() + for br in soup.find_all("br"): + br.replace_with("\n") return f"\n```\n{soup.get_text().strip()}\n```\n\n" # convert tsd-signature elements to code blocks, converting
    to newlines diff --git a/mcp/types-generator/test_prepare_api_docs.py b/mcp/types-generator/test_prepare_api_docs.py new file mode 100644 index 0000000000..bb2fc631eb --- /dev/null +++ b/mcp/types-generator/test_prepare_api_docs.py @@ -0,0 +1,31 @@ +import unittest + +from prepare_api_docs import PenpotAPIContentMarkdownConverter + + +class PenpotAPIContentMarkdownConverterTest(unittest.TestCase): + def test_preserves_line_breaks_in_code_examples(self): + html = ( + '
    '
    +            'const source = await penpot.library.connectLibrary("library-id");
    ' + 'penpot.library.tokensSource = source;
    ' + '// Restore local tokens:
    ' + 'penpot.library.tokensSource = penpot.library.local;' + '
    ' + ) + + result = PenpotAPIContentMarkdownConverter().convert(html).strip() + + self.assertEqual( + result, + '```\n' + 'const source = await penpot.library.connectLibrary("library-id");\n' + 'penpot.library.tokensSource = source;\n' + '// Restore local tokens:\n' + 'penpot.library.tokensSource = penpot.library.local;\n' + '```', + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index af3cc43b0b..fbd46deec9 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -4,6 +4,7 @@ - **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 `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. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json index 5034ba9730..4fb9d6b689 100644 --- a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -301,7 +301,8 @@ "availableLibraries", "connectLibrary", "connected", - "local" + "local", + "tokensSource" ], "LibraryElement": ["id", "libraryId", "name", "path"], "LibrarySummary": [ @@ -544,6 +545,7 @@ "addTheme", "getSetById", "getThemeById", + "isEditableTokens", "sets", "themes" ], @@ -5318,6 +5320,12 @@ "type": "Library", "array": true }, + "tokensSource": { + "decl": "LibraryContext", + "kind": "getset", + "type": "Library", + "array": false + }, "availableLibraries": { "decl": "LibraryContext", "kind": "method", @@ -9394,6 +9402,12 @@ } }, "TokenCatalog": { + "isEditableTokens": { + "decl": "TokenCatalog", + "kind": "get", + "type": null, + "array": false + }, "themes": { "decl": "TokenCatalog", "kind": "get", diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 66d73cc4af..aa7008dc13 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -2892,6 +2892,32 @@ export type LibraryContext = { */ readonly connected: Library[]; + /** + * The library that supplies tokens to the current file. + * Defaults to the local library. + * + * Assign a connected library that has its own tokens to use it as the + * source. This copies its active themes and sets to the current file. + * Assign `local` to use the file's own tokens again. Themes and sets + * that don't exist in the local tokens are deactivated. + * Setting the source requires `content:write` permission and supports undo. + * Assigning the current source again keeps the active themes and sets. + * + * The catalog of an external `tokensSource` is read-only and reports the + * library's own active themes and sets. To change which ones are active + * in the current file, use `penpot.library.local.tokens`, which lists the + * tokens of the source. + * + * @example + * ```js + * const source = await penpot.library.connectLibrary('library-id'); + * penpot.library.tokensSource = source; + * // Restore local tokens: + * penpot.library.tokensSource = penpot.library.local; + * ``` + */ + tokensSource: Library; + /** * Retrieves a summary of available libraries that can be connected to. * @return Returns a promise that resolves to an array of `LibrarySummary` objects representing available libraries. @@ -5293,10 +5319,12 @@ export type Token = */ export interface TokenCatalog { /** - * Returns true if the tokens can be edited (it can't - * if the tokens of the file are in an external library). - * Note that even in this case, Themes and Sets status still - * can be changed and tokens can be applied to shapes. + * Returns true if the tokens in this catalog can be edited. Only the + * catalog of the current file (`penpot.library.local.tokens`) can be + * edited, and only when the file is its own tokens source. + * When the tokens come from an external library, the local catalog + * can still change the active themes and sets, and its tokens can + * still be applied to shapes. */ readonly isEditableTokens: boolean; @@ -5370,7 +5398,8 @@ export interface TokenSet { name: string; /** - * Indicates if the set is currently active. + * Indicates if the set is active in the file that owns this catalog. + * Only sets from `penpot.library.local.tokens` can be changed. */ active: boolean; @@ -5386,6 +5415,7 @@ export interface TokenSet { /** * Toggles the active status of this set. + * Only sets from `penpot.library.local.tokens` can be changed. */ toggleActive(): void; @@ -5470,12 +5500,14 @@ export interface TokenTheme { name: string; /** - * Indicates if the theme is currently active. + * Indicates if the theme is active in the file that owns this catalog. + * Only themes from `penpot.library.local.tokens` can be changed. */ active: boolean; /** * Toggles the active status of this theme. + * Only themes from `penpot.library.local.tokens` can be changed. */ toggleActive(): void; From cec8080351a1f922840706cbdb39b3a9cde71352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Fri, 16 Oct 2026 18:22:51 +0200 Subject: [PATCH 047/167] :bug: Show trial status on subscription page (#12389) --- frontend/src/app/main/ui/settings/subscription.cljs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index 1ac36461b0..d1db4cab5f 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -620,7 +620,9 @@ [:div {:class (stl/css :your-subscription)} [:h3 {:class (stl/css :plan-section-title)} (tr "subscription.settings.section-plan")] (if nitrate? - [:> plan-card* {:card-title (tr "subscription.settings.enterprise") + [:> plan-card* {:card-title (if (= (:status nitrate-license) "trialing") + (tr "subscription.settings.enterprise-trial") + (tr "subscription.settings.enterprise")) :cancel-at (when (:cancel-at nitrate-license) (tr "nitrate.subscription.active-until" (ct/format-inst (:cancel-at nitrate-license) "d MMMM, yyyy"))) :benefits-title (tr "subscription.settings.benefits.nitrate-unlimited-benefits") From d1aa36b814745bd10becb517bd4ec0c975c41bba Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 16 Oct 2026 19:00:11 +0000 Subject: [PATCH 048/167] :arrow_up: Update pnpm to 12.14.0 and Node to v24.22.0 Bump the pnpm pin to 12.14.0 in the devenv and the production images and stamp the same version into every package.json, realigning the drift between the devenv (12.9.1) and the rest (12.6.0). Bump Node to v24.22.0, the newest patch of the Krypton LTS line, in .nvmrc, the devenv and image Dockerfiles and the plugins-deploy workflows. The pnpm arch checksums are those of the GitHub release tarballs, not of the npm package. Lockfile changes are metadata only. AI-assisted-by: deepseek-v4.1-flash --- .github/workflows/plugins-deploy-api-doc.yml | 2 +- .../workflows/plugins-deploy-styles-doc.yml | 2 +- .nvmrc | 2 +- .serena/memories/critical-info.md | 1 + .serena/memories/workflow/updating-node.md | 24 ++++ .serena/memories/workflow/updating-pnpm.md | 17 ++- backend/package.json | 2 +- backend/pnpm-lock.yaml | 122 +++++++++--------- common/package.json | 2 +- common/pnpm-lock.yaml | 122 +++++++++--------- docker/devenv/Dockerfile | 56 ++++---- docker/images/Dockerfile.exporter | 8 +- docker/images/Dockerfile.mcp | 10 +- docker/images/Dockerfile.media-processor | 8 +- 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/tokenscript/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 +++++++++--------- 56 files changed, 790 insertions(+), 752 deletions(-) create mode 100644 .serena/memories/workflow/updating-node.md diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 3bd9470178..d9e7393859 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@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0 with: working-directory: plugins - runtime: node@24.21.0 + runtime: node@24.22.0 install: false - name: Install deps diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 0cba3f685d..4f4d23e271 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@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0 with: working-directory: plugins - runtime: node@24.21.0 + runtime: node@24.22.0 install: false - name: Install deps diff --git a/.nvmrc b/.nvmrc index 166aae1151..cdd6de7dff 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.21.0 +v24.22.0 diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index d0304e1fe8..3a822cd873 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 a Node version update → `mem:workflow/updating-node` (the six pin sites in `.nvmrc`, the devenv and image Dockerfiles and the plugins-deploy workflows, the DHI tag gotcha, what to verify) - **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/workflow/updating-node.md b/.serena/memories/workflow/updating-node.md new file mode 100644 index 0000000000..fce094a2a1 --- /dev/null +++ b/.serena/memories/workflow/updating-node.md @@ -0,0 +1,24 @@ +# Updating Node Across the Toolchain + +No single source of truth: nothing reads `.nvmrc` at build time, and the version is repeated in a different shape per file. Six sites move together. + +## Pin sites + +- `.nvmrc` → `v`, with the leading `v`. +- `docker/devenv/Dockerfile`, `setup-node` stage → `ENV NODE_VERSION=v`; downloads `https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-{arm64,x64}.tar.gz`. Node is the only download in that file with **no** checksum verification (pnpm, JDK and AWS CLI do verify), so bumping Node has no hash to recompute. +- `docker/images/Dockerfile.mcp` → two stages: `FROM dhi.io/node:-debian13-dev AS build` and `FROM dhi.io/node:-debian13 AS image`. The release stage carries no `-dev`. +- `docker/images/Dockerfile.media-processor` and `docker/images/Dockerfile.exporter` → single `FROM dhi.io/node:-debian13-dev`. +- `.github/workflows/plugins-deploy-api-doc.yml` and `.github/workflows/plugins-deploy-styles-doc.yml` → `runtime: node@` in the `pnpm/setup` step. Its `working-directory: plugins` makes pnpm's own version come from the manifest, so this pin is the Node one; keep it equal to `.nvmrc` minus the `v`. +- DHI tags take the bare version; only the devenv `NODE_VERSION` carries the `v`. + +## Gotchas + +- DHI tags are exact-version and lag the upstream release, and dhi.io answers 401 to anonymous requests, so a tag that does not exist yet can only be caught by a real build. `build-docker.yml` and `build-docker-devenv.yml` log into dhi.io, so the failure surfaces in CI, not locally. +- Node 24.x is the Krypton LTS line: take the newest patch of that major unless a major bump is the point. +- Renovate also opens these bumps, on `renovate-*` branches and with its config living outside this repo; a manual bump and Renovate's must not diverge on any of the six sites. +- Both workflows must stay valid YAML. The `python3` in the devenv has no `yaml` module, so validate with `uv run --with pyyaml python3 -c ...` instead of a bare import. + +## Verification + +- `rg ''` finds nothing outside `CHANGES.md` and untracked scratch files (a repo-wide grep without path filters hits multi-megabyte ClojureScript analyzer caches: always scope it). +- After a devenv rebuild, `node --version` matches `.nvmrc` and `pnpm --version` matches `PNPM_VERSION` in `docker/devenv/Dockerfile`. diff --git a/.serena/memories/workflow/updating-pnpm.md b/.serena/memories/workflow/updating-pnpm.md index 1bb1596959..1c92add414 100644 --- a/.serena/memories/workflow/updating-pnpm.md +++ b/.serena/memories/workflow/updating-pnpm.md @@ -49,14 +49,23 @@ file (never pipe tool output through filters). `pnpm view pnpm dist-tags --json` for `latest-12` (latest 12.x). The tag moves over time; always re-check. 2. Bump `PNPM_VERSION` (+ arch SHAs) in `docker/devenv/Dockerfile` and in - `docker/images/Dockerfile.{media-processor,exporter,mcp}`. + `docker/images/Dockerfile.{media-processor,exporter,mcp}`. Those SHAs are + of the **GitHub release tarballs**, one per arch: download + `pnpm-linux-{arm64,x64}.tar.gz` from the `v` release and + `sha256sum` each. The npm `dist.integrity` that the script stamps in step + 3 is a different hash of a different artifact — never reuse it for the + Dockerfiles. 3. From the repo root, on a host whose system pnpm is the target version (rebuilt devenv), run `scripts/sync-pnpm-version`. It resolves the integrity hash via `pnpm view` + node (no npm, no corepack, no python3) and stamps the identical field into all 35 files, replacing only the value line (each file keeps its own indent) and inserting the key after `"name"`/`"version"` where missing. Explicit version - instead: `scripts/sync-pnpm-version `. + instead: `scripts/sync-pnpm-version `. The explicit form works + from a host whose system pnpm is older than the target (the registry + lookup resolves the hash, and the first later `pnpm` call auto-downloads + the target into `package-manager-store`), so a rebuild is only needed for + the in-container `pnpm --version` to match. 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. @@ -76,6 +85,10 @@ file (never pipe tool output through filters). gone on purpose: pnpm picks the nearest `pnpm-workspace.yaml` walking up, so a nested one silently forks install and lockfile behavior. Do not reintroduce it. +- `media-processor/target/package.json` is a generated, gitignored copy that + the script also stamps, so stamping and `--check` report 36 files while + only 35 are tracked. A stale copy there makes `--check` fail with nothing + wrong in the repo: delete `media-processor/target` and re-run. - Expect metadata-only lockfile diffs when only the pnpm version moves: the pnpm self-reference entries, plus a new `packageManagerDependencies` section in lockfiles last written by older pnpm. Large diffs mean diff --git a/backend/package.json b/backend/package.json index 1e103493fe..a2813b9649 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 18113d0170..8cef22d73a 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' diff --git a/common/package.json b/common/package.json index d921c77202..2fa9d1fcdd 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "type": "module", "repository": { "type": "git", diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index a19e1083c1..0df77b1194 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 11dcf79391..ebc75a1d2d 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.21.0 \ - PNPM_VERSION=12.9.1 \ +ENV NODE_VERSION=v24.22.0 \ + PNPM_VERSION=12.14.0 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -77,13 +77,13 @@ RUN set -eux; \ OPENSSL_ARCH='linux-aarch64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='468294b77633d29889c6e4a79cefb1277eba30cd62e7d6a754218cb85a1d730d'; \ + PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ PNPM_ARCH='x64'; \ - PNPM_SHA256='b4f58449ac02d9d24023ac89e17e05e8331f8ddc12ca1f489cf231bc2bb2bce1'; \ + PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -107,31 +107,31 @@ RUN set -eux; \ ## OPENCODE SETUP ################################################################################ -FROM base AS setup-opencode +# FROM base AS setup-opencode -ENV OPENCODE_VERSION=2.0.29 +# ENV OPENCODE_VERSION=2.0.29 -RUN set -eux; \ - ARCH="$(dpkg --print-architecture)"; \ - case "${ARCH}" in \ - aarch64|arm64) \ - BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-arm64/-/cli-linux-arm64-${OPENCODE_VERSION}.tgz"; \ - ESUM='0af39e75dbdfacc20f8f531ef35fb4dcc0b44c08084203518614272f232283ae'; \ - ;; \ - amd64|x86_64) \ - BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-${OPENCODE_VERSION}.tgz"; \ - ESUM='14b5a6638a1a093ac9d484599c844b10c29b445d04844b682ccc668ce26530a2'; \ - ;; \ - *) \ - echo "Unsupported arch: ${ARCH}"; exit 1; \ - ;; \ - esac; \ - curl -LfsSo /tmp/opencode.tgz "${BINARY_URL}"; \ - echo "${ESUM} */tmp/opencode.tgz" | sha256sum -c -; \ - mkdir -p /tmp/opencode; \ - tar -xzf /tmp/opencode.tgz -C /tmp/opencode --strip-components=2 package/bin; \ - chmod +x /tmp/opencode/opencode; \ - rm -f /tmp/opencode.tgz; +# RUN set -eux; \ +# ARCH="$(dpkg --print-architecture)"; \ +# case "${ARCH}" in \ +# aarch64|arm64) \ +# BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-arm64/-/cli-linux-arm64-${OPENCODE_VERSION}.tgz"; \ +# ESUM='0af39e75dbdfacc20f8f531ef35fb4dcc0b44c08084203518614272f232283ae'; \ +# ;; \ +# amd64|x86_64) \ +# BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-${OPENCODE_VERSION}.tgz"; \ +# ESUM='14b5a6638a1a093ac9d484599c844b10c29b445d04844b682ccc668ce26530a2'; \ +# ;; \ +# *) \ +# echo "Unsupported arch: ${ARCH}"; exit 1; \ +# ;; \ +# esac; \ +# curl -LfsSo /tmp/opencode.tgz "${BINARY_URL}"; \ +# echo "${ESUM} */tmp/opencode.tgz" | sha256sum -c -; \ +# mkdir -p /tmp/opencode; \ +# tar -xzf /tmp/opencode.tgz -C /tmp/opencode --strip-components=2 package/bin; \ +# chmod +x /tmp/opencode/opencode; \ +# rm -f /tmp/opencode.tgz; ################################################################################ @@ -558,7 +558,7 @@ COPY --from=setup-rust /opt/cargo /opt/cargo COPY --from=setup-rust /opt/rustup /opt/rustup COPY --from=setup-rust /opt/emsdk /opt/emsdk COPY --from=setup-caddy /usr/bin/caddy /usr/bin/caddy -COPY --from=setup-opencode /tmp/opencode/opencode /opt/utils/bin/opencode +# COPY --from=setup-opencode /tmp/opencode/opencode /opt/utils/bin/opencode COPY files/nginx.conf /etc/nginx/nginx.conf COPY files/nginx-mime.types /etc/nginx/mime.types diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 1153014be3..dd28f7cb43 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.21.0-debian13-dev +FROM dhi.io/node:24.22.0-debian13-dev LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ @@ -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.6.0 +ARG PNPM_VERSION=12.14.0 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='973af2b3eb9509416cf889a7336705062c936407d5fda07298814e7532f5dea1'; \ + PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3f4c66f668d0e84219679982d095b30da68325e1fac9284e7f5dc1584bd3d13e'; \ + PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index 1394306bd1..6f91c42991 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.21.0-debian13-dev AS build +FROM dhi.io/node:24.22.0-debian13-dev AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -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.6.0 +ARG PNPM_VERSION=12.14.0 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='973af2b3eb9509416cf889a7336705062c936407d5fda07298814e7532f5dea1'; \ + PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3f4c66f668d0e84219679982d095b30da68325e1fac9284e7f5dc1584bd3d13e'; \ + PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.21.0-debian13 AS image +FROM dhi.io/node:24.22.0-debian13 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 fbabbfb70f..eefa694905 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.21.0-debian13-dev +FROM dhi.io/node:24.22.0-debian13-dev LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ @@ -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.6.0 +ARG PNPM_VERSION=12.14.0 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='973af2b3eb9509416cf889a7336705062c936407d5fda07298814e7532f5dea1'; \ + PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3f4c66f668d0e84219679982d095b30da68325e1fac9284e7f5dc1584bd3d13e'; \ + PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docs/package.json b/docs/package.json index dd15b23ccb..4897ce0855 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index ed94c030a5..10afcffde5 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' diff --git a/exporter/package.json b/exporter/package.json index f596181f04..d4dccd7091 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index c15da53ffa..ec7e2177a1 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' diff --git a/frontend/package.json b/frontend/package.json index 432db7a070..e470580a87 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "browserslist": [ "defaults" ], diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 6618f61074..aa187debb2 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 64bbd16dad..d5c2a52548 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "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 565edc0855..8cafca6749 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index 1b8f97c154..b8269621d3 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "exports": { ".": { "import": "./dist/index.js" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 35c7052b59..3f045541a8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index f47cdd851f..06cd1124a0 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/library/package.json b/library/package.json index 94da4f1912..af0488c1df 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "type": "module", "repository": { "type": "git", diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index e7d3cc1f84..cf2b0d0663 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' diff --git a/mcp/package.json b/mcp/package.json index 98505ca3e5..c378c46e94 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "devDependencies": { "concurrently": "^10.0.5", "prettier": "^3.9.9" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index c8bdde4bd0..45452fe567 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "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 64f7a0c8b6..fbd002728e 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "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 d400b4f4e6..737e9f8546 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -27,7 +27,7 @@ "engines": { "node": ">=20" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "dependencies": { "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 05834afcd4..5510053fe8 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' diff --git a/media-processor/package.json b/media-processor/package.json index 155498f016..0b3ddc1e27 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "dependencies": { "express": "^5.2.1", "multer": "^2.2.0", diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index 774b041d11..d988aa5fb9 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' diff --git a/package.json b/package.json index 1289dce20b..20591c84cf 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "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 d7f66f0b8e..fa28b3aad9 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index feee72036d..5257d9f075 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 341e530bc4..73700d96b6 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index 6c2ed36828..419b52fa7b 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index 5f837261ee..46941b0c59 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -7,5 +7,5 @@ "test": "vitest", "lint": "eslint ." }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index 1f05146cdf..af7a4d9c58 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index a290d2afd0..69226e5f40 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index 6e874c26f8..3dfe909673 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index 7bbf13f2df..d4c72366d0 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index 2c563bd33a..23e3d77b98 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index 61654d0646..b715d220df 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index 179d878ab5..66bdbad7f1 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index 6ab4bd4881..180320699b 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 2fac5e0feb..148f002897 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index c752df719c..7fbd7f2fa7 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 26d4ee0d1d..985920cc4b 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/plugins/package.json b/plugins/package.json index 2f6630b530..3da12fffea 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "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 02dda4ee4d..a370bad9b2 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e2bdb7d80..f8b01fbc82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' diff --git a/render-wasm/package.json b/render-wasm/package.json index 795a79d0c7..fea9914702 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.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index b8a64120ea..87a062b5c1 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.14.0 + version: 12.14.0 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.14.0': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.14.0': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.14.0': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.14.0': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.14.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.14.0': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.14.0': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.14.0': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.14.0': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.14.0': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.14.0': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.14.0': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.14.0': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.14.0': optional: true - pnpm@12.6.0: + pnpm@12.14.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 --- lockfileVersion: '9.0' From 016c3976d018f6b34b42dbcb073f728bb756f465 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 17 Oct 2026 11:54:50 +0000 Subject: [PATCH 049/167] :wrench: Install opencode on demand instead of baking it in The opencode build stage was already commented out, so the image carried a dead block and a commented COPY. Drop both: opencode now comes from scripts/setup-opencode, which installs @opencode/cli as a global pnpm package in the penpot user's $PNPM_HOME. That dir sits in the user-data volume, so the install survives a container re-creation and needs no root. pnpm 12 keeps the global binaries in $PNPM_HOME/bin, so put that dir on PATH in the bashrc (and make the guard check the same path) instead of $PNPM_HOME, which left the commands unreachable. The entrypoint now also chowns ~/.local, ~/.cache and the dotfiles it copies from /root. EXTERNAL_UID remaps penpot to the host UID on every start, so the ownership the image set at build time stops matching; that broke global pnpm installs and pnpm's own packageManager download. AI-assisted-by: deepseek-v4.1-flash --- .serena/memories/critical-info.md | 1 + .serena/memories/devenv/core.md | 2 ++ .serena/memories/workflow/updating-pnpm.md | 4 ++- docker/devenv/Dockerfile | 32 -------------------- docker/devenv/files/bashrc | 4 +-- docker/devenv/files/entrypoint.sh | 14 +++++++++ scripts/setup-opencode | 35 ++++++++++++++++++++++ 7 files changed, 57 insertions(+), 35 deletions(-) create mode 100755 scripts/setup-opencode diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 3a822cd873..053bf961d8 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -60,6 +60,7 @@ The memory is structured in a way that you can get the critical information abou - `scripts/changelog.py` — Changelog checks for the `update-changelog` skill: `check-merged` (PR merge status), `cross-ref` (milestone PRs vs changelog section), `report` (anomaly report to CHANGES-ISSUES.md). Calls `scripts/gh.py` via subprocess. See `mem:scripts/changelog`. - `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`. # Dependency graph diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index 7bd436221a..7e9161fff0 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -24,6 +24,8 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par - Network aliases (`- main`, `- redis`) are not declared in main.yml. Compose's auto-service-alias still registers `redis` on the shared network, so DNS for `redis` is non-deterministic with multiple instances. Backend uses `PENPOT_REDIS_URI=redis://penpot-devenv-wsN-valkey/0` (container_name) instead. - No cross-project `depends_on`. `manage.sh ensure-infra-up` uses Compose `--wait`; PostgreSQL, RustFS, and Mailpit expose healthchecks. PostgreSQL checks TCP so its temporary init server cannot report ready. - `JAVA_OPTS` in `manage.sh` is shadowed inside the container by `_env`. The `-e JAVA_OPTS=...` flag only matters for processes that don't source `_env`. +- opencode is not part of the devenv image. `scripts/setup-opencode` installs the `@opencode/cli` global pnpm package into the penpot user's `$PNPM_HOME` (`/home/penpot/.local/share/pnpm`, inside the user-data volume, so it survives a container re-creation); nothing pins the version in the repo, it tracks the `latest` dist-tag at install time. pnpm 12 puts the global packages in `$PNPM_HOME/global` and the bin dir in `$PNPM_HOME/bin`, and aborts with `ERR_PNPM_GLOBAL_BIN_DIR_NOT_IN_PATH` unless that bin dir is on `PATH`; `files/bashrc` and the script both add it. The script exports `PNPM_HOME` itself because non-interactive shells never read `~/.bashrc`. +- `files/entrypoint.sh` chowns penpot's whole `~/.local` and `~/.cache` on every start, because `EXTERNAL_UID` remaps penpot's UID to the host user and build-time ownership then stops matching. That covers pnpm's global dir, store, `package-manager-store` and metadata cache, plus opencode's own state; without it, global installs and pnpm's `packageManager` auto-download fail on permissions. The dotfiles copied from `/root` (`.bashrc`, `.vimrc`, `.tmux.conf`) are chowned in the same pass. `~/.config` stays out on purpose (the opencode overlay bind-mounts a host directory inside it) and so does `/home/penpot` itself, which holds the repo bind mount. ## Worker policy diff --git a/.serena/memories/workflow/updating-pnpm.md b/.serena/memories/workflow/updating-pnpm.md index 1c92add414..cbaafdeffc 100644 --- a/.serena/memories/workflow/updating-pnpm.md +++ b/.serena/memories/workflow/updating-pnpm.md @@ -33,7 +33,9 @@ file (never pipe tool output through filters). images). Nothing may call corepack: it is gone from Node 25+. pnpm auto-downloads the `packageManager` version on mismatch (`pmOnFail: download`, the default), so drift self-heals; aligned pins - just skip the download. + just skip the download. Inside the devenv that download lands in the + penpot user's `package-manager-store`, which `files/entrypoint.sh` chowns + to penpot on every start (see `mem:devenv/core`). - The pnpm version pin lives in three places that move together: `PNPM_VERSION` (+ arch SHAs) in `docker/devenv/Dockerfile` and in `docker/images/Dockerfile.{media-processor,exporter,mcp}`, and the diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index ebc75a1d2d..4cc0e0817e 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -103,37 +103,6 @@ RUN set -eux; \ find /opt/node/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \; ; \ rm -f /tmp/nodejs.tar.gz /tmp/pnpm.tar.gz; -################################################################################ -## OPENCODE SETUP -################################################################################ - -# FROM base AS setup-opencode - -# ENV OPENCODE_VERSION=2.0.29 - -# RUN set -eux; \ -# ARCH="$(dpkg --print-architecture)"; \ -# case "${ARCH}" in \ -# aarch64|arm64) \ -# BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-arm64/-/cli-linux-arm64-${OPENCODE_VERSION}.tgz"; \ -# ESUM='0af39e75dbdfacc20f8f531ef35fb4dcc0b44c08084203518614272f232283ae'; \ -# ;; \ -# amd64|x86_64) \ -# BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-${OPENCODE_VERSION}.tgz"; \ -# ESUM='14b5a6638a1a093ac9d484599c844b10c29b445d04844b682ccc668ce26530a2'; \ -# ;; \ -# *) \ -# echo "Unsupported arch: ${ARCH}"; exit 1; \ -# ;; \ -# esac; \ -# curl -LfsSo /tmp/opencode.tgz "${BINARY_URL}"; \ -# echo "${ESUM} */tmp/opencode.tgz" | sha256sum -c -; \ -# mkdir -p /tmp/opencode; \ -# tar -xzf /tmp/opencode.tgz -C /tmp/opencode --strip-components=2 package/bin; \ -# chmod +x /tmp/opencode/opencode; \ -# rm -f /tmp/opencode.tgz; - - ################################################################################ ## CADDYSERVER SETUP ################################################################################ @@ -558,7 +527,6 @@ COPY --from=setup-rust /opt/cargo /opt/cargo COPY --from=setup-rust /opt/rustup /opt/rustup COPY --from=setup-rust /opt/emsdk /opt/emsdk COPY --from=setup-caddy /usr/bin/caddy /usr/bin/caddy -# COPY --from=setup-opencode /tmp/opencode/opencode /opt/utils/bin/opencode COPY files/nginx.conf /etc/nginx/nginx.conf COPY files/nginx-mime.types /etc/nginx/mime.types diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index 5097ab010e..5fb66c9613 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -27,7 +27,7 @@ fi # pnpm export PNPM_HOME="/home/penpot/.local/share/pnpm" case ":$PATH:" in - *":$PNPM_HOME:"*) ;; - *) export PATH="$PNPM_HOME:$PATH" ;; + *":$PNPM_HOME/bin:"*) ;; + *) export PATH="$PNPM_HOME/bin:$PATH" ;; esac # pnpm end diff --git a/docker/devenv/files/entrypoint.sh b/docker/devenv/files/entrypoint.sh index d7a00a670a..193005d3fe 100755 --- a/docker/devenv/files/entrypoint.sh +++ b/docker/devenv/files/entrypoint.sh @@ -10,6 +10,10 @@ cp /root/.bashrc /home/penpot/.bashrc cp /root/.vimrc /home/penpot/.vimrc cp /root/.tmux.conf /home/penpot/.tmux.conf +chown -R penpot:penpot /home/penpot/.bashrc +chown -R penpot:penpot /home/penpot/.vimrc +chown -R penpot:penpot /home/penpot/.tmux.conf + # Seed SERENA_HOME with default config on first run mkdir -p ${SERENA_HOME} if [ ! -f "${SERENA_HOME}/serena_config.yml" ]; then @@ -21,6 +25,16 @@ chown penpot:users /home/penpot # we need to be able to install rust-analyzer and possibly other dependencies with rustup chown -R penpot:penpot /opt/rustup +# pnpm keeps its global dir (bin, global, store, package-manager-store) +# under ~/.local and its metadata cache under ~/.cache; opencode and other +# user-space tools write there too. Hand both trees back to penpot so +# packages, and pnpm itself, can be updated without root. Needed because the +# UID remap above can leave everything the image created owned by the old +# UID. ~/.config is deliberately excluded: the opencode overlay bind-mounts +# a host directory inside it. +mkdir -p /home/penpot/.local /home/penpot/.cache +chown -R penpot:penpot /home/penpot/.local /home/penpot/.cache + rsync -ar --chown=penpot:users /opt/cargo/ /home/penpot/.cargo/ export JAVA_OPTS="-Djava.net.preferIPv4Stack=true" diff --git a/scripts/setup-opencode b/scripts/setup-opencode new file mode 100755 index 0000000000..044311b45f --- /dev/null +++ b/scripts/setup-opencode @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Install the opencode CLI as a global pnpm package. +# +# opencode is not part of the devenv image: run this when you want the +# `opencode` and `opencode2` commands. The install lives in $PNPM_HOME, which +# inside the devenv is the penpot home (the user-data volume), so it survives +# a container re-creation. +# +# Usage: scripts/setup-opencode + +set -euo pipefail + +if ! command -v pnpm >/dev/null 2>&1; then + echo "setup-opencode: pnpm is not on PATH." >&2 + exit 1 +fi + +# pnpm installs global packages under $PNPM_HOME and refuses to run when its +# bin dir is not on PATH: without this the install aborts with +# ERR_PNPM_GLOBAL_BIN_DIR_NOT_IN_PATH. Non-interactive shells do not read +# ~/.bashrc, so both are set here. The default is the directory pnpm itself +# uses on Linux. +export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}" +path_hint=0 +case ":$PATH:" in + *":$PNPM_HOME/bin:"*) ;; + *) path_hint=1; export PATH="$PNPM_HOME/bin:$PATH" ;; +esac + +pnpm add -g --allow-build=@opencode/cli @opencode/cli +opencode --version + +if [[ $path_hint -eq 1 ]]; then + echo "note: $PNPM_HOME/bin is not on your PATH; add it to run 'opencode' directly." >&2 +fi From 231adf4edc491f3d1e747ee182baa5a62dda517f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 17 Oct 2026 11:58:25 +0000 Subject: [PATCH 050/167] :sparkles: Add a skill to rewrite a squashed commit message The new skill turns HEAD's stacked subjects, bodies and repeated trailers (a typical result of squashing) into one message that follows mem:workflow/creating-commits: a single imperative subject, a body covering the whole branch diff, then the trailers. It collects every distinct AI-assisted-by value before rewriting, so an attribution is never dropped, and it requires ./scripts/check-commit to exit 0 after the amend. It never pushes: rewriting history over commits the user already pushed is their call, not the agent's. AI-assisted-by: deepseek-v4.1-flash --- .../skills/rewrite-commit-message/SKILL.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .agents/skills/rewrite-commit-message/SKILL.md diff --git a/.agents/skills/rewrite-commit-message/SKILL.md b/.agents/skills/rewrite-commit-message/SKILL.md new file mode 100644 index 0000000000..3281d0fa75 --- /dev/null +++ b/.agents/skills/rewrite-commit-message/SKILL.md @@ -0,0 +1,38 @@ +--- +name: rewrite-commit-message +description: Rewrite HEAD's commit message (typically a squash that concatenated old messages) into one proper title plus description following Penpot commit conventions, preserving every distinct AI-assisted-by attribution. Amends in place, never pushes. +slash: true +--- + +# Skill: rewrite-commit-message + +HEAD's message is a concatenation of older messages (typical after a +squash): several subjects, bodies and repeated trailers stacked in a +single message. Replace it with one proper message and amend HEAD. + +## When to Use + +- The user asks to rewrite, retitle or fix the last commit message +- After a squash left HEAD with stacked messages and trailer repeats + +## Steps + +1. Read the full current message (`git log -1 --format=%B HEAD`) and + the branch diff (`git diff develop..HEAD --stat`, plus the code + when the summary needs grounding). +2. Collect every DISTINCT `AI-assisted-by:` value in the current + message. All of them survive into the new message, one line each: + attributions are never dropped in a rewrite, even when most of + the old text is. +3. Draft one message per `mem:workflow/creating-commits` (read it + end-to-end first): single subject (imperative, capitalized, no + trailing period, ≤80 chars), blank line, body wrapped at ≤80 + chars covering the whole diff (not the old per-commit slices), + then the preserved trailers. +4. Write it to a file (a heredoc keeps real newlines; inline `-m` + with `\n` does not) and `git commit --amend -F `. +5. Run `./scripts/check-commit` and require exit code 0. A non-zero + exit is a hard blocker: fix the message, amend again. +6. Report the new hash. Never push: the user pushes from their own + shell. Warn when local history was rewritten over pushed commits + (the push will need force — their call, not yours). From 53f83c98cfdb7d852b73c6453381d4086509f6fe Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:01:55 +0200 Subject: [PATCH 051/167] :arrow_up: Update GitHub Actions (#12401) - actions/checkout v7 (.github/workflows/commit-checker.yml) - actions/download-artifact v8.0.2 (.github/workflows/tests-e2e.yml) - actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml) - actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml) - actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml) - actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- .github/workflows/commit-checker.yml | 2 +- .github/workflows/tests-e2e.yml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index 0de7fe8975..15790e72a9 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -56,7 +56,7 @@ jobs: # always the trusted version: PR commits are only read, never # executed. On push events this resolves to the pushed sha. - name: Checkout trusted code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 ref: ${{ github.event.pull_request.base.sha || github.sha }} diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index 84db8d144d..167f00ddf8 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -206,7 +206,7 @@ jobs: --reporter=blob - name: Upload blob report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-blob-report-${{ matrix.shard }} @@ -215,7 +215,7 @@ jobs: retention-days: 3 - name: Upload test result - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-tests-result-${{ matrix.shard }} @@ -338,7 +338,7 @@ jobs: pnpm install --frozen-lockfile; - name: Download blob reports - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: frontend/all-blob-reports pattern: integration-blob-report-* @@ -367,7 +367,7 @@ jobs: >> "$GITHUB_STEP_SUMMARY" - name: Upload JSON report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-json-report @@ -377,7 +377,7 @@ jobs: retention-days: 7 - name: Upload HTML report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-html-report From 1a68928aca1ac323158222bc2867da033431b662 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:06:09 +0200 Subject: [PATCH 052/167] :arrow_up: Update DHI base images (#12399) - dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/devenv/Dockerfile) - dhi.io/nginx 1.31.7-alpine3.24@6d3811f (docker/images/Dockerfile.storybook) - dhi.io/nginx 1.31.7-debian13-dev@10c0037 (docker/images/Dockerfile.frontend) - dhi.io/node 24.22.0@ (docker/devenv/Dockerfile) - dhi.io/node 24.22.0@ (.nvmrc) - dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.media-processor) - dhi.io/node 24.22.0-debian13@c6ddacf (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.exporter) --- .nvmrc | 2 +- docker/devenv/Dockerfile | 8 ++++---- docker/imagemagick/Dockerfile | 2 +- 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, 14 insertions(+), 14 deletions(-) diff --git a/.nvmrc b/.nvmrc index 166aae1151..cdd6de7dff 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.21.0 +v24.22.0 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index d8ea87ac5d..2a2d2284a9 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 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.21.0 \ +ENV NODE_VERSION=v24.22.0 \ PNPM_VERSION=12.9.1 \ 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='724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5'; \ + NODE_SHA256='b7e7991d5407815adfa74a2a9cd4db683552c8ce32ff4a295c22e972849c3f5d'; \ PNPM_ARCH='arm64'; \ PNPM_SHA256='468294b77633d29889c6e4a79cefb1277eba30cd62e7d6a754218cb85a1d730d'; \ ;; \ 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='6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff'; \ + NODE_SHA256='28ea0a438de9a13bd7891fd758eee6c7819ee8af5442dfd8d45bd65e56d4228c'; \ PNPM_ARCH='x64'; \ PNPM_SHA256='b4f58449ac02d9d24023ac89e17e05e8331f8ddc12ca1f489cf231bc2bb2bce1'; \ ;; \ diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index bfb5544c7d..a0323a1c63 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 4937b04cda..3d74f8be43 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 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:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 03d468e72e..34abe54df6 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.21.0-debian13-dev@sha256:2e51e8397b85c3771c554834fb55d7fd5825e7d57a993e00153db42c584b7e58 +FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 6e913f5919..8213d88490 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.6-debian13-dev@sha256:32e434084ff7e82629abc090c534fa0b02ebf1a7465f8029314bf9e95349c3a1 +FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:10c0037149e51fec759fb6006d5ab8bc8343f0d718c86841bd510848cc5eeef4 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index d175718177..ca65a69340 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.21.0-debian13-dev@sha256:2e51e8397b85c3771c554834fb55d7fd5825e7d57a993e00153db42c584b7e58 AS build +FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.21.0-debian13@sha256:f3575cf1e68ffe163703b691c465af833fd2d94cccc1e825d052c943018f2aa3 AS image +FROM dhi.io/node:24.22.0-debian13@sha256:c6ddacf90691f33734423b06682a63b27abeeafee294830455e91c79614ece8b 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 20831a6798..ead97142c7 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.21.0-debian13-dev@sha256:2e51e8397b85c3771c554834fb55d7fd5825e7d57a993e00153db42c584b7e58 +FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 13a887fb0a..2b259ffb61 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.6-alpine3.24@sha256:94652754aa6bbf187859b24c0b547aa2c3d07e99d4462868f8d9f36bf2039d88 +FROM dhi.io/nginx:1.31.7-alpine3.24@sha256:6d3811fbc76c5d0652752b39a003769759ccfeb2c1e874bacf99e3d44e5da3b9 LABEL maintainer="Penpot " ARG BUNDLE_PATH="./bundle-storybook/" From fcbc4f17695d6a81892465e0f9588c83b6f658c1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 17 Oct 2026 12:53:13 +0000 Subject: [PATCH 053/167] :bug: Remove duplicate declarations in the typography sample `.typography-sample-input` declared `font-size` and `line-height` twice with the same values, so stylelint failed the frontend lint with `declaration-block-no-duplicate-properties`. The duplication came from an earlier staging into develop merge: one side moved the pair next to its comment, the other kept it below `block-size`, and the merge kept both. Drop the second pair. No behaviour change: both pairs held the same values, and only the last declaration of a block takes effect. AI-assisted-by: deepseek-v4.1-flash --- .../app/main/ui/workspace/sidebar/options/menus/typography.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index 40e0374813..b9354a9f10 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -128,9 +128,7 @@ $font-preview-box-height: 28px; flex-shrink: 0; min-inline-size: $sz-24; block-size: 100%; - font-size: px2rem(16); white-space: nowrap; - line-height: 1; overflow: hidden; color: var(--color-foreground-primary); } From 9336962a773280a96ce14294995db7b3a1b07240 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 17 Oct 2026 14:54:23 +0200 Subject: [PATCH 054/167] :rewind: Backport docker changes from develop --- docker/devenv/Dockerfile | 7 +++++-- docker/imagemagick/Dockerfile | 6 +++--- docker/images/Dockerfile.backend | 6 +++--- docker/images/Dockerfile.exporter | 4 ++-- docker/images/Dockerfile.frontend | 2 +- docker/images/Dockerfile.mcp | 4 ++-- docker/images/Dockerfile.media-processor | 2 +- docker/images/Dockerfile.storybook | 2 +- 8 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 4cc0e0817e..ba65456579 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ @@ -76,12 +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='b7e7991d5407815adfa74a2a9cd4db683552c8ce32ff4a295c22e972849c3f5d'; \ PNPM_ARCH='arm64'; \ PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ ;; \ 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='28ea0a438de9a13bd7891fd758eee6c7819ee8af5442dfd8d45bd65e56d4228c'; \ PNPM_ARCH='x64'; \ PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ ;; \ @@ -91,6 +93,7 @@ RUN set -eux; \ ;; \ esac; \ curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \ + echo "${NODE_SHA256} */tmp/nodejs.tar.gz" | sha256sum -c -; \ mkdir -p /opt/node; \ cd /opt/node; \ tar -xf /tmp/nodejs.tar.gz --strip-components=1; \ @@ -516,7 +519,7 @@ ENV LANG='C.UTF-8' \ SERENA_CONTEXT="claude-code" \ PATH="/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" -COPY --from=penpotapp/imagemagick:7.1.2-27-1 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick COPY --from=setup-jvm /opt/jdk /opt/jdk COPY --from=setup-jvm /opt/clojure /opt/clojure COPY --from=setup-node /opt/node /opt/node diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index 0ab72b37af..a0323a1c63 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -6,7 +6,7 @@ ENV LANG='C.UTF-8' \ DEBIAN_FRONTEND=noninteractive \ TZ=Etc/UTC -ARG IMAGEMAGICK_VERSION=7.1.2-27 +ARG IMAGEMAGICK_VERSION=7.1.2-32 RUN set -e; \ apt-get -qq update; \ @@ -74,7 +74,7 @@ RUN set -ex; \ rm -rf /var/lib/apt/lists/* -FROM dhi.io/debian-base:trixie-debian13 AS image +FROM dhi.io/debian-base:trixie-debian13@sha256:20079b51710f0397da5e056bfc7156b6aafc7ff3aa4ef4cbcb0b0f1df99cd8c4 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index e2f1f15179..3d74f8be43 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 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 AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -106,7 +106,7 @@ RUN set -ex; \ chown -R penpot:penpot /opt/data; COPY --from=build /opt/jre /opt/jre -COPY --from=penpotapp/imagemagick:7.1.2-27-1 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick COPY files/imagemagick-policy.xml /opt/imagick/etc/ImageMagick-7/policy.xml diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index dd28f7cb43..4bbb65594f 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.0-debian13-dev +FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ @@ -88,7 +88,7 @@ RUN set -ex; \ ARG BUNDLE_PATH="./bundle-exporter/" COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/exporter/ -COPY --from=penpotapp/imagemagick:7.1.2-27-1 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick WORKDIR /opt/penpot/exporter diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 7c6547779e..8213d88490 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.6-debian13-dev +FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:10c0037149e51fec759fb6006d5ab8bc8343f0d718c86841bd510848cc5eeef4 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index 6f91c42991..f73a35696b 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.0-debian13-dev AS build +FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.22.0-debian13 AS image +FROM dhi.io/node:24.22.0-debian13@sha256:c6ddacf90691f33734423b06682a63b27abeeafee294830455e91c79614ece8b 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 eefa694905..1590dcf6a1 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.0-debian13-dev +FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 7428803510..2b259ffb61 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.6-alpine3.24 +FROM dhi.io/nginx:1.31.7-alpine3.24@sha256:6d3811fbc76c5d0652752b39a003769759ccfeb2c1e874bacf99e3d44e5da3b9 LABEL maintainer="Penpot " ARG BUNDLE_PATH="./bundle-storybook/" From 07e085c2cf4c8b323b0a47535d4ae74e9144b48a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 17 Oct 2026 14:55:22 +0200 Subject: [PATCH 055/167] :revind: Backport several updates for .github/workflows from develop --- .github/workflows/commit-checker.yml | 2 +- .github/workflows/tests-e2e.yml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index 0de7fe8975..15790e72a9 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -56,7 +56,7 @@ jobs: # always the trusted version: PR commits are only read, never # executed. On push events this resolves to the pushed sha. - name: Checkout trusted code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 ref: ${{ github.event.pull_request.base.sha || github.sha }} diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index 84db8d144d..167f00ddf8 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -206,7 +206,7 @@ jobs: --reporter=blob - name: Upload blob report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-blob-report-${{ matrix.shard }} @@ -215,7 +215,7 @@ jobs: retention-days: 3 - name: Upload test result - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-tests-result-${{ matrix.shard }} @@ -338,7 +338,7 @@ jobs: pnpm install --frozen-lockfile; - name: Download blob reports - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: frontend/all-blob-reports pattern: integration-blob-report-* @@ -367,7 +367,7 @@ jobs: >> "$GITHUB_STEP_SUMMARY" - name: Upload JSON report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-json-report @@ -377,7 +377,7 @@ jobs: retention-days: 7 - name: Upload HTML report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-html-report From 15606a3f85b6ad82ff7a753e5b4d7dfe7b3a2242 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 17 Oct 2026 13:46:22 +0000 Subject: [PATCH 056/167] :bug: Answer 400 on a request body the parser cannot read A body declared as JSON that clojure.data.json cannot read ended as a 500 :unexpected with the whole stack trace in the log: wrap-parse-request classified the failure by exception class, and the library signals its syntax errors with a bare java.lang.Exception. Classify it where it happens instead: read-body-params reads the body and raises :validation :malformed-json (400) for anything it cannot parse, keeps RequestTooBigException at 413 and re-raises a structured error from deeper untouched. The body is read outside the handler try, so the error goes out as a throw like the other specific-exception branches. The failure is now one log line (path, peer address, content-type, parser message) without the stack trace, and it is counted: the middleware takes the metrics instance as a middleware argument, so the backend exports penpot_http_malformed_requests_total and nexus declares its own nexus_http_malformed_requests_total. Tests: parse-request-malformed-json-body reproduces the production body and asserts the 400 and the counter; the existing parse-request tests now pass a metrics stub. AI-assisted-by: deepseek-v4.1-flash --- .serena/memories/audit-log.md | 2 +- .serena/memories/backend/subtleties.md | 3 +- backend/src/app/http.clj | 3 +- backend/src/app/http/middleware.clj | 106 ++++++++++++++---- backend/src/app/main.clj | 6 + .../backend_tests/http_middleware_test.clj | 52 +++++++-- 6 files changed, 139 insertions(+), 33 deletions(-) diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index 3739612214..898e95e413 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -269,7 +269,7 @@ Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and insert - **Anonymization happens in Penpot, before sending.** By the time events leave, times are day-truncated, IPs are already `0.0.0.0`, props/context are filtered (see above), and emails are gone (only derived `email-domain` survives). Nexus additionally forces the outcome: it prefixes any non-`telemetry:` source, sets `received-at = tracked-at` (already truncated), pins IP to `0.0.0.0`, and stamps the sending `instance-id` into every event context. - **No personal data is expected.** Profile ids are random UUIDs with no PII, so the telemetry consumer needs no login guard; grouping is by `instance`, not by person (see posthog-telemetry below). -- **The endpoint always answers 200.** Failures are swallowed and only counted (`nexus_telemetry_errors_total`), so a bad batch never breaks the sending instance; successes are counted (`nexus_telemetry_events_total`, `nexus_telemetry_reports_total`). Old instances may send a fressian+zstd base64 blob instead of a plain vector; Nexus decodes both (50 MiB cap against decompression bombs). +- **The endpoint always answers 200.** Failures are swallowed and only counted (`nexus_telemetry_errors_total`), so a bad batch never breaks the sending instance; successes are counted (`nexus_telemetry_events_total`, `nexus_telemetry_reports_total`). A body the parser cannot read never reaches the handler: it answers 400 and counts in `nexus_http_malformed_requests_total`. Old instances may send a fressian+zstd base64 blob instead of a plain vector; Nexus decodes both (50 MiB cap against decompression bombs). ## 12. Archive to Nexus (today) and to Nitrate (future) diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index 996b2c19d2..7e6c6ba54b 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -42,7 +42,7 @@ - `app.config/config` and `flags` are dynamic `defonce` vars populated from `PENPOT_*` env vars through the shared schema string transformer. Tests and tooling can bind them. - `parse-flags` automatically adds `:disable-secure-session-cookies` when `public-uri` is plain HTTP and not localhost. This changes cookie defaults without an explicit env flag. - The backend sets Clojure `*assert*` globally from the `:backend-asserts` feature flag. Assertion-dependent checks can therefore differ by runtime flags. -- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. +- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. A body the parser cannot read is a client error, classified at the parse site and never by exception class (`clojure.data.json` signals its syntax errors with a bare `java.lang.Exception`): 400 `:validation`/`:malformed-json`, one `warn` line (`malformed request body`) carrying path, peer address and content-type instead of a stack trace, and `penpot_http_malformed_requests_total`. `RequestTooBigException` keeps its 413. - Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling. - Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`. Only `kid=1`/`ver=1` tokens are decoded as session tokens; anything else is left unauthenticated (legacy v1 tokens were removed). - Shared-key auth requires `x-shared-key` as ` ` and stores the lowercased key id on the request. If no shared keys are configured it always rejects. @@ -60,6 +60,7 @@ ## Metrics recording - `app.metrics/run!` is safe by default: a recording failure never throws (a metrics bug must not change the behavior of the measured operation). The first failure per metric id logs at `warn`, later ones at `debug`. The `instance` precondition is a plain assert (the backend enables `:backend-asserts`), and the collector lookup sits outside the recording guard, so a missing instance fails hard even when asserts are disabled. `::mtx/metrics` is required by the storage, s3-backend, and db-pool schemas; `app.db` wires the prometheus `MetricsTrackerFactory` unconditionally. Storage-specific metric contracts: `mem:backend/storage`. +- `penpot_http_malformed_requests_total` (counter, no labels) counts requests whose body the parser could not read. `app.http.middleware/wrap-parse-request` emits it where it classifies the error, so the instance travels as a middleware argument (`[mw/parse-request (::mtx/metrics cfg)]`) and each service declares the definition with its own prefix (nexus: `nexus_http_malformed_requests_total`). The per-endpoint request counters of nexus do not count these requests: they sit inside the parse middleware, so the failure happens before them. ## Log-derived metrics diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index 30efa426e4..aa6950fdeb 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -244,6 +244,7 @@ [::mgmt/routes schema:routes] ::session/manager ::setup/props + ::mtx/metrics ::db/pool]) (defmethod ig/assert-key ::router @@ -260,7 +261,7 @@ [mw/auth {:bearer (partial session/decode-token cfg) :cookie (partial session/decode-token cfg) :token (partial actoken/decode-token cfg)}] - [mw/parse-request] + [mw/parse-request (::mtx/metrics cfg)] [mw/errors errors/handle] [mw/restrict-methods]]} diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index 45f8abecca..ad80bfe9c4 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -14,7 +14,9 @@ [app.config :as cf] [app.http :as-alias http] [app.http.errors :as errors] + [app.metrics :as mtx] [app.tokens :as tokens] + [app.util.inet :as inet] [app.util.pointer-map :as pmap] [cuerdas.core :as str] [yetti.adapter :as yt] @@ -44,27 +46,81 @@ (java.io.BufferedReader. (java.io.InputStreamReader. body)))) +(def ^:private not-a-body + "Marker returned by `read-body-params` when the `content-type` header names + neither transit nor json; the request is left untouched. + + A fresh object and not a keyword: a keyword is a legal value for a decoded + body, so identity is the only safe comparison." + (Object.)) + +(defn- raise-malformed-body + "Signals a body that the parser could not read. + + A failure while reading the body means the body does not match what the + client declared, so it is a client error: the exception class says nothing + here, because `clojure.data.json` signals its syntax errors with a bare + `java.lang.Exception`." + [request metrics content-type cause] + ;; A single line, without the stack trace: the trace of a parse failure + ;; hides the fields (path, peer address, content-type) that identify the + ;; client sending the broken body. + (l/wrn :hint "malformed request body" + :path (yreq/path request) + :remote-addr (inet/parse-request request) + :content-type content-type + :error (ex/get-hint cause)) + + (mtx/run! metrics + :id :http-malformed-requests-total + :inc 1) + + (ex/raise :type :validation + :code :malformed-json + :hint "invalid JSON in request body" + :cause cause)) + +(defn- read-body-params + "Reads the request body according to the `content-type` header. + + `RequestTooBigException` keeps its own code (413) and a structured error + raised from deeper (an `ex-info`) travels untouched; anything else is a + malformed body." + [request metrics content-type] + (try + (cond + (str/starts-with? content-type "application/transit+json") + (with-open [^InputStream is (yreq/body request)] + (t/read! (t/reader is))) + + (str/starts-with? content-type "application/json") + (with-open [reader (get-reader request)] + (json/read reader :key-fn json/read-kebab-key)) + + :else + not-a-body) + + (catch RequestTooBigException cause + (ex/raise :type :validation + :code :request-body-too-large + :hint "request body exceeds size limit" + :cause cause)) + + (catch Throwable cause + (if (ex/error? cause) + (throw cause) + (raise-malformed-body request metrics content-type cause))))) + (defn wrap-parse-request - [handler] + [handler metrics] (letfn [(process-request [request] - (let [header (yreq/get-header request "content-type")] - (cond - (str/starts-with? header "application/transit+json") - (with-open [^InputStream is (yreq/body request)] - (let [params (t/read! (t/reader is))] - (-> request - (assoc :body-params params) - (update :params merge params)))) - - (str/starts-with? header "application/json") - (with-open [reader (get-reader request)] - (let [params (json/read reader :key-fn json/read-kebab-key)] - (-> request - (assoc :body-params params) - (update :params merge params)))) - - :else - request))) + (let [header (yreq/get-header request "content-type") + params (read-body-params request metrics header)] + (if (identical? params not-a-body) + request + (-> request + (assoc :body-params params) + (update :params merge params))))) ;; The specific-exception branches below (IAE, ;; RequestTooBigException, EOFException) raise with @@ -107,10 +163,14 @@ (fn [request] (if (= (yreq/method request) :post) - (try - (-> request process-request handler) - (catch Throwable cause - (handle-error cause request))) + ;; The body is read outside the `try`: a failure there is always + ;; raised already classified by `read-body-params`, and goes out as a + ;; throw like the rest of the specific-exception branches above. + (let [request (process-request request)] + (try + (handler request) + (catch Throwable cause + (handle-error cause request)))) (handler request))))) (def parse-request diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 9fc93eb26e..43039cdee4 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -226,6 +226,11 @@ :http-connector-errors-total {::mdef/name "penpot_http_connector_errors_total" ::mdef/help "Total number of handler errors in the http listener." + ::mdef/type :counter} + + :http-malformed-requests-total + {::mdef/name "penpot_http_malformed_requests_total" + ::mdef/help "Total number of requests whose body the parser could not read." ::mdef/type :counter}}) (def system-config @@ -360,6 +365,7 @@ ::rpc/routes (ig/ref ::rpc/routes) ::setup/props (ig/ref ::setup/props) ::mtx/routes (ig/ref ::mtx/routes) + ::mtx/metrics (ig/ref ::mtx/metrics) ::oidc/routes (ig/ref ::oidc/routes) ::mgmt/routes (ig/ref ::mgmt/routes) ::http.debug/routes (ig/ref ::http.debug/routes) diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index 1651d92e0a..c572e20c09 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -16,7 +16,8 @@ [app.http.errors :as http-errors] [app.http.middleware :as mw] [app.http.session :as session] - [app.main :as-alias main] + [app.main :as main] + [app.metrics :as mtx] [app.rpc :as-alias rpc] [app.rpc.commands.access-token] [app.tokens :as tokens] @@ -28,6 +29,7 @@ [yetti.request :as yreq] [yetti.response :as yres]) (:import + io.prometheus.client.CollectorRegistry io.undertow.server.RequestTooBigException)) (t/use-fixtures :once th/state-init) @@ -515,7 +517,8 @@ ;; handler in app.http/router-handler), this test asserts on ;; the ex-info thrown by wrap-parse-request directly. (let [handler (#'app.http.middleware/wrap-parse-request - (fn [_] {::yres/status 200 ::yres/body :ok})) + (fn [_] {::yres/status 200 ::yres/body :ok}) + (th/metrics-stub)) ;; Body contains the bytes for: {"x": "\}"} -- a string ;; value with a backslash followed by '}', which ;; clojure.data.json v0.5.x cannot handle. @@ -532,13 +535,44 @@ (t/is (= :malformed-json (-> ex ex-data :code))) (t/is (= "invalid JSON in request body" (-> ex ex-data :hint))))) +(defn- malformed-requests-count + "Value of the malformed-body counter of a metrics instance." + [metrics] + (.getSampleValue ^CollectorRegistry (mtx/get-registry metrics) + "penpot_http_malformed_requests_total" + (make-array String 0) + (make-array String 0))) + +(t/deftest parse-request-malformed-json-body + ;; The body shape seen in production: an object where the parser expects a + ;; key. clojure.data.json signals it with a bare java.lang.Exception, which + ;; the class-based classification did not recognize, so it surfaced as a 500 + ;; :unexpected with the whole stack trace in the log. It is a client error: + ;; 400 :malformed-json, and it is counted. + (let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics}) + handler (#'app.http.middleware/wrap-parse-request + (fn [_] {::yres/status 200 ::yres/body :ok}) + metrics) + request (th/make-dummy-request + {:method :post + :headers {"content-type" "application/json"} + :body-bytes (.getBytes "{\"events\":[{{\"a\":1}:\"b\"}]}" "UTF-8")}) + ex (try + (handler request) + (catch clojure.lang.ExceptionInfo e e))] + (t/is (instance? clojure.lang.ExceptionInfo ex)) + (t/is (= :validation (-> ex ex-data :type))) + (t/is (= :malformed-json (-> ex ex-data :code))) + (t/is (= 1.0 (malformed-requests-count metrics))))) + (t/deftest parse-request-request-too-big-exception ;; When RequestTooBigException is raised (e.g. the request body ;; exceeded the configured size limit), the middleware should ;; convert it to a 413 :request-body-too-large validation ;; error. (let [handler (#'app.http.middleware/wrap-parse-request - (fn [_] (throw (RequestTooBigException. "too large")))) + (fn [_] (throw (RequestTooBigException. "too large"))) + (th/metrics-stub)) request (th/make-dummy-request {:method :post :headers {"content-type" "application/json"} @@ -556,7 +590,8 @@ ;; was closed before the parser could read it), the middleware ;; should convert it to a 400 :malformed-json validation error. (let [handler (#'app.http.middleware/wrap-parse-request - (fn [_] (throw (java.io.EOFException. "stream closed")))) + (fn [_] (throw (java.io.EOFException. "stream closed"))) + (th/metrics-stub)) request (th/make-dummy-request {:method :post :headers {"content-type" "application/json"} @@ -579,7 +614,8 @@ wrapped (doto (RuntimeException. "wrapped") (.initCause iae)) handler (#'app.http.middleware/wrap-parse-request - (fn [_] (throw wrapped))) + (fn [_] (throw wrapped)) + (th/metrics-stub)) request (th/make-dummy-request {:method :post :headers {"content-type" "application/json"} @@ -597,7 +633,8 @@ ;; path and return a 500 with :type :server-error :code ;; :unexpected. This is the "true internal error" path. (let [handler (#'app.http.middleware/wrap-parse-request - (fn [_] (throw (RuntimeException. "boom")))) + (fn [_] (throw (RuntimeException. "boom"))) + (th/metrics-stub)) request (th/make-dummy-request {:method :post :headers {"content-type" "application/json"} @@ -617,7 +654,8 @@ ;; has a dedicated handle-exception method that returns 500 ;; with :code :io-exception. (let [handler (#'app.http.middleware/wrap-parse-request - (fn [_] (throw (java.io.IOException. "network gone")))) + (fn [_] (throw (java.io.IOException. "network gone"))) + (th/metrics-stub)) request (th/make-dummy-request {:method :post :headers {"content-type" "application/json"} From 70b317a976e0bc1c68a47882f23244cecc1e710a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 17 Oct 2026 16:01:04 +0200 Subject: [PATCH 057/167] :bug: Harden upload path handling and validation error bodies (#12067) * :ambulance: Reject forged upload paths outside multipart Transit bodies can smuggle upload maps whose path points at any server file; the schema only checks the shape and handlers open the path directly. app.rpc now injects a server-side multipart flag that bodies cannot forge, and methods declare their upload keys with the new multipart-only-params metadata, enforced centrally after schema validation with a params-validation error. Covers all nine upload methods, including chunked and management flows. AI-assisted-by: muse-spark-1.3-contributor * :bug: Sanitize raw explain in generic validation error bodies The generic :validation branch returned ex-data untouched, so errors carrying the raw malli explain (live schema objects, e.g. storage invalid-storage-metadata) broke JSON encoding mid-stream and clients got truncated bodies. Apply the same clean treatment the known codes already use: drop the raw explain and attach its humanized text. AI-assisted-by: muse-spark-1.3-contributor * :bug: Add e2e path traversal tests for binfile and media upload Add two e2e suites that hand-write transit bodies with a forged ["~#path", ...] upload path (no transit library needed) and expect 400 validation/params-validation. They cover import-binfile :file, upload-file-media-object :content and upload-chunk :content, plus a plain-JSON string-path control case. Before the multipart-path fix they failed (500 io-exception, invalid-image, 200 stored chunk); after it they pass. AI-assisted-by: muse-spark-1.3-contributor * :bug: Use multipart helper in binfile SVG test setup The SVG import setup uploaded through plain command!, so the new multipart-only guard rejected it with params-validation. Switch the setup to multipart-command! like the other upload tests. AI-assisted-by: muse-spark-1.3-contributor * :bug: Use multipart helper in remaining upload tests The multipart-only upload guard rejects plain RPC calls carrying upload maps. Five tests with real files still used command! and failed with params-validation. Switch them to multipart-command! while keeping the forged-content tests on plain command!. AI-assisted-by: muse-spark-1.3-contributor --- backend/src/app/http/errors.clj | 6 +- backend/src/app/rpc.clj | 31 +- backend/src/app/rpc/commands/binfile.clj | 1 + .../src/app/rpc/commands/files_thumbnails.clj | 6 +- backend/src/app/rpc/commands/media.clj | 2 + backend/src/app/rpc/commands/profile.clj | 1 + backend/src/app/rpc/commands/teams.clj | 3 +- backend/src/app/rpc/management/exporter.clj | 1 + backend/src/app/rpc/management/nitrate.clj | 1 + .../test/backend_tests/binfile_svg_test.clj | 18 +- backend/test/backend_tests/helpers.clj | 34 ++ backend/test/backend_tests/helpers_test.clj | 29 ++ .../backend_tests/http_middleware_test.clj | 22 ++ .../test/backend_tests/rpc_binfile_test.clj | 44 +++ backend/test/backend_tests/rpc_file_test.clj | 18 +- .../rpc_file_thumbnails_test.clj | 110 ++++-- backend/test/backend_tests/rpc_font_test.clj | 20 +- .../rpc_management_nitrate_test.clj | 16 + .../backend_tests/rpc_management_test.clj | 19 +- backend/test/backend_tests/rpc_media_test.clj | 315 ++++++++++-------- .../test/backend_tests/rpc_profile_test.clj | 25 +- .../test/backend_tests/rpc_quotes_test.clj | 2 +- backend/test/backend_tests/rpc_team_test.clj | 16 +- backend/test/backend_tests/storage_test.clj | 104 +++--- .../import-binfile-path-traversal.test.mjs | 110 ++++++ .../upload-file-media-path-traversal.test.mjs | 189 +++++++++++ 26 files changed, 865 insertions(+), 278 deletions(-) create mode 100644 backend/test/e2e/import-binfile-path-traversal.test.mjs create mode 100644 backend/test/e2e/upload-file-media-path-traversal.test.mjs diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index e8b103c417..b8ef12eb74 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -122,7 +122,11 @@ {::yres/status 400 ::yres/body data})) :else - {::yres/status 400 ::yres/body data}))) + (let [explain (ex/explain data)] + {::yres/status 400 + ::yres/body (-> data + (dissoc ::sm/explain) + (cond-> explain (assoc :explain explain)))})))) (defmethod handle-error :assertion [error request parent-cause] diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index e28485181f..c5e464c75e 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -127,7 +127,15 @@ (-> (assoc ::auth-type :token) (assoc ::token-perms (set (::actoken/perms request #{}))))) (cond-> key-id - (assoc ::auth-key-id key-id))) + (assoc ::auth-key-id key-id)) + ;; Server-side only: request bodies cannot forge + ;; it (qualified keys are stripped before the + ;; merge). Upload maps are only trusted when they + ;; arrive over multipart. + (assoc ::is-multipart + (str/starts-with? + (str (yreq/get-header request "content-type")) + "multipart/"))) data (with-meta data {::http/request request}) @@ -228,10 +236,23 @@ (fn [cfg params] (let [request-params (-> params meta ::http/request :params decode)] (if (validate request-params) - (let [result (f cfg (merge params (d/without-qualified request-params)))] - (if (instance? clojure.lang.IObj result) - (vary-meta result assoc :encode/json encode) - result)) + (let [merged (merge params (d/without-qualified request-params))] + ;; Upload temp files are created server-side by the multipart + ;; parser, so an upload map smuggled in a transit/JSON body can + ;; point :path at any internal server file. Methods declare + ;; their upload keys with ::multipart-only-params and they are + ;; only accepted when ::is-multipart arrived server-side with + ;; the request (request bodies cannot forge qualified keys). + (when (some #(and (contains? merged %) + (not (::is-multipart merged))) + (::multipart-only-params mdata)) + (ex/raise :type :validation + :code :params-validation + :hint "upload requires multipart")) + (let [result (f cfg merged)] + (if (instance? clojure.lang.IObj result) + (vary-meta result assoc :encode/json encode) + result))) (ex/raise :type :validation :code :params-validation ::sm/explain (explain request-params)))))) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index c4fc37cb99..e842fd4c60 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -170,6 +170,7 @@ ::webhooks/event? true ::sse/stream? true ::sm/params schema:import-binfile + ::rpc/multipart-only-params [:file] ::climit/id [[:import-binfile/by-profile ::rpc/profile-id] [:import-binfile/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}] diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 1a821c4027..964e3be645 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -286,7 +286,8 @@ ::rtry/enabled true ::rtry/when rtry/conflict-exception? ::audit/skip true - ::sm/params schema:create-file-object-thumbnail} + ::sm/params schema:create-file-object-thumbnail + ::rpc/multipart-only-params [:media]} [cfg {:keys [::rpc/profile-id file-id object-id media tag]}] (media.v/validate-media-type! media) @@ -387,7 +388,8 @@ [:file-thumbnail-ops/global]] ::rtry/enabled true ::rtry/when rtry/conflict-exception? - ::sm/params schema:create-file-thumbnail} + ::sm/params schema:create-file-thumbnail + ::rpc/multipart-only-params [:media]} [cfg {:keys [::rpc/profile-id file-id] :as params}] (media.v/validate-media-type! (:media params)) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 1c9e07ed01..fa2199d24c 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -57,6 +57,7 @@ {::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"]] ::sm/params schema:upload-file-media-object + ::rpc/multipart-only-params [:content] ::climit/id [[:process-image/by-profile ::rpc/profile-id] [:process-image/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}] @@ -371,6 +372,7 @@ (sv/defmethod ::upload-chunk {::doc/added "2.17" ::sm/params schema:upload-chunk + ::rpc/multipart-only-params [:content] ::sm/result schema:upload-chunk-result} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id session-id index content] :as _params}] diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 82d307bb46..c0cdf56971 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -307,6 +307,7 @@ (sv/defmethod ::update-profile-photo {:doc/added "1.1" ::sm/params schema:update-profile-photo + ::rpc/multipart-only-params [:file] ::sm/result :nil} [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 0cfb0f2baf..f8b85ac0eb 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -1084,7 +1084,8 @@ (sv/defmethod ::update-team-photo {::doc/added "1.17" - ::sm/params schema:update-team-photo} + ::sm/params schema:update-team-photo + ::rpc/multipart-only-params [:file]} [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index dabca00835..332fe23984 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -31,6 +31,7 @@ (sv/defmethod ::upload-tempfile {::doc/added "2.12" ::sm/params schema:upload-tempfile-params + ::rpc/multipart-only-params [:content] ::sm/result schema:upload-tempfile-result} [cfg {:keys [::rpc/profile-id content]}] (media.v/validate-media-type! content cm/tempfile-types) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 36e37aca9d..bb97a66f37 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -135,6 +135,7 @@ collection when replacing an existing one." {::doc/added "2.18" ::sm/params schema:upload-organization-logo + ::rpc/multipart-only-params [:content] ::sm/result schema:upload-organization-logo-result ::nitrate/sso false} [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}] diff --git a/backend/test/backend_tests/binfile_svg_test.clj b/backend/test/backend_tests/binfile_svg_test.clj index 92fe3f2a0e..446abca995 100644 --- a/backend/test/backend_tests/binfile_svg_test.clj +++ b/backend/test/backend_tests/binfile_svg_test.clj @@ -195,15 +195,15 @@ :project-id (:default-project-id profile) :is-shared false}) [svg-path svg-size] (write-temp-svg clean-svg) - out (th/command! {::th/type :upload-file-media-object - ::rpc/profile-id (:id profile) - :file-id (:id file) - :is-local true - :name "benign.svg" - :content {:filename "benign.svg" - :path svg-path - :mtype "image/svg+xml" - :size svg-size}}) + out (th/multipart-command! {::th/type :upload-file-media-object + ::rpc/profile-id (:id profile) + :file-id (:id file) + :is-local true + :name "benign.svg" + :content {:filename "benign.svg" + :path svg-path + :mtype "image/svg+xml" + :size svg-size}}) _ (t/is (nil? (:error out)) (str "upload failed: " (pr-str (:error out)))) uploaded (:result out) page-id (first (get-in file [:data :pages])) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 4882827b65..8aa3b684fc 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -471,6 +471,13 @@ (-> params (assoc :app.rpc/request-at (ct/now)) + ;; Mirror of the production flag in `app.rpc/make-rpc-handler`: + ;; upload maps are only trusted over multipart. Tests declare it + ;; with a content-type header on the supplied request map. + (assoc ::rpc/is-multipart + (str/starts-with? + (str (yreq/get-header request "content-type")) + "multipart/")) (with-meta {:app.http/request request})))) (defn command! @@ -508,6 +515,33 @@ (let [params (prepare-rpc-params data)] (try-on! (method-fn params))))) +(def multipart-headers + {"content-type" "multipart/form-data; boundary=test"}) + +(defn with-multipart + "Attach a multipart content-type to RPC test params, declaring that the + upload maps they carry arrived over multipart (as the production + multipart parser would deliver them). Preserves any request metadata + already present on the data." + [data] + (let [request (-> data meta :app.http/request)] + (vary-meta data assoc :app.http/request + (assoc (if (map? request) request {}) + :headers (merge multipart-headers + (when (map? request) + (:headers request))))))) + +(defn multipart-command! + "Like `command!` but declares the request as multipart, for calls that + carry upload maps (`:file`, `:content`, `:media`)." + [data] + (command! (with-multipart data))) + +(defn multipart-management-command! + "Like `management-command!` but declares the request as multipart." + [data] + (management-command! (with-multipart data))) + (defn run-task! ([name] (run-task! name {})) diff --git a/backend/test/backend_tests/helpers_test.clj b/backend/test/backend_tests/helpers_test.clj index d8e1bb2dc2..2473f86115 100644 --- a/backend/test/backend_tests/helpers_test.clj +++ b/backend/test/backend_tests/helpers_test.clj @@ -91,6 +91,35 @@ (t/deftest make-dummy-request-missing-cookie-returns-nil-value (t/is (= {:value nil} (yreq/get-cookie (th/make-dummy-request) "missing")))) +;; --- RPC PARAMS: MULTIPART FLAG + +(t/deftest prepare-rpc-params-marks-multipart-request + (let [params (#'th/prepare-rpc-params + (with-meta {::th/type :dummy + :events [{:name "event"}]} + {:app.http/request {:headers {"content-type" "multipart/form-data; boundary=xxx"}}}))] + (t/is (true? (:app.rpc/is-multipart params))))) + +(t/deftest prepare-rpc-params-leaves-non-multipart-request-unmarked + (let [params (#'th/prepare-rpc-params + (with-meta {::th/type :dummy + :events [{:name "event"}]} + {:app.http/request {:headers {"content-type" "application/transit+json"}}}))] + (t/is (false? (:app.rpc/is-multipart params))))) + +(t/deftest prepare-rpc-params-leaves-missing-content-type-unmarked + (let [params (#'th/prepare-rpc-params {::th/type :dummy + :events [{:name "event"}]})] + (t/is (false? (:app.rpc/is-multipart params))))) + +(t/deftest prepare-rpc-params-body-cannot-forge-multipart-flag + (let [params (#'th/prepare-rpc-params + (with-meta {::th/type :dummy + :app.rpc/is-multipart true + :events [{:name "event"}]} + {:app.http/request {:headers {"content-type" "application/json"}}}))] + (t/is (false? (:app.rpc/is-multipart params))))) + ;; --- RPC PARAMS: REQUEST METADATA (t/deftest prepare-rpc-params-preserves-supplied-request diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index c572e20c09..44042a4b2f 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -7,6 +7,7 @@ (ns backend-tests.http-middleware-test (:require [app.common.exceptions :as ex] + [app.common.json :as json] [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] @@ -20,6 +21,7 @@ [app.metrics :as mtx] [app.rpc :as-alias rpc] [app.rpc.commands.access-token] + [app.storage.schema :as stsch] [app.tokens :as tokens] [backend-tests.helpers :as th] [clojure.string :as str] @@ -707,3 +709,23 @@ (t/is (= "safe user-facing hint" (:hint body))) (t/is (nil? (:state body))) (t/is (nil? (:path body))))) + +(t/deftest validation-error-body-with-raw-explain-is-json-serializable + ;; A :validation error whose ex-data carries the raw malli explain + ;; (storage invalid-storage-metadata) must produce a response body + ;; that survives JSON encoding. Before the fix the live schema + ;; object inside the explain broke the writer mid-stream and the + ;; client received a truncated body. + (let [cause (try + (stsch/encode-metadata {}) + (catch clojure.lang.ExceptionInfo cause cause)) + response (http-errors/handle cause {}) + body (::yres/body response)] + (t/is (= 400 (::yres/status response))) + (t/is (= :validation (:type body))) + (t/is (= :invalid-storage-metadata (:code body))) + (t/is (nil? (:app.common.schema/explain body))) + (let [encoded (json/encode body :key-fn json/write-camel-key) + decoded (json/decode encoded :key-fn json/read-kebab-key)] + (t/is (str/includes? encoded "invalid-storage-metadata")) + (t/is (string? (:explain decoded)))))) diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index ccdcc7fafa..0447fe135d 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -45,6 +45,50 @@ (t/is (false? (validator params-with-file-id)) "params with file-id should be rejected"))) +(t/deftest import-binfile-schema-accepts-path-object + ;; Documents the schema hole: a server-side Path object in :file passes + ;; validation on shape alone. The multipart guard in the handler (not the + ;; schema) is what rejects client-forged paths. + (let [schema @#'binfile/schema:import-binfile + validator (sm/lazy-validator schema)] + (t/is (true? (validator {:name "pwned" + :project-id (uuid/random) + :version 3 + :file {:filename "evil.zip" + :size 123 + :path (fs/path "/etc/passwd")}}))))) + +(t/deftest import-binfile-rejects-forged-file-without-multipart + ;; Path-traversal PoC at command level: a transit body like + ;; {"~:file": {"~:path": ["~#path", "/etc/passwd"]}} decodes to this exact + ;; map. Without a multipart request it must fail at params validation, + ;; before any file is read. + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :import-binfile + ::rpc/profile-id (:id prof) + :name "pwned" + :version 3 + :project-id (:default-project-id prof) + :file {:filename "evil.zip" + :size 123 + :path (fs/path "/etc/passwd")}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) + +(t/deftest import-binfile-rejects-forged-file-with-forged-flag + ;; The multipart flag is server-side: smuggling it in the body must not + ;; bypass the guard (prepare-rpc-params overwrites it from the request). + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :import-binfile + ::rpc/profile-id (:id prof) + :app.rpc/is-multipart true + :name "pwned" + :version 3 + :project-id (:default-project-id prof) + :file {:filename "evil.zip" + :size 123 + :path (fs/path "/etc/passwd")}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) + (t/deftest import-binfile-schema-rejects-unsupported-version ;; T1-N2-03: version parameter should be restricted to supported values (1 or 3) (let [schema @#'binfile/schema:import-binfile diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 1bb3a0a09e..34adcae63e 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -264,7 +264,7 @@ :is-local true :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -413,7 +413,7 @@ :is-local true :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -604,7 +604,7 @@ :object-id object-id :tag "frame" :media mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -1264,7 +1264,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - {:keys [error result] :as out} (th/command! data)] + {:keys [error result] :as out} (th/multipart-command! data)] (t/is (nil? error)) (t/is (map? result))) @@ -1321,7 +1321,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - {:keys [error result] :as out} (th/command! data)] + {:keys [error result] :as out} (th/multipart-command! data)] (t/is (nil? error)) (t/is (map? result))) @@ -1345,7 +1345,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - {:keys [error result] :as out} (th/command! data)] + {:keys [error result] :as out} (th/multipart-command! data)] (t/is (nil? error)) (t/is (map? result))) @@ -1396,7 +1396,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - {:keys [error result] :as out} (th/command! data)] + {:keys [error result] :as out} (th/multipart-command! data)] ;; (th/print-result! out) (t/is (nil? error)) @@ -1411,7 +1411,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - {:keys [error result] :as out} (th/command! data)] + {:keys [error result] :as out} (th/multipart-command! data)] ;; (th/print-result! out) (t/is (nil? error)) @@ -1909,7 +1909,7 @@ :is-local true :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 5273180c15..49345f4ba9 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -76,11 +76,11 @@ :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}}] - (let [out (th/command! data1)] + (let [out (th/multipart-command! data1)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) - (let [out (th/command! data2)] + (let [out (th/multipart-command! data2)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -184,12 +184,12 @@ :path (th/tempfile "backend_tests/test_files/sample.jpg") :mtype "image/jpeg"}}] - (let [out (th/command! data1)] + (let [out (th/multipart-command! data1)] ;; (th/print-result! out) (t/is (nil? (:error out))) (t/is (contains? (:result out) :uri))) - (let [out (th/command! data2)] + (let [out (th/multipart-command! data2)] (t/is (nil? (:error out))) (t/is (contains? (:result out) :uri))) @@ -272,7 +272,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data) + out (th/multipart-command! data) error (:error out)] (t/is (nil? (:result out))) @@ -298,7 +298,7 @@ :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}}] - (let [out (th/command! data1)] + (let [out (th/multipart-command! data1)] ;; (th/print-result! out) (t/is (nil? (:error out))) (t/is (contains? (:result out) :uri))) @@ -325,7 +325,7 @@ :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}}] - (let [out (th/command! data)] + (let [out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -341,7 +341,7 @@ (let [params {::th/type :get-file-object-thumbnails ::rpc/profile-id (:id profile) :file-id (:id file)} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) @@ -367,7 +367,7 @@ (-> media (assoc :path (th/tempfile "backend_tests/test_files/sample2.jpg")) (assoc :size 7923)))) - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -376,7 +376,7 @@ (-> media (assoc :path (th/tempfile "backend_tests/test_files/sample.jpg")) (assoc :size 312043)))) - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))))) @@ -402,7 +402,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out))))) @@ -419,7 +419,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1 oid2 oid3]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -440,7 +440,7 @@ data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids []} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out))))) @@ -457,7 +457,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1 oid2]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))))) @@ -480,7 +480,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -488,7 +488,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1 oid2 oid3]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -517,7 +517,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -525,7 +525,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -533,7 +533,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -563,7 +563,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -571,7 +571,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile2) :object-ids [oid]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (some? (:error out))) (t/is (th/ex-info? (:error out))) (t/is (= :not-found (th/ex-type (:error out))))) @@ -606,7 +606,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out))))) @@ -614,7 +614,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1 oid2]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -653,7 +653,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -665,7 +665,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -674,7 +674,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile1) :object-ids [oid1 oid2]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (some? (:error out))) (t/is (th/ex-info? (:error out))) (t/is (= :not-found (th/ex-type (:error out))))) @@ -709,7 +709,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -721,7 +721,7 @@ :size 312043 :path (th/tempfile "backend_tests/test_files/sample.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -740,7 +740,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1 oid2]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -770,7 +770,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out))))) @@ -784,7 +784,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids oids} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -813,7 +813,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -821,7 +821,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -849,7 +849,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -857,7 +857,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid oid]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -891,7 +891,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out))))) @@ -899,7 +899,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -915,3 +915,39 @@ (t/is (= 1 (count rows2))) (t/is (nil? (:deleted-at (first rows2))))))) + +(t/deftest create-file-thumbnail-rejects-forged-media + ;; A transit body like {"~:media": {"~:path": ["~#path", "/etc/passwd"]}} + ;; decodes to this exact map. Without a multipart request it must fail at + ;; params validation, before any file is read. NOTE: plain command! on + ;; purpose, a multipart header would make it legitimate. + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + out (th/command! {::th/type :create-file-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :revn 0 + :media {:filename "evil.jpg" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) + +(t/deftest create-file-object-thumbnail-rejects-forged-media + ;; Same transit-forgery vector through object thumbnails. NOTE: plain + ;; command! on purpose, a multipart header would make it legitimate. + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + out (th/command! {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id "obj" + :media {:filename "evil.jpg" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index bd81112bcb..4ef4533f7a 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -76,11 +76,11 @@ (when (uuid? session-id) (doseq [[idx chunk-data] (map-indexed vector chunks)] (let [mfile (make-chunk-mfile chunk-data mtype) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index idx - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] (t/is (nil? (:error out)))))) session-id)) @@ -438,11 +438,11 @@ ;; Upload all real chunks except the last one (omit it so the session is incomplete) (doseq [[idx chunk-data] (map-indexed vector (butlast chunks))] (let [mfile (make-chunk-mfile chunk-data "font/ttf") - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index idx - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] (t/is (nil? (:error out))))) (let [out (th/command! {::th/type :create-font-variant diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 30ff6a3823..5be56ece30 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -23,6 +23,7 @@ [clojure.set :as set] [clojure.test :as t] [cuerdas.core :as str] + [datoteka.fs :as fs] [mockery.core :refer [with-mocks]])) (t/use-fixtures :once (t/compose-fixtures @@ -2070,3 +2071,18 @@ (t/is (th/success? out)) (let [[params] (:call-args @email-mock)] (t/is (= "" (:user-name params))))))) + +(t/deftest upload-organization-logo-rejects-forged-content + ;; Same transit-forgery vector through the management API: without a + ;; multipart request the wrapper must reject it at params validation. + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + params {::th/type :upload-organization-logo + ::rpc/profile-id (:id profile) + :organization-id (uuid/random) + :content {:filename "evil.png" + :path (fs/path "/etc/passwd") + :mtype "image/png" + :size 123}} + out (th/management-command! params)] + (t/is (th/ex-of-code? (:error out) :params-validation))))) diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index e1f6e81950..a55dcf95a4 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -56,9 +56,9 @@ :size 7}} config (assoc cf/config :public-uri "https://example.com/penpot") out1 (binding [cf/config config] - (th/management-command! params)) + (th/multipart-management-command! params)) out2 (binding [cf/config config] - (th/management-command! params))] + (th/multipart-management-command! params))] (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) (t/is (str/starts-with? (str (get-in out1 [:result :uri])) @@ -77,7 +77,7 @@ :path path :mtype "text/html" :size 27}} - out (th/management-command! params)] + out (th/multipart-management-command! params)] (t/is (some? (:error out))) (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :media-type-not-allowed (th/ex-code (:error out)))))) @@ -720,3 +720,16 @@ (ct/inst? (get-in % [:props :modified-at]))) events)) (t/is (every? #(= (:id team) (get-in % [:props :team-id])) events)))))) + +(t/deftest upload-tempfile-rejects-forged-content + ;; Same transit-forgery vector through the management API: without a + ;; multipart request the wrapper must reject it at params validation. + (let [profile (th/create-profile* 1 {:is-active true}) + params {::th/type :upload-tempfile + ::rpc/profile-id (:id profile) + :content {:filename "evil.png" + :path (fs/path "/etc/passwd") + :mtype "image/png" + :size 123}} + out (th/management-command! params)] + (t/is (th/ex-of-code? (:error out) :params-validation)))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index 985fb68fc2..3b42685b4d 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -73,7 +73,7 @@ :is-local true :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -144,7 +144,7 @@ :is-local true :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -189,7 +189,7 @@ :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] (let [error (:error out) error-data (ex-data error)] @@ -391,11 +391,11 @@ ;; --- 1. Upload chunks --- (doseq [[idx chunk-data] (map-indexed vector chunks)] (let [mfile (make-chunk-mfile chunk-data mtype) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index idx - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] (t/is (nil? (:error out))) (t/is (= session-id (:session-id (:result out)))) (t/is (= idx (:index (:result out)))))) @@ -438,11 +438,11 @@ mfile (make-chunk-mfile (first chunks) mtype) session-id (create-session! prof 1)] - (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile}) + (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) ;; First assemble succeeds; session row is marked as consumed afterwards (let [out1 (th/command! {::th/type :assemble-file-media-object @@ -482,11 +482,11 @@ :size 312043} ;; prof2 tries to upload a chunk into prof1's session - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof2) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof2) + :session-id session-id + :index 0 + :content mfile})] (t/is (some? (:error out))) (t/is (= :not-found (-> out :error ex-data :type))))) @@ -505,11 +505,11 @@ :size 312043}] ;; prof1 uploads a chunk into their own session - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof1) - :session-id session-id - :index 0 - :content mfile})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof1) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out)))) ;; prof2 tries to assemble prof1's session via create-font-variant @@ -540,11 +540,11 @@ :mtype "image/jpeg" :size 312043}] - (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile}) + (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) ;; Assemble with a wrong mtype should fail validation (let [out (th/command! {::th/type :assemble-file-media-object @@ -573,11 +573,11 @@ :size 312043}] ;; Upload only 1 chunk - (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile}) + (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) ;; Assemble: session says 3 expected, only 1 stored → :missing-chunks (let [out (th/command! {::th/type :assemble-file-media-object @@ -606,18 +606,18 @@ mtype "image/jpeg" size (alength (first chunks))] - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (first chunks) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (first chunks) mtype)})] (t/is (nil? (:error out)))) - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (first chunks) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (first chunks) mtype)})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :chunk-already-exists (-> out :error ex-data :code)))) @@ -650,27 +650,27 @@ (t/is (= 3 (count chunks))) - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (nth chunks 0) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (nth chunks 0) mtype)})] (t/is (nil? (:error out)))) - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (nth chunks 0) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (nth chunks 0) mtype)})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :chunk-already-exists (-> out :error ex-data :code)))) - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 1 - :content (make-chunk-mfile (nth chunks 1) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 1 + :content (make-chunk-mfile (nth chunks 1) mtype)})] (t/is (nil? (:error out)))) ;; The mapping table holds exactly the two distinct indices: the @@ -735,11 +735,11 @@ :mtype "image/jpeg" :size 312043} session-id (create-session! prof 1) - upload-out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + upload-out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error upload-out))) (let [assemble-out (th/command! {::th/type :assemble-file-media-object @@ -788,21 +788,21 @@ :size 312043}] ;; index == total-chunks (out of range) - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 2 - :content mfile})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 2 + :content mfile})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :invalid-chunk-index (-> out :error ex-data :code)))) ;; negative index - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index -1 - :content mfile})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index -1 + :content mfile})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :invalid-chunk-index (-> out :error ex-data :code)))))) @@ -821,11 +821,11 @@ mtype "image/jpeg"] ;; 312043 bytes exceeds the mocked 1024-byte cap: rejected - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (first chunks) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (first chunks) mtype)})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :chunk-too-large (-> out :error ex-data :code)))) @@ -835,11 +835,11 @@ session-id])))) ;; A chunk exactly at the cap still uploads fine - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})] (t/is (nil? (:error out))))))) (t/deftest chunked-upload-sessions-per-profile-quota @@ -875,11 +875,11 @@ :path source-path :mtype "image/jpeg" :size 312043} - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) (let [row (th/db-exec-one! ["select session_id, object_id, chunk_index from upload_session_chunk where session_id = ?" @@ -904,19 +904,19 @@ :path source-path :mtype "image/jpeg" :size 312043} - out1 (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out1 (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out1))) (let [before (:count (th/db-exec-one! ["select count(*) from storage_object"])) - out2 (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out2 (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (some? (:error out2))) (t/is (= :validation (-> out2 :error ex-data :type))) (t/is (= :chunk-already-exists (-> out2 :error ex-data :code))) @@ -937,11 +937,11 @@ (th/db-exec! ["insert into upload_session_chunk (session_id, chunk_index, object_id) values (?, ?, null)" session-id 0]) (let [before (:count (th/db-exec-one! ["select count(*) from storage_object"])) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :chunk-already-exists (-> out :error ex-data :code))) @@ -965,21 +965,21 @@ (if (compare-and-set! failed? false true) (throw (ex-info "link boom" {})) (orig pool object-id session-id index)))}] - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (some? (:error out)))) ;; the failed link left no reservation behind (t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" session-id])))) ;; retrying the same index in the same session succeeds - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" session-id])))))))) @@ -999,11 +999,11 @@ :path source-path :mtype "image/jpeg" :size 312043} - out1 (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out1 (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out1))) (let [assemble-out (th/command! {::th/type :assemble-file-media-object @@ -1024,11 +1024,11 @@ ;; uploading to the consumed session fails without creating an object (let [before (:count (th/db-exec-one! ["select count(*) from storage_object"])) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (some? (:error out))) (t/is (= :not-found (-> out :error ex-data :type))) (t/is (= :object-not-found (-> out :error ex-data :code))) @@ -1054,11 +1054,11 @@ :path source-path :mtype "image/jpeg" :size 312043} - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) (let [object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" @@ -1182,13 +1182,13 @@ :mtype "image/jpeg" :size 312043} sent-id (uuid/next) - out (th/command! {::th/type :upload-file-media-object - ::rpc/profile-id (:id prof) - :file-id (:id file) - :is-local true - :name "testfile" - :content mfile - :id sent-id})] + out (th/multipart-command! {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content mfile + :id sent-id})] (t/is (th/ex-info? (:error out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation)))) @@ -1241,12 +1241,45 @@ :path (th/tempfile "backend_tests/test_files/sample.jpg") :mtype "image/jpeg" :size 312043} - out (th/command! {::th/type :upload-file-media-object - ::rpc/profile-id (:id prof) - :file-id (:id file) - :is-local true - :name "testfile" - :content mfile})] + out (th/multipart-command! {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content mfile})] (t/is (th/success? out)) (t/is (= (:id team) (get-in (meta (:result out)) [:app.loggers.audit/replace-props :team-id]))))) + +(t/deftest upload-file-media-object-rejects-forged-content + ;; A transit body like {"~:content": {"~:path": ["~#path", "/etc/passwd"]}} + ;; decodes to this exact map. Without a multipart request it must fail at + ;; params validation, before any file is read. + (let [prof (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + out (th/command! {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content {:filename "evil.jpg" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) + +(t/deftest upload-chunk-rejects-forged-content + ;; Same transit-forgery vector through the chunk primitive: without a + ;; multipart request the wrapper must reject it at params validation. + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id (uuid/random) + :index 0 + :content {:filename "chunk" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 6200261e86..11a13addbe 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -140,12 +140,12 @@ (t/is (= "dark" (get-in out [:result :theme]))))) (t/testing "update photo" - (let [data {::th/type :update-profile-photo - ::rpc/profile-id (:id profile) - :file {:filename "sample.jpg" - :size 123123 - :path (th/tempfile "backend_tests/test_files/sample.jpg") - :mtype "image/jpeg"}} + (let [data (th/with-multipart {::th/type :update-profile-photo + ::rpc/profile-id (:id profile) + :file {:filename "sample.jpg" + :size 123123 + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg"}}) out (th/command! data)] ;; (th/print-result! out) @@ -1829,3 +1829,16 @@ (t/is (th/success? second-out)) (t/is (= (:id profile) (get-in (meta second-out) [:app.loggers.audit/props :id])))))) + +(t/deftest update-profile-photo-rejects-forged-file + ;; A transit body like {"~:file": {"~:path": ["~#path", "/etc/passwd"]}} + ;; decodes to this exact map. Without a multipart request it must fail at + ;; params validation, before any file is read. + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :update-profile-photo + ::rpc/profile-id (:id prof) + :file {:filename "evil.jpg" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) diff --git a/backend/test/backend_tests/rpc_quotes_test.clj b/backend/test/backend_tests/rpc_quotes_test.clj index c691ea678b..7c42451418 100644 --- a/backend/test/backend_tests/rpc_quotes_test.clj +++ b/backend/test/backend_tests/rpc_quotes_test.clj @@ -464,7 +464,7 @@ :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; 312043 bytes > 100 byte limit → should be rejected (t/is (not (th/success? out))) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 991b380548..10638cee1d 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -911,7 +911,7 @@ :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] (t/is (nil? (:error out)))) (let [params {::th/type :delete-team @@ -1443,6 +1443,20 @@ (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation)))) +(t/deftest update-team-photo-rejects-forged-file + ;; Same transit-forgery vector as profile photos: without a multipart + ;; request the wrapper must reject it at params validation. + (let [prof (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id prof)}) + out (th/command! {::th/type :update-team-photo + ::rpc/profile-id (:id prof) + :team-id (:id team) + :file {:filename "evil.jpg" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) + (t/deftest leaver-loses-file-access ;; GHSA-v9r9-h77c-55m2: leaving the team must revoke the file and ;; project roles granted in that team. diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index f85319bc47..0cc5ba990f 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -211,8 +211,8 @@ :name "testfile" :content mfile} - out1 (th/command! params) - out2 (th/command! params)] + out1 (th/multipart-command! params) + out2 (th/multipart-command! params)] (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) @@ -269,11 +269,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (assert (nil? (:error out))) session-id)) @@ -318,7 +318,7 @@ :font-style "normal" :uploads {"font/ttf" session-id}} - out1 (th/command! params1) + out1 (th/multipart-command! params1) out2 (th/command! params2)] ;; (th/print-result! out) @@ -385,8 +385,8 @@ :name "testfile" :content mfile} - out1 (th/command! params) - out2 (th/command! params)] + out1 (th/multipart-command! params) + out2 (th/multipart-command! params)] (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) @@ -661,8 +661,8 @@ :is-local true :name "testfile" :content mfile} - out1 (th/command! params) - out2 (th/command! params)] + out1 (th/multipart-command! params) + out2 (th/multipart-command! params)] (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) @@ -1035,11 +1035,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) @@ -1076,11 +1076,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" @@ -1128,11 +1128,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" session-id])))) @@ -1167,11 +1167,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile}) + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" session-id])) check @#'gc-touched/has-upload-session-chunk-refs? @@ -1212,11 +1212,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile}) + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" session-id]))] (t/is (nil? (:error out))) @@ -1250,11 +1250,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile}) + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" session-id]))] (t/is (nil? (:error out))) @@ -1290,11 +1290,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) @@ -1335,11 +1335,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) ;; soft-delete the profile; the live session is neither consumed nor stalled diff --git a/backend/test/e2e/import-binfile-path-traversal.test.mjs b/backend/test/e2e/import-binfile-path-traversal.test.mjs new file mode 100644 index 0000000000..df5598aa2f --- /dev/null +++ b/backend/test/e2e/import-binfile-path-traversal.test.mjs @@ -0,0 +1,110 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { setupTestProfile } from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; + +function errorCode(body) { + if (body == null) return undefined; + if (typeof body === "string") { + try { + return errorCode(JSON.parse(body)); + } catch { + return undefined; + } + } + return body.code ?? body["~:code"]; +} + +function errorType(body) { + if (body == null) return undefined; + if (typeof body === "string") { + try { + return errorType(JSON.parse(body)); + } catch { + return undefined; + } + } + return body.type ?? body["~:type"]; +} + +describe("import-binfile path traversal", () => { + let profile; + let cookie; + + before(async () => { + const setup = await setupTestProfile(); + profile = setup.profile; + cookie = setup.cookie; + }); + + it("rejects a hand-forged :file path in a transit body", async () => { + // ATTACK SCENARIO: + // 1. The attacker has a valid session and a valid project-id. + // 2. Instead of a real multipart upload, they hand-write a transit + // body (plain JSON, no transit library needed) with a forged + // :file map that points to an internal server file. + // The transit tag ["~#path", "/etc/passwd"] becomes a real + // java.nio.file.Path on the server (see app.common.transit + // read handlers) and passes media.v/schema:upload, which only + // checks the shape, not where the path came from. + // 3. import-binfile then uses (:path file) directly as input-path + // (parse-file-format / get-manifest / import-files!). + // + // EXPECTED BEHAVIOR AFTER FIX: 400 validation/params-validation, + // without touching the file. While vulnerable, the request passes + // validation and fails later (zip/manifest error) or hangs in SSE, + // proving the file was opened. + const transitBody = JSON.stringify({ + "~:project-id": `~u${profile.defaultProjectId}`, + "~:name": "pwned", + "~:version": 3, + "~:file": { + "~:filename": "evil.zip", + "~:size": 123, + "~:path": ["~#path", "/etc/passwd"], + }, + }); + + const res = await rpcPost("import-binfile", transitBody, { + cookieToken: cookie, + contentType: "application/transit+json", + accept: "application/json", + }); + + assert.equal( + res.status, + 400, + `forged :file path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorType(res.body), + "validation", + `expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorCode(res.body), + "params-validation", + `forged :file path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + }); + + it("plain JSON string path stays rejected", async () => { + // Control case: without the transit "~#path" tag the path arrives + // as a plain string and ::fs/path already rejects it. This test + // documents that only the transit vector bypasses the check. + const res = await rpcPost( + "import-binfile", + { + projectId: profile.defaultProjectId, + name: "pwned", + version: 3, + file: { filename: "evil.zip", size: 123, path: "/etc/passwd" }, + }, + { cookieToken: cookie } + ); + + assert.equal(res.status, 400); + assert.equal(errorType(res.body), "validation"); + assert.equal(errorCode(res.body), "params-validation"); + }); +}); diff --git a/backend/test/e2e/upload-file-media-path-traversal.test.mjs b/backend/test/e2e/upload-file-media-path-traversal.test.mjs new file mode 100644 index 0000000000..7b98f820d2 --- /dev/null +++ b/backend/test/e2e/upload-file-media-path-traversal.test.mjs @@ -0,0 +1,189 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { setupTestProfile } from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; + +function errorCode(body) { + if (body == null) return undefined; + if (typeof body === "string") { + try { + return errorCode(JSON.parse(body)); + } catch { + return undefined; + } + } + return body.code ?? body["~:code"]; +} + +function errorType(body) { + if (body == null) return undefined; + if (typeof body === "string") { + try { + return errorType(JSON.parse(body)); + } catch { + return undefined; + } + } + return body.type ?? body["~:type"]; +} + +async function createFile(cookie, projectId, name = "E2E Media Test File") { + const res = await rpcPost( + "create-file", + { name, projectId }, + { cookieToken: cookie } + ); + assert.equal(res.status, 200, `create-file failed: ${JSON.stringify(res.body)}`); + return res.body; +} + +async function createUploadSession(cookie) { + const res = await rpcPost( + "create-upload-session", + { totalChunks: 1 }, + { cookieToken: cookie } + ); + assert.equal( + res.status, + 200, + `create-upload-session failed: ${JSON.stringify(res.body)}` + ); + return res.body.sessionId ?? res.body["~:session-id"]; +} + +describe("upload-file-media path traversal", () => { + let profile; + let cookie; + let fileId; + + before(async () => { + const setup = await setupTestProfile(); + profile = setup.profile; + cookie = setup.cookie; + const file = await createFile(cookie, profile.defaultProjectId); + fileId = file.id; + }); + + it("rejects a hand-forged :content path in a transit body", async () => { + // ATTACK SCENARIO: + // 1. The attacker has a valid session and a file they can edit. + // 2. Instead of a real multipart upload, they hand-write a transit + // body (plain JSON, no transit library needed) with a forged + // :content map that points to an internal server file. + // The transit tag ["~#path", "/etc/passwd"] becomes a real + // java.nio.file.Path on the server (see app.common.transit + // read handlers) and passes media.v/schema:upload, which only + // checks the shape, not where the path came from. The attacker + // also controls :size and :mtype, so validate-media-type! and + // validate-media-size! pass with "image/png" and a small size. + // 3. upload-file-media-object then reads (:path content) in + // process-image (media/run :info) and stores the bytes as a + // media object, which is later downloadable by URL. + // + // EXPECTED BEHAVIOR AFTER FIX: 400 validation/params-validation, + // without touching the file. While vulnerable, the request passes + // validation and fails later in media processing, proving the + // file was opened. + const transitBody = JSON.stringify({ + "~:file-id": `~u${fileId}`, + "~:is-local": true, + "~:name": "pwned", + "~:content": { + "~:filename": "evil.png", + "~:size": 123, + "~:mtype": "image/png", + "~:path": ["~#path", "/etc/passwd"], + }, + }); + + const res = await rpcPost("upload-file-media-object", transitBody, { + cookieToken: cookie, + contentType: "application/transit+json", + accept: "application/json", + }); + + assert.equal( + res.status, + 400, + `forged :content path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorType(res.body), + "validation", + `expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorCode(res.body), + "params-validation", + `forged :content path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + }); + + it("rejects a hand-forged chunk :content path in a transit body", async () => { + // Same vector through upload-chunk: the forged path is read with + // sto/content and stored as a chunk blob. While vulnerable this + // returns 200 (the internal file bytes are stored under the + // attacker session, retrievable via assemble) — proving the file + // was read and kept. After the fix it must be 400 + // validation/params-validation. + const sessionId = await createUploadSession(cookie); + + const transitBody = JSON.stringify({ + "~:session-id": `~u${sessionId}`, + "~:index": 0, + "~:content": { + "~:filename": "c", + "~:size": 123, + "~:mtype": "application/octet-stream", + "~:path": ["~#path", "/etc/passwd"], + }, + }); + + const res = await rpcPost("upload-chunk", transitBody, { + cookieToken: cookie, + contentType: "application/transit+json", + accept: "application/json", + }); + + assert.equal( + res.status, + 400, + `forged chunk :content path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorType(res.body), + "validation", + `expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorCode(res.body), + "params-validation", + `forged chunk :content path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + }); + + it("plain JSON string path stays rejected", async () => { + // Control case: without the transit "~#path" tag the path arrives + // as a plain string and ::fs/path already rejects it. This test + // documents that only the transit vector bypasses the check. + const res = await rpcPost( + "upload-file-media-object", + { + fileId, + isLocal: true, + name: "pwned", + content: { + filename: "evil.png", + size: 123, + mtype: "image/png", + path: "/etc/passwd", + }, + }, + { cookieToken: cookie } + ); + + assert.equal(res.status, 400); + assert.equal(errorType(res.body), "validation"); + assert.equal(errorCode(res.body), "params-validation"); + }); +}); From 49aeeb053b978f29529e3aabd71a8faf007c3616 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 17 Oct 2026 21:53:29 +0000 Subject: [PATCH 058/167] :bug: Use multipart helper in jobs upload-chunk test The multipart-only upload guard rejects the :content map sent by upload-chunked! over plain command!. Send it through multipart-command! like the other upload tests; forged-path tests stay on plain command!. AI-assisted-by: muse-spark-1.3-contributor --- .../backend_tests/rpc/commands/jobs_test.clj | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/backend/test/backend_tests/rpc/commands/jobs_test.clj b/backend/test/backend_tests/rpc/commands/jobs_test.clj index a39f92d71e..5ced5072ee 100644 --- a/backend/test/backend_tests/rpc/commands/jobs_test.clj +++ b/backend/test/backend_tests/rpc/commands/jobs_test.clj @@ -249,17 +249,17 @@ ::rpc/profile-id (:id profile) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id profile) - :session-id session-id - :index 0 - :content (if (some? path) - (let [size (.length (jio/file path))] - {:filename "package.penpot" - :path path - :mtype "application/zip" - :size size}) - (upload-chunk :content content))})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id profile) + :session-id session-id + :index 0 + :content (if (some? path) + (let [size (.length (jio/file path))] + {:filename "package.penpot" + :path path + :mtype "application/zip" + :size size}) + (upload-chunk :content content))})] (t/is (nil? (:error out))) session-id)) From 16ea1ab3cd6fad142fde02e6f2bf8c1b4d02186c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sun, 18 Oct 2026 18:01:22 +0000 Subject: [PATCH 059/167] :wrench: Ignore generated artifacts and local root files Local scratch files and autogenerated build output were showing up in git status: - /*.html and /*.json cover local reports and token dumps at the repo root; !/package.json keeps the tracked root manifest visible to git. - /media-processor/target/ and /scripts/__pycache__/ cover build and Python cache output. - render-wasm artifacts copied into the exporter go in their own commented block. Record the exporter and render-wasm generated paths in the module memories so nobody edits or commits them by hand. AI-assisted-by: deepseek-v4.1-flash --- .gitignore | 11 +++++++++++ .serena/memories/exporter/core.md | 1 + .serena/memories/render-wasm/core.md | 2 ++ 3 files changed, 14 insertions(+) diff --git a/.gitignore b/.gitignore index 707d430b13..4ad6684de2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,10 @@ .repl opencode.json .opencode/package-lock.json +/*.html /*.jpg +/*.json +!/package.json /*.md !CHANGES.md !CONTRIBUTING.md @@ -91,7 +94,14 @@ opencode.json /playwright/.cache/ /render-wasm/target/ /render-wasm/.render-wasm-build.lock + +# render-wasm build artifacts copied into the exporter (autogenerated) +/exporter/resources/wasm/render-wasm.js +/exporter/resources/wasm/render-wasm.wasm +/exporter/src/app/wasm/shared.js + /media-processor/dist/ +/media-processor/target/ /**/node_modules /**/.yarn/* /.pnpm-store @@ -112,4 +122,5 @@ opencode.json /.ci-logs /tmp/ /.codex/ +/scripts/__pycache__/ /tools/__pycache__ diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md index 9b7078045b..f48948d8d6 100644 --- a/.serena/memories/exporter/core.md +++ b/.serena/memories/exporter/core.md @@ -5,6 +5,7 @@ ## Layout and commands - Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`. +- `exporter/resources/wasm/render-wasm.{js,wasm}` and `exporter/src/app/wasm/shared.js` are generated render-wasm build output, not source: they are gitignored. Never hand-edit or commit them; regenerate by rebuilding render-wasm. - From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`. - Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`. - Cross-cutting testing principles and anti-patterns: `mem:testing`. diff --git a/.serena/memories/render-wasm/core.md b/.serena/memories/render-wasm/core.md index 17345b7a7d..88cf0e560e 100644 --- a/.serena/memories/render-wasm/core.md +++ b/.serena/memories/render-wasm/core.md @@ -20,6 +20,8 @@ `./build` sources `_build_env`, which sets the Emscripten paths and `EMCC_CFLAGS`. The WASM heap starts at 256 MB and uses geometric growth. +Build output is gitignored and stays untracked everywhere it lands: `frontend/resources/public/js/render-wasm.{js,wasm}` and `frontend/src/app/render_wasm/api/shared.js` from `./build`, plus `exporter/resources/wasm/render-wasm.{js,wasm}` and `exporter/src/app/wasm/shared.js`. Never commit generated JS/WASM; rebuild instead. + ## Commands From `render-wasm/`: From 49f746ddd8bf5bebcca8902b6e41f3982bebd255 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sun, 18 Oct 2026 18:39:50 +0000 Subject: [PATCH 060/167] :books: Never commit the plan file with the implementation The implement-plan flow asked to commit the closed plan together with the code, so the resulting commit looked like it was missing a piece. State the rule in the three places that describe it: the implement-plan skill, the commit workflow memory and the agents README. A plan is session bookkeeping, not part of the change, and its absence from the commit is correct rather than a missing piece. AI-assisted-by: deepseek-v4.1-flash --- .agents/README.md | 4 ++++ .agents/skills/implement-plan/SKILL.md | 19 +++++++++++-------- .serena/memories/workflow/creating-commits.md | 6 ++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.agents/README.md b/.agents/README.md index 657ab6eac3..371444a89d 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -410,6 +410,10 @@ append-only `Review Log` (UTC ISO 8601, one line per entry): A plan approved with no changes goes `draft` → `done` directly. +A plan is never staged or committed: the `done` status and its +`Review Log` are session bookkeeping, not part of the change. Their +absence from the commit is expected. + ## 7. Connecting `gh` CLI with a token The `create-issue` and `create-pr` flows need an authenticated `gh` diff --git a/.agents/skills/implement-plan/SKILL.md b/.agents/skills/implement-plan/SKILL.md index 9d99e48a6d..e3c4b3d6cd 100644 --- a/.agents/skills/implement-plan/SKILL.md +++ b/.agents/skills/implement-plan/SKILL.md @@ -107,12 +107,15 @@ When the implementation is complete, close the plan file first: flip its with the issue URL when one exists (standalone mode, e.g. `https://github.com/penpot/penpot/issues/NNNN`); in continue/direct mode with no issue, just `done` with no invented identifier. Never -record commit hashes. Then load the **`create-commit`** skill -and follow its workflow to commit the changes together with the -closed plan file, so plan and code land in the same commit. Provide a brief summary -of what was implemented and why, the issue reference (`issue-NNNN`) when -there is one, and the model name you are running as so the -`AI-assisted-by` trailer is set correctly. +record commit hashes. + +**The plan file is never staged or committed.** It is session +bookkeeping, not part of the change: the commit carries the code and +the tests. Then load the **`create-commit`** skill and follow its +workflow to commit them. Provide a brief summary of what was +implemented and why, the issue reference (`issue-NNNN`) when there is +one, and the model name you are running as so the `AI-assisted-by` +trailer is set correctly. ### Step-by-step mode (on request) @@ -124,8 +127,8 @@ per task" — loop one task at a time: one commit per task, never two tasks in one commit. Same inputs as always: what and why, the issue reference, your model name. - After the final task, close the plan file (`Status: done`, one - `Review Log` line with the issue URL when one exists) and include - it in that last commit. + `Review Log` line with the issue URL when one exists). It is never + staged: session bookkeeping does not go into the commit. - Show the user the result (what changed, files touched, how it was verified). - WAIT for the user's confirmation before starting the next task. diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index a60234819f..1fd50b88de 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -2,6 +2,12 @@ Commit only on explicit request. Before commit: `git status`; exclude unrelated user changes. +**Never stage a plan file.** A plan is session bookkeeping, not part of the +change: the commit carries the code and the tests. The `implement-plan` flow +still closes the plan (`Status: done` plus one `Review Log` line with the issue +URL); that is not a file to ship, and its absence from the commit is correct, +not a missing piece. + Do not guess or hallucinate git author information (Name or Email). Never include the `--author` flag in git commands unless specifically instructed by the user for a unique case; assume the local environment is already configured. Allow git commit to From addc5bc31e412b38f756d0714811e8c43807a954 Mon Sep 17 00:00:00 2001 From: "Rafael X." Date: Sun, 18 Oct 2026 22:29:18 +0200 Subject: [PATCH 061/167] :books: Fix typo in incomplete user info hint (#12413) Signed-off-by: Rafael X. --- backend/src/app/auth/oidc.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 1cea8af046..05121e7479 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -629,7 +629,7 @@ (l/warn :hint "received incomplete user info object (please set correct scopes)" :info info) (ex/raise :type :internal :code :incomplete-user-info - :hint "inconmplete user info" + :hint "incomplete user info" :info info))) ;; If the provider is OIDC, we can proceed to check From 91a5b3a9fb74246ee3c55d51f735ed5d71b5155d Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Mon, 19 Oct 2026 16:44:32 +0900 Subject: [PATCH 062/167] :bug: Fix token tree path handling for repeated and nested segments (#11591) * :bug: Fix token tree collapsing repeated path segments Tokens whose path contains the same segment name twice in a row (e.g. test.pointer.pointer) collapsed to a single level in the token tree. build-tree-node marked a node as a leaf when the current segment name matched the first segment of every remaining path. Because group-by-first-segment rewrites nested paths into the segment name, a still-nested remaining path was indistinguishable from a token ending at the current node, so test.pointer.pointer merged into one leaf. Mark rewritten segments with an internal ::remaining? marker and strip it when the rest-path is fully consumed; is-leaf? only accepts unmarked remaining segments as leaves. Closes #11588 AI-assisted-by: glm-5.3-flash Signed-off-by: Junsoo Choi * :bug: Preserve ancestor tokens in path trees Keep a node's token and descendants independently when imported paths contain both an ancestor and a child. Render both in the token tree and include the ancestor token in existing path-based group operations. Cover accepted same-set imports, leaf IDs, repeated segments, and internal marker removal without changing import or collision validation. AI-assisted-by: GPT-6 Signed-off-by: makesomethingshit * :recycle: Rework token tree path building Carry the segments left to descend as a ::remaining-path vector on each segment and advance it by subvec, instead of rewriting the segment :name and re-splitting it later. With that, build-tree-node finds the leaf token with a predicate over ::remaining-path and takes children from the segments that still have one, so the pre-grouping children-first sort no longer changes the result. Drop sort-children-first and its segment-has-children? helper, and document inside-path?. AI-assisted-by: deepseek-v4.1-flash * :bug: Fix and relocate the token tree tests The ancestor-and-descendant test called a function that does not exist in app.common.types.tokens-lib, so the namespace failed to load and the whole common suite errored. Use make-tokens-status-from-lib and the two-argument get-tokens-in-active-sets instead. Move the build-tree-root tests to path-names-tree-test so path-names-test stays a pure split/join unit test. AI-assisted-by: deepseek-v4.1-flash * :recycle: Refactor the token tree rendering and share path helpers Extract the token pill into a leaf-node* component and treat a node as a leaf only when it holds a token and has no children, so a node can carry both a token and its children. Make token-separator public and reuse it from the token tree and the management panel instead of local copies. Both management path filters inlined the same ancestor-or-descendant rule; extract it as token-in-path? and cover it with a unit test. AI-assisted-by: deepseek-v4.1-flash --------- Signed-off-by: Junsoo Choi Signed-off-by: makesomethingshit Co-authored-by: luisddm --- common/src/app/common/path_names.cljc | 147 +++++++------- common/src/app/common/types/tokens_lib.cljc | 8 +- .../common_tests/path_names_tree_test.cljc | 97 +++++++++ common/test/common_tests/runner.cljc | 2 + .../main/ui/workspace/tokens/management.cljs | 14 +- .../tokens/management/token_tree.cljs | 185 +++++++++++------- frontend/test/frontend_tests/runner.cljs | 2 + .../tokens/token_path_test.cljs | 39 ++++ 8 files changed, 347 insertions(+), 147 deletions(-) create mode 100644 common/test/common_tests/path_names_tree_test.cljc create mode 100644 frontend/test/frontend_tests/tokens/token_path_test.cljs diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc index de96093155..cc1526eae0 100644 --- a/common/src/app/common/path_names.cljc +++ b/common/src/app/common/path_names.cljc @@ -118,7 +118,10 @@ Some naming conventions: [path] (last (split-path path))) -(defn inside-path? [child parent] +(defn inside-path? + "Returns true when `child` equals `parent` or is nested inside it, + comparing both as path segments (e.g. 'a / b / c' is inside 'a / b')." + [child parent] (let [child-path (split-path child) parent-path (split-path parent)] (and (<= (count parent-path) (count child-path)) @@ -135,90 +138,94 @@ Some naming conventions: ;; Tree building functions -------------------------------------------------- -"Build tree structure from flat list of paths" - -"`build-tree-root` is the main function to build the tree." - -"Receives a list of segments with 'name' properties representing paths, - and a separator string." -"E.g segments = [{... :name 'one/two/three'} {... :name 'one/two/four'} {... :name 'one/five'}]" - -"Transforms into a tree structure like: - [{:name 'one' - :path 'one' - :depth 0 - :leaf nil - :children [{:name 'two' - :path 'one.two' - :depth 1 - :leaf nil - :children [{... :name 'three'} {... :name 'four'}]} - {:name 'five' - :path 'one.five' - :depth 1 - :leaf {... :name 'five'} - :children nil}]}]" - -(defn- sort-by-children - "Sorts segments so that those with children come first." - [segments separator] - (sort-by (fn [segment] - (let [path (split-path (:name segment) :separator separator) - path-length (count path)] - (if (= path-length 1) - 1 - 0))) - segments)) +(defn- attach-remaining-path + "Annotates a segment with ::remaining-path: the vector of path segments + still to descend after the current level. Computed once, at the root, + from the segment :name." + [segment separator] + (assoc segment ::remaining-path (split-path (:name segment) :separator separator))) (defn- group-by-first-segment - "Groups segments by their first path segment and update segment name." - [segments separator] + "Groups segments by the first element of their ::remaining-path and + advances that path by one element. + + A segment whose ::remaining-path becomes empty is a token that ends at + the current node; a non-empty one still has descendants below it." + [segments] (reduce (fn [acc segment] - (let [[first-segment & remaining-segments] (split-path (:name segment) :separator separator) - rest-path (when (seq remaining-segments) (join-path remaining-segments :separator separator :with-spaces? false))] - (update acc first-segment (fnil conj []) - (if rest-path - (assoc segment :name rest-path) - segment)))) + (let [path (::remaining-path segment)] + (update acc (first path) (fnil conj []) + (update segment ::remaining-path subvec 1)))) {} segments)) (defn- sort-and-group-segments - "Sorts elements and groups them by their first path segment." - [segments separator] - (let [sorted (sort-by-children segments separator) - grouped (group-by-first-segment sorted separator)] - (into (sorted-map) grouped))) + "Groups elements by their first path segment, keyed in sorted order." + [segments] + (into (sorted-map) (group-by-first-segment segments))) (defn- build-tree-node "Builds a single tree node with computed children." - [segment-name remaining-segments separator parent-path depth] + [segment-name remaining-segments parent-path depth] (let [current-path (if parent-path (str parent-path "." segment-name) segment-name) - is-leaf? (and (seq remaining-segments) - (every? (fn [segment] - (let [remaining-segment-name (first (split-path (:name segment) :separator separator))] - (= segment-name remaining-segment-name))) - remaining-segments)) + leaf-segment (some (fn [segment] + (when (empty? (::remaining-path segment)) + (-> segment + (assoc :name segment-name) + (dissoc ::remaining-path)))) + remaining-segments) - leaf-segment (when is-leaf? (first remaining-segments)) - node {:name segment-name - :path current-path - :depth depth - :leaf leaf-segment - :children (when-not is-leaf? - (let [grouped-elements (sort-and-group-segments remaining-segments separator)] - (mapv (fn [[child-segment-name remaining-child-segments]] - (build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth))) - grouped-elements)))}] - node)) + child-segments (filter #(seq (::remaining-path %)) remaining-segments) + + children (when (seq child-segments) + (let [grouped-elements (sort-and-group-segments child-segments)] + (mapv (fn [[child-segment-name remaining-child-segments]] + (build-tree-node child-segment-name remaining-child-segments current-path (inc depth))) + grouped-elements)))] + + {:name segment-name + :path current-path + :depth depth + :leaf leaf-segment + :children children})) + +(defn- attach-remaining-paths + "Annotates every segment with its ::remaining-path vector." + [segments separator] + (map #(attach-remaining-path % separator) segments)) + +(defn- build-root-node + "Builds a root-level tree node from a grouped element." + [[segment-name remaining-segments]] + (build-tree-node segment-name remaining-segments nil 0)) (defn build-tree-root - "Builds the root level of the tree." + "Builds the root level of the tree from a flat list of path segments. + + Receives a list of segments with a :name that represents a path, and + the separator used to split each name, e.g.: + + [{... :name 'one/two/three'} {... :name 'one/two/four'} {... :name 'one/five'}] + + Transforms them into a tree of nodes shaped like: + + [{:name 'one' + :path 'one' + :depth 0 + :leaf nil + :children [{:name 'two' + :path 'one.two' + :depth 1 + :leaf nil + :children [{... :name 'three'} {... :name 'four'}]} + {:name 'five' + :path 'one.five' + :depth 1 + :leaf {... :name 'five'} + :children nil}]}]" [segments separator] - (let [grouped-elements (sort-and-group-segments segments separator)] - (mapv (fn [[segment-name remaining-segments]] - (build-tree-node segment-name remaining-segments separator nil 0)) - grouped-elements))) + (let [grouped-elements (sort-and-group-segments (attach-remaining-paths segments separator))] + (mapv build-root-node grouped-elements))) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index a99a9502d2..082da2d43e 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -147,7 +147,9 @@ (check-token-attrs) (map->Token))) -(def ^:private token-separator ".") +(def token-separator + "Separator used to split a token name into its path segments." + ".") (defn get-token-path [token] @@ -167,9 +169,9 @@ (rename-path node nil new-name)) ([node token new-name] (let [element (if token (:name token) (:path node)) - split-path (cpn/split-path element :separator ".") + split-path (cpn/split-path element :separator token-separator) updated-split-element-name (assoc split-path (:depth node) new-name) - new-element-path (cpn/join-path updated-split-element-name :separator "." :with-spaces? false)] + new-element-path (cpn/join-path updated-split-element-name :separator token-separator :with-spaces? false)] new-element-path))) ;; === Token Set diff --git a/common/test/common_tests/path_names_tree_test.cljc b/common/test/common_tests/path_names_tree_test.cljc new file mode 100644 index 0000000000..b39b5fc0b2 --- /dev/null +++ b/common/test/common_tests/path_names_tree_test.cljc @@ -0,0 +1,97 @@ +;; 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.path-names-tree-test + (:require + [app.common.files.tokens :as cfo] + [app.common.path-names :as cpn] + [app.common.types.tokens-lib :as ctob] + [clojure.test :as t])) + +(defn- single-chain-terminal + "Follows the first child down a single-token tree to its terminal node." + [root] + (loop [node root] + (if (:children node) + (recur (first (:children node))) + node))) + +(defn- tree-payloads + "Collects every node and :leaf map in a tree for marker-leak checks." + [node] + (cons node + (concat (when (:leaf node) [(:leaf node)]) + (mapcat tree-payloads (:children node))))) + +(defn- tree-leaves + "Collects every token leaf from a tree." + [nodes] + (mapcat (fn [node] + (concat (when (:leaf node) [(:leaf node)]) + (tree-leaves (:children node)))) + nodes)) + +(t/deftest build-tree-root-preserves-repeated-segments + (t/testing "#11588: adjacent repeated path segments keep their depth" + (let [tree (cpn/build-tree-root [{:name "test.pointer.pointer" :id "t1"}] ".") + terminal (single-chain-terminal (first tree))] + (t/is (= "test" (-> tree first :name))) + (t/is (= "pointer" (-> tree first :children first :name))) + (t/is (nil? (-> tree first :children first :leaf))) + (t/is (= "pointer" (:name terminal))) + (t/is (= "test.pointer.pointer" (:path terminal))) + (t/is (= {:name "pointer" :id "t1"} (:leaf terminal))) + (t/is (nil? (:children terminal))) + (t/is (every? #(not (contains? % :app.common.path-names/remaining-path)) + (tree-payloads (first tree)))))) + (t/testing "control case without repeated segments is unchanged" + (let [tree (cpn/build-tree-root [{:name "test.thing.other-thing" :id "t2"}] ".") + terminal (single-chain-terminal (first tree))] + (t/is (= "test" (-> tree first :name))) + (t/is (= "thing" (-> tree first :children first :name))) + (t/is (= "other-thing" (:name terminal))) + (t/is (= {:name "other-thing" :id "t2"} (:leaf terminal))))) + (t/testing "repeated and non-repeated siblings coexist" + (let [tree (cpn/build-tree-root [{:name "test.pointer.pointer" :id "t1"} + {:name "test.thing.other-thing" :id "t2"}] ".") + children (-> tree first :children)] + (t/is (= ["pointer" "thing"] (mapv :name children))) + (t/is (= {:name "pointer" :id "t1"} (-> children first :children first :leaf))))) + (t/testing "longer repeated runs keep every depth" + (doseq [[path id] [["test.test.pointer" "t3"] ["test.test.test" "t4"] + ["a.a" "t5"] ["a.b.b" "t6"] ["a.a.a" "t8"]]] + (let [segments (cpn/split-path path :separator ".") + root (first (cpn/build-tree-root [{:name path :id id}] ".")) + terminal (single-chain-terminal root)] + (t/is (= (last segments) (:name terminal)) path) + (t/is (= (inc (count segments)) (count (tree-payloads root))) path) + (t/is (= {:name (last segments) :id id} (:leaf terminal)) path))))) + +(t/deftest build-tree-root-preserves-active-set-ancestor-and-descendant + (t/testing "#11588: an ancestor token and its descendant in the active set are both kept" + (let [tokens-lib (ctob/read-multi-set-dtcg + {"Global" {"a.a" {"$value" "#aaa" "$type" "color"} + "a.a.a" {"$value" "#bbb" "$type" "color"}} + "$themes" [] + "$metadata" {"tokenSetOrder" ["Global"] + "activeSets" ["Global"]}}) + tokens-status (cfo/make-tokens-status-from-lib tokens-lib) + active-tokens (cfo/get-tokens-in-active-sets tokens-status tokens-lib) + tree (cpn/build-tree-root (vec (vals active-tokens)) ".") + collision (-> tree first :children first) + leaves (tree-leaves tree)] + (t/is (= #{"a.a" "a.a.a"} (set (keys active-tokens)))) + (t/is (= 2 (count leaves))) + (t/is (= #{(get-in active-tokens ["a.a" :id]) + (get-in active-tokens ["a.a.a" :id])} + (set (map :id leaves)))) + (t/is (= "a.a" (:path collision))) + (t/is (= (get-in active-tokens ["a.a" :id]) + (get-in collision [:leaf :id]))) + (t/is (= (get-in active-tokens ["a.a.a" :id]) + (get-in collision [:children 0 :leaf :id]))) + (t/is (every? #(not (contains? % :app.common.path-names/remaining-path)) + (mapcat tree-payloads tree)))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 88e10df432..7a4c4243f4 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -80,6 +80,7 @@ [common-tests.math-test] [common-tests.media-test] [common-tests.path-names-test] + [common-tests.path-names-tree-test] [common-tests.record-test] [common-tests.schema-test] [common-tests.spec-test] @@ -184,6 +185,7 @@ 'common-tests.math-test 'common-tests.media-test 'common-tests.path-names-test + 'common-tests.path-names-tree-test 'common-tests.record-test 'common-tests.schema-test 'common-tests.spec-test diff --git a/frontend/src/app/main/ui/workspace/tokens/management.cljs b/frontend/src/app/main/ui/workspace/tokens/management.cljs index c50c8e0d58..dbf6e42980 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management.cljs @@ -26,6 +26,13 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) +(defn token-in-path? + "True when `token-name` is exactly `path` or nested below it. This keeps an + ancestor token inside the operations of its own path-based group." + [path token-name] + (or (= token-name path) + (str/starts-with? token-name (str path ctob/token-separator)))) + (defn- get-sorted-token-groups "Separate token-types into groups of `empty` or `filled` depending if tokens exist for that type. Sort each group alphabetically (by their type)." @@ -130,9 +137,9 @@ (fn [tokens-filtered-by-type node] (->> tokens-filtered-by-type (filter (fn [token] - (let [token-path (cpn/split-path (:name token) :separator ".")] + (let [token-path (cpn/split-path (:name token) :separator ctob/token-separator)] (and (> (count token-path) 0) - (str/starts-with? (:name token) (str (:path node) "."))))))))) + (token-in-path? (:path node) (:name token))))))))) ;; Filter tokens by their path and return their ids filter-tokens-by-path-ids @@ -142,7 +149,8 @@ (->> selected-token-set-tokens (filter (fn [token] (let [[_ token-value] token] - (and (= (:type token-value) type) (str/starts-with? (:name token-value) (str path ".")))))) + (and (= (:type token-value) type) + (token-in-path? path (:name token-value)))))) (mapv (fn [token] (let [[_ token-value] token] (:id token-value))))))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs index 3a5973209a..55bbffd676 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs @@ -31,8 +31,56 @@ [:on-node-context-menu {:optional true} fn?] [:can-edit {:optional true} :boolean]]) +(defn- leaf-node? + "A tree node is a leaf when it holds a token and has no children." + [node] + (and (:leaf node) (not (seq (:children node))))) + +(defn- sort-nodes + "Sorts tree nodes so that leaf tokens come before groups, sorting each + set naturally by name." + [nodes] + (let [[leafs groups] + (reduce (fn [[leafs groups] node] + (if (leaf-node? node) + [(conj leafs node) groups] + [leafs (conj groups node)])) + [[] []] + nodes)] + (concat (d/natural-sort-by :name leafs) + (d/natural-sort-by :name groups)))) + +(def ^:private schema:leaf-node + [:map + [:leaf :any] + [:tokens-lib {:optional true} :any] + [:selected-token-set-id {:optional true} :any] + [:selected-shapes :any] + [:is-selected-inside-layout {:optional true} :boolean] + [:active-theme-tokens {:optional true} :any] + [:can-edit {:optional true} :boolean] + [:on-token-pill-click {:optional true} fn?] + [:on-pill-context-menu {:optional true} fn?]]) + +(mf/defc leaf-node* + {::mf/private true + ::mf/schema schema:leaf-node} + [{:keys [leaf tokens-lib selected-token-set-id selected-shapes + is-selected-inside-layout active-theme-tokens can-edit + on-token-pill-click on-pill-context-menu]}] + (let [id (:id leaf) + token (ctob/get-token tokens-lib selected-token-set-id id)] + [:> token-pill* {:token token + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :on-click on-token-pill-click + :can-edit can-edit + :on-context-menu on-pill-context-menu}])) + (mf/defc folder-node* - {::mf/schema schema:folder-node} + {::mf/private true + ::mf/schema schema:folder-node} [{:keys [node type folded-token-paths @@ -47,20 +95,12 @@ can-edit]}] (let [full-path (str (name type) "." (:path node)) is-folder-expanded (not (contains? (set (or folded-token-paths [])) full-path)) + children (:children node) sorted-children (mf/with-memo [children] - (let [[leafs groups] - (reduce (fn [[l g] item] - (if (:leaf item) - [(conj l item) g] - [l (conj g item)])) - [[] []] - children) - sorted-leafs (d/natural-sort-by :name leafs) - sorted-groups (d/natural-sort-by :name groups)] - (concat sorted-leafs sorted-groups))) + (sort-nodes children)) swap-folder-expanded (mf/use-fn @@ -80,40 +120,50 @@ :expanded is-folder-expanded :aria-expanded is-folder-expanded :aria-controls (str "folder-children-" (:path node)) - :is-expandable (not (:leaf node)) + :is-expandable (boolean (seq children)) :on-toggle-expand swap-folder-expanded :on-context-menu node-context-menu-prep}] (when is-folder-expanded [:div {:class (stl/css :folder-children-wrapper) :id (str "folder-children-" (:path node))} - (when (seq children) - (for [child sorted-children] - (if (not (:leaf child)) - [:ul {:class (stl/css :node-parent) - :key (:path child)} - [:> folder-node* {:type type - :node child - :folded-token-paths folded-token-paths - :selected-shapes selected-shapes - :is-selected-inside-layout is-selected-inside-layout - :active-theme-tokens active-theme-tokens - :on-token-pill-click on-token-pill-click - :on-pill-context-menu on-pill-context-menu - :on-node-context-menu on-node-context-menu - :tokens-lib tokens-lib - :can-edit can-edit - :selected-token-set-id selected-token-set-id}]] - (let [id (:id (:leaf child)) - token (ctob/get-token tokens-lib selected-token-set-id id)] - [:> token-pill* - {:key id - :token token - :selected-shapes selected-shapes - :is-selected-inside-layout is-selected-inside-layout - :active-theme-tokens active-theme-tokens - :on-click on-token-pill-click - :can-edit can-edit - :on-context-menu on-pill-context-menu}]))))])])) + (when-let [leaf (:leaf node)] + [:> leaf-node* {:key (:id leaf) + :leaf leaf + :tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :can-edit can-edit + :on-token-pill-click on-token-pill-click + :on-pill-context-menu on-pill-context-menu}]) + + (for [child sorted-children] + (if (leaf-node? child) + [:> leaf-node* {:key (:id (:leaf child)) + :leaf (:leaf child) + :tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :can-edit can-edit + :on-token-pill-click on-token-pill-click + :on-pill-context-menu on-pill-context-menu}] + [:ul {:class (stl/css :node-parent) + :key (:path child)} + [:> folder-node* {:type type + :node child + :folded-token-paths folded-token-paths + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :on-token-pill-click on-token-pill-click + :on-pill-context-menu on-pill-context-menu + :on-node-context-menu on-node-context-menu + :tokens-lib tokens-lib + :can-edit can-edit + :selected-token-set-id selected-token-set-id}]]))])])) (def ^:private schema:token-tree [:map @@ -146,42 +196,35 @@ on-node-context-menu can-edit can-edit-tokens]}] - (let [separator "." - raw-tree + (let [raw-tree (mf/with-memo [tokens] - (cpn/build-tree-root tokens separator)) + (cpn/build-tree-root tokens ctob/token-separator)) - on-node-context-menu (mf/use-fn - (mf/deps can-edit-tokens on-node-context-menu) - (fn [event node] - (when can-edit-tokens - (on-node-context-menu event node)))) + ordered-nodes + (mf/with-memo [raw-tree] + (sort-nodes raw-tree)) + + on-node-context-menu + (mf/use-fn + (mf/deps can-edit-tokens on-node-context-menu) + (fn [event node] + (when can-edit-tokens + (on-node-context-menu event node))))] - ordered-nodes (mf/with-memo [raw-tree] - (let [[leafs groups] - (reduce (fn [[l g] item] - (if (:leaf item) - [(conj l item) g] - [l (conj g item)])) - [[] []] - raw-tree) - sorted-leafs (d/natural-sort-by :name leafs) - sorted-groups (d/natural-sort-by :name groups)] - (concat sorted-leafs sorted-groups)))] [:div {:class (stl/css :token-tree-wrapper)} (for [node ordered-nodes] - (if (:leaf node) - (let [token (ctob/get-token tokens-lib selected-token-set-id (get-in node [:leaf :id]))] - [:> token-pill* - {:token token - :key (:id (:leaf node)) - :selected-shapes selected-shapes - :is-selected-inside-layout is-selected-inside-layout - :active-theme-tokens active-theme-tokens - :on-click on-token-pill-click - :can-edit can-edit - :on-context-menu on-pill-context-menu}]) - ;; Render segment folder + (if (leaf-node? node) + [:> leaf-node* {:key (:id (:leaf node)) + :leaf (:leaf node) + :tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :can-edit can-edit + :on-token-pill-click on-token-pill-click + :on-pill-context-menu on-pill-context-menu}] + [:ul {:class (stl/css :node-parent) :key (:path node)} [:> folder-node* {:node node diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 7db391183b..e33ae65f8f 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -99,6 +99,7 @@ [frontend-tests.tokens.logic.tokens-status-test] [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] + [frontend-tests.tokens.token-path-test] [frontend-tests.tokens.workspace-tokens-remap-test] [frontend-tests.ui.check-updates-test] [frontend-tests.ui.color-token-position-test] @@ -242,6 +243,7 @@ 'frontend-tests.tokens.logic.token-remapping-test 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test + 'frontend-tests.tokens.token-path-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/token_path_test.cljs b/frontend/test/frontend_tests/tokens/token_path_test.cljs new file mode 100644 index 0000000000..06033e0882 --- /dev/null +++ b/frontend/test/frontend_tests/tokens/token_path_test.cljs @@ -0,0 +1,39 @@ +;; 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.token-path-test + (:require + [app.main.ui.workspace.tokens.management :as wtm] + [cljs.test :as t :include-macros true])) + +;; https://github.com/penpot/penpot/issues/11588 +;; A token whose name equals a tree node path must stay in the operations of +;; that node, alongside the tokens nested below it. + +(t/deftest token-in-path-matches-the-path-itself + (t/testing "an ancestor token is in its own path" + (t/is (true? (wtm/token-in-path? "a.a" "a.a"))) + (t/is (true? (wtm/token-in-path? "a" "a"))))) + +(t/deftest token-in-path-matches-nested-tokens + (t/testing "a token nested below the path is in the path" + (t/is (true? (wtm/token-in-path? "a.a" "a.a.a"))) + (t/is (true? (wtm/token-in-path? "a" "a.b.c"))) + (t/is (true? (wtm/token-in-path? "a.a" "a.a.b.c"))))) + +(t/deftest token-in-path-rejects-non-descendants + (t/testing "a sibling is not in the path" + (t/is (false? (wtm/token-in-path? "a.a" "a.b")))) + + (t/testing "a deeper path does not match a shorter token" + (t/is (false? (wtm/token-in-path? "a.a.a" "a.a")))) + + (t/testing "a name sharing the prefix without a separator boundary is not in the path" + (t/is (false? (wtm/token-in-path? "a.a" "a.ab"))) + (t/is (false? (wtm/token-in-path? "a" "ab")))) + + (t/testing "an unrelated path is not in the path" + (t/is (false? (wtm/token-in-path? "x.y" "z"))))) From 8787937b7cd7cf8c7c1ab3a09f79b431de190b04 Mon Sep 17 00:00:00 2001 From: "Stephen Sagarino Jr." <113438801+ReidoBoss@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:24:11 +0800 Subject: [PATCH 063/167] :bug: Truncate long labels in the shadow options panel (#12414) Long translations of "Blur" and "Spread" ran over the number input in the advanced shadow options. The label inherited display: flex from the legacy %input-label placeholder, where text-overflow has no effect, and it could not shrink below its text. Make the label a block with the DS text-ellipsis mixin, size it to its text, and cap it at the field width minus room for the number. Labels that fit look the same as before; longer ones end in an ellipsis and keep their tooltip. Closes #4456 --- .../ui/workspace/sidebar/options/rows/shadow_row.scss | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss index a376138e7f..4e0eff8a0b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss @@ -8,6 +8,7 @@ @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; @use "ds/_utils.scss" as *; +@use "ds/mixins.scss" as *; @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; @@ -84,8 +85,13 @@ @include t.use-typography("body-small"); .shadow-advanced-label { + @include text-ellipsis; + padding-inline-start: var(--sp-s); - inline-size: px2rem(60); + flex-shrink: 0; + inline-size: auto; + max-inline-size: calc(100% - #{px2rem(36)}); + line-height: $sz-32; } } From a5678657f583dcd3a3707a5ffe34753796fc27c9 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 10:48:39 +0200 Subject: [PATCH 064/167] :bug: Fix padding on sets section (#12422) --- frontend/src/app/main/ui/workspace/tokens/sidebar.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss index 24e7c22f87..023fc72182 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss @@ -28,7 +28,7 @@ overflow-y: auto; scrollbar-gutter: stable; position: relative; - padding-block: var(--sp-xs) var(--sp-l); + padding-block-start: var(--sp-xs); } .tokens-section-wrapper { From f4f581ab74f8a4615f5ff45307ebadad9a1fbbf8 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 10:50:32 +0200 Subject: [PATCH 065/167] :bug: Fix export scale dropdown clipped in Inspect (#12351) * :bug: Fix export scale dropdown clipped in Inspect The export selects in the Inspect tab used a dropdown class that always opened them upwards, overriding the select's own up/down choice. On elements with few properties the export section sits near the top of the panel, so the scale list (now eight options) went past the top of the scroll area and hid 0.5x. Keep only the dropdown width and let the select pick the direction. Closes #12346 AI-assisted-by: claude-opus-5-5 * :recycle: Move Inspect exports styles to DS tokens Replace the deprecated spacing and size variables, the flex mixins, the color aliases and the typography mixins with DS tokens, and use logical properties. The typographies switch to the closest DS ones: body-small for the suffix input (line-height 1.3 instead of 1.4) and headline-small for the export button (12px instead of 11px). The @extend placeholders still come from refactor/ and are left for a later migration. AI-assisted-by: claude-opus-5-5 * :recycle: Use DS buttons in Inspect exports Replace the add, remove and export buttons in the Inspect exports section with the DS icon-button and button, as the workspace export section does. Delete their now unused styles and the sizes import; the export button class only stretches the button and centers its label. AI-assisted-by: claude-opus-5-5 * :bug: Fix add export button lookup in the Inspect exports e2e test The add export button in Inspect is a DS icon button now and lost its .add-export class, so the test timed out waiting for it. Find it by its role and accessible name instead. AI-assisted-by: claude-opus-5-5 --- .../ui/specs/viewer-inspect-exports.spec.js | 5 +- frontend/src/app/main/ui/inspect/exports.cljs | 37 +++++----- frontend/src/app/main/ui/inspect/exports.scss | 73 ++++++------------- 3 files changed, 45 insertions(+), 70 deletions(-) diff --git a/frontend/playwright/ui/specs/viewer-inspect-exports.spec.js b/frontend/playwright/ui/specs/viewer-inspect-exports.spec.js index 3e99476fe5..b1da71a01f 100644 --- a/frontend/playwright/ui/specs/viewer-inspect-exports.spec.js +++ b/frontend/playwright/ui/specs/viewer-inspect-exports.spec.js @@ -23,11 +23,8 @@ test("[View mode] Export presets are preserved when navigating between boards in // Enter inspect (code) mode await viewer.showCode(); - // Wait for the inspect panel to load - await page.waitForSelector(".main_ui_inspect_exports__add-export"); - // Add an export preset via the "+" button in the Export section - const addExportButton = page.locator(".main_ui_inspect_exports__add-export"); + const addExportButton = page.getByRole("button", { name: "Add export" }); await addExportButton.click(); // Verify the "Export 1 element" button appears, confirming the preset was added diff --git a/frontend/src/app/main/ui/inspect/exports.cljs b/frontend/src/app/main/ui/inspect/exports.cljs index dab7869948..c70e37ff05 100644 --- a/frontend/src/app/main/ui/inspect/exports.cljs +++ b/frontend/src/app/main/ui/inspect/exports.cljs @@ -14,7 +14,9 @@ [app.main.store :as st] [app.main.ui.components.select :refer [select]] [app.main.ui.components.title-bar :refer [title-bar*]] - [app.main.ui.icons :as deprecated-icon] + [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.util.dom :as dom] [app.util.i18n :refer [tr c]] [app.util.keyboard :as kbd] @@ -161,17 +163,19 @@ [:> title-bar* {:collapsable false :title (tr "workspace.options.export") :class (stl/css :title-spacing-export-viewer)} - [:button {:class (stl/css :add-export) - :on-click add-export} deprecated-icon/add]]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.export.add-export") + :on-click add-export + :icon i/add}]]] (cond (= :multiple exports) [:div {:class (stl/css :multiple-exports)} [:div {:class (stl/css :label)} (tr "settings.multiple")] [:div {:class (stl/css :actions)} - [:button {:class (stl/css :action-btn) - :on-click ()} - deprecated-icon/remove-icon]]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.export.remove-export") + :icon i/remove}]]] (seq @exports) [:div {:class (stl/css :element-set-content)} @@ -183,14 +187,14 @@ [:& select {:default-value (d/name (:type export)) :options format-options - :dropdown-class (stl/css :dropdown-upwards) + :dropdown-class (stl/css :dropdown) :on-change (partial on-type-change index)}]] (when (scale-enabled? export) [:div {:class (stl/css :size-select)} [:& select {:default-value (str (:scale export)) :options size-options - :dropdown-class (stl/css :dropdown-upwards) + :dropdown-class (stl/css :dropdown) :on-change (partial on-scale-change index)}]]) [:label {:class (stl/css :suffix-input) :for "suffix-export-input"} @@ -203,16 +207,15 @@ :on-change on-suffix-change :on-key-down manage-key-down}]]] - [:button {:class (stl/css :action-btn) - :on-click (partial delete-export index)} - deprecated-icon/remove-icon]])]) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.export.remove-export") + :on-click (partial delete-export index) + :icon i/remove}]])]) (when (or (= :multiple exports) (seq @exports)) - [:button - {:on-click (when-not in-progress? on-download) - :class (stl/css-case - :export-btn true - :btn-disabled in-progress?) - :disabled in-progress?} + [:> button* {:variant "secondary" + :class (stl/css :export-btn) + :on-click (when-not in-progress? on-download) + :disabled in-progress?} (if in-progress? (tr "workspace.options.exporting-object") (tr "workspace.options.export-object" (c (count shapes))))])])) diff --git a/frontend/src/app/main/ui/inspect/exports.scss b/frontend/src/app/main/ui/inspect/exports.scss index 1feb282b4c..67345837ac 100644 --- a/frontend/src/app/main/ui/inspect/exports.scss +++ b/frontend/src/app/main/ui/inspect/exports.scss @@ -5,9 +5,11 @@ // Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as *; +@use "ds/_utils.scss" as *; .element-set { - padding-bottom: deprecated.$s-16; + padding-block-end: var(--sp-l); margin: 0; } @@ -17,33 +19,22 @@ .title-spacing-export-viewer { margin: 0; - color: var(--entry-foreground-color-hover); - margin-inline-start: calc(-1 * deprecated.$s-8); - width: calc(100% + deprecated.$s-8); -} - -.add-export { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } + color: var(--color-foreground-primary); + margin-inline-start: calc(-1 * var(--sp-s)); + inline-size: calc(100% + var(--sp-s)); } .element-set-content { - @include deprecated.flex-column; - - margin-bottom: deprecated.$s-4; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin-block-end: var(--sp-xs); } .multiple-exports { - @include deprecated.flex-row; - + display: flex; + align-items: center; + gap: var(--sp-xs); grid-column: 1 / span 9; } @@ -52,24 +43,15 @@ } .actions { - @include deprecated.flex-row; + display: flex; + align-items: center; + gap: var(--sp-xs); } .element-group { display: grid; grid-template-columns: repeat(9, 1fr); - column-gap: deprecated.$s-4; - - .action-btn { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - } - } + column-gap: var(--sp-xs); } .input-wrapper { @@ -82,10 +64,8 @@ grid-column: span 3; padding: 0; - .dropdown-upwards { - bottom: deprecated.$s-36; - width: deprecated.$s-80; - top: unset; + .dropdown { + inline-size: px2rem(80); } } @@ -93,24 +73,19 @@ grid-column: span 2; padding: 0; - .dropdown-upwards { - bottom: deprecated.$s-36; - top: unset; - width: deprecated.$s-80; + .dropdown { + inline-size: px2rem(80); } } .suffix-input { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); grid-column: span 3; } .export-btn { - @extend %button-secondary; - @include deprecated.uppercase-title-typography; - - height: deprecated.$s-32; - width: 100%; + justify-content: center; + inline-size: 100%; } From 5262aab3ffe60ec6bc3b6eaaed28d834f45b1fab Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 11:15:51 +0200 Subject: [PATCH 066/167] :bug: Count saved versions in the version quota (#12405) * :bug: Count saved versions in the version quota The snapshots-per-file and snapshots-per-team quotes counted the saved versions by file_change.data, a column that only the legacy-db file data backend writes: versions keep their payload in file_data, so the count returned 0 and a configured quota never applied. Count by label instead, which tells a version apart with every file data backend, and add a partial index on file_change with the same predicate so both counts are answered by an index only scan instead of walking every row of every file of the team (10.3 seconds in production for a team with 829 versions). Closes #12404 AI-assisted-by: deepseek-v4.1-flash * :recycle: Drop the ! suffix from the snapshot quota test helpers The check helpers assert the outcome of a command instead of mutating anything, so they do not take the ! that this codebase reserves for functions whose contract changes state or runs a command (the th/command! call inside them already carries it). AI-assisted-by: deepseek-v4.1-flash --- .../http-storage-filedata-subtleties.md | 3 + .serena/memories/prod-infra/core.md | 1 + backend/src/app/migrations.clj | 5 +- .../sql/0155-add-idx-to-file-change.sql | 38 ++++++++ backend/src/app/rpc/quotes.clj | 4 +- .../test/backend_tests/rpc_quotes_test.clj | 94 +++++++++++++++++++ 6 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 backend/src/app/migrations/sql/0155-add-idx-to-file-change.sql diff --git a/.serena/memories/backend/http-storage-filedata-subtleties.md b/.serena/memories/backend/http-storage-filedata-subtleties.md index 188ece7277..fb735b6c56 100644 --- a/.serena/memories/backend/http-storage-filedata-subtleties.md +++ b/.serena/memories/backend/http-storage-filedata-subtleties.md @@ -25,4 +25,7 @@ - File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data. - `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob. - Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. +- File snapshots (versions) keep their metadata row in `file_change` and their payload in `file_data` (`type = 'snapshot'`). With the `db` backend `file_change.data` stays NULL: that column is only written by the `legacy-db` backend, plus the rows written before the 2025-07 file storage refactor. +- Tell a snapshot apart by `label IS NOT NULL` (with `created_by` / `deleted_at` as needed), never by `file_change.data IS NOT NULL`: the label holds with every file data backend, and the payload column stopped meaning "this is a snapshot" when the payload moved out. Queries that identify versions must use the label. +- The version quota counts of `app.rpc.quotes` (`sql:get-snapshots-per-file`, `sql:get-snapshots-per-team`) are served by the partial index `file_change__user_snapshots__idx`, whose predicate mirrors their where clause; changing one without the other drops the index only scan and the count walks the whole table again. - `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. diff --git a/.serena/memories/prod-infra/core.md b/.serena/memories/prod-infra/core.md index e90f353b7e..5dfaeebed7 100644 --- a/.serena/memories/prod-infra/core.md +++ b/.serena/memories/prod-infra/core.md @@ -39,6 +39,7 @@ Production runs the deployed toolbox, not the devenv one: - **Start from the ids, not from an inventory.** The ids come from the error or the logs. Unbounded queries over tables with tens of millions of rows (`storage_object`, `file_media_object`) do not finish in a reasonable time; bounded to an id list they are index lookups. - **One query per reference column.** Two columns in one `IN (col_a, col_b)` lose the index and scan the whole table; split them. - The read-only replica cuts long queries: see `mem:prod-infra/grafana`. +- A migration that builds an index runs inside the migration transaction, so it cannot use `CONCURRENTLY`, and a plain `CREATE INDEX` locks the table against writes for the whole build. On a large table (file saves write to `file_change` on every save) build the index out of band, before deploying, with `CREATE INDEX CONCURRENTLY` on its own (never inside a transaction block); the shipped migration uses `IF NOT EXISTS`, finds it and does nothing. A concurrent build interrupted midway leaves the index invalid: drop it and retry (check `pg_indexes` and `pg_index.indisvalid`). ## See also diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index a3c4647a33..861629bb54 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -505,7 +505,10 @@ :fn (mg/resource "app/migrations/sql/0153-del-orphan-profile-rels-after-team-leave.sql")} {:name "0154-del-legacy-file-object-thumbnail-table" - :fn (mg/resource "app/migrations/sql/0154-del-legacy-file-object-thumbnail-table.sql")}]) + :fn (mg/resource "app/migrations/sql/0154-del-legacy-file-object-thumbnail-table.sql")} + + {:name "0155-add-idx-to-file-change" + :fn (mg/resource "app/migrations/sql/0155-add-idx-to-file-change.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0155-add-idx-to-file-change.sql b/backend/src/app/migrations/sql/0155-add-idx-to-file-change.sql new file mode 100644 index 0000000000..28357cc75c --- /dev/null +++ b/backend/src/app/migrations/sql/0155-add-idx-to-file-change.sql @@ -0,0 +1,38 @@ +-- Index for the version (snapshot) quota checks of app.rpc.quotes: +-- sql:get-snapshots-per-file and sql:get-snapshots-per-team count the live +-- versions created by a user, and this partial index answers both counts +-- with an index only scan, without walking the whole file_change table. +-- +-- The predicate must stay identical to the queries: the planner only uses a +-- partial index when the query where clause implies the index predicate. +-- Versions are told apart by label (every snapshot has one); file_change.data +-- is a legacy column, only written by the legacy-db file data backend. + +CREATE INDEX IF NOT EXISTS file_change__user_snapshots__idx + ON file_change (file_id) + WHERE label IS NOT NULL + AND created_by = 'user' + AND deleted_at IS NULL; + +-- NOTE: on instances where file_change is large, run the statement above +-- out of band, before deploying, with CONCURRENTLY instead: this migration +-- runs inside a transaction, and a plain CREATE INDEX locks the table +-- against writes for the whole build (in Penpot every file save writes a row +-- here), while CONCURRENTLY does not. Run it on its own, not inside a +-- transaction block: +-- +-- CREATE INDEX CONCURRENTLY IF NOT EXISTS file_change__user_snapshots__idx +-- ON file_change (file_id) +-- WHERE label IS NOT NULL +-- AND created_by = 'user' +-- AND deleted_at IS NULL; +-- +-- A CONCURRENTLY build interrupted midway leaves the index INVALID: drop it +-- and retry. Verify with: +-- +-- SELECT indexdef FROM pg_indexes +-- WHERE indexname = 'file_change__user_snapshots__idx'; +-- SELECT indisvalid FROM pg_index +-- WHERE indexrelid = 'file_change__user_snapshots__idx'::regclass; +-- +-- Once the index exists, this migration finds it and does nothing. diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index e391f0b0ac..9f2e67c8ec 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -427,7 +427,7 @@ WHERE fc.file_id = ? AND fc.created_by = 'user' AND fc.deleted_at IS NULL - AND fc.data IS NOT NULL") + AND fc.label IS NOT NULL") (defmethod check-quote ::snapshots-per-file [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}] @@ -460,7 +460,7 @@ WHERE p.team_id = ? AND fc.created_by = 'user' AND fc.deleted_at IS NULL - AND fc.data IS NOT NULL") + AND fc.label IS NOT NULL") (defmethod check-quote ::snapshots-per-team [{:keys [::profile-id ::team-id ::target] :as quote}] diff --git a/backend/test/backend_tests/rpc_quotes_test.clj b/backend/test/backend_tests/rpc_quotes_test.clj index b279ff96d3..b0740f1b22 100644 --- a/backend/test/backend_tests/rpc_quotes_test.clj +++ b/backend/test/backend_tests/rpc_quotes_test.clj @@ -472,3 +472,97 @@ (t/is (= :restriction (th/ex-type error))) (t/is (= :max-quote-reached (th/ex-code error))) (t/is (= "media-storage-bytes-per-team" (:target (ex-data error)))))))) + +;; --- SNAPSHOTS (VERSIONS) QUOTES + +(defn- check-snapshot-ok + "Save a version of the file and check that it was created." + [profile file label] + (let [out (th/command! {::th/type :create-file-snapshot + ::rpc/profile-id (:id profile) + :file-id (:id file) + :label label})] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (t/is (= label (get-in out [:result :label]))) + out)) + +(defn- check-snapshot-ko + "Try to save a version of the file and check that the quota refused it." + [profile file label target] + (let [out (th/command! {::th/type :create-file-snapshot + ::rpc/profile-id (:id profile) + :file-id (:id file) + :label label}) + error (:error out)] + ;; (th/print-result! out) + (t/is (not (th/success? out))) + (t/is (= :restriction (th/ex-type error))) + (t/is (= :max-quote-reached (th/ex-code error))) + (t/is (= target (:target (ex-data error)))) + out)) + +(t/deftest snapshots-per-file-quote + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-snapshots-per-file 2 + :quotes-snapshots-per-team 1000})}] + + (let [profile (th/create-profile* 1) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:default-team-id profile)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project) + :is-shared false})] + + (check-snapshot-ok profile file "version1") + (check-snapshot-ok profile file "version2") + (check-snapshot-ko profile file "version3" "snapshots-per-file")))) + +(t/deftest snapshots-per-team-quote + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-snapshots-per-file 1000 + :quotes-snapshots-per-team 3})}] + + (let [profile (th/create-profile* 1) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:default-team-id profile)}) + file-1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project) + :is-shared false}) + file-2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:id project) + :is-shared false})] + + ;; the quota adds up the versions of every file of the team + (check-snapshot-ok profile file-1 "version1") + (check-snapshot-ok profile file-2 "version2") + (check-snapshot-ok profile file-1 "version3") + (check-snapshot-ko profile file-2 "version4" "snapshots-per-team")))) + +(t/deftest snapshots-quote-freed-on-delete + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-snapshots-per-file 2 + :quotes-snapshots-per-team 1000})}] + + (let [profile (th/create-profile* 1) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:default-team-id profile)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project) + :is-shared false}) + snapshot (:result (check-snapshot-ok profile file "version1"))] + + (check-snapshot-ok profile file "version2") + (check-snapshot-ko profile file "version3" "snapshots-per-file") + + ;; deleting a version takes it out of the count right away + (let [out (th/command! {::th/type :delete-file-snapshot + ::rpc/profile-id (:id profile) + :id (:id snapshot)})] + (t/is (nil? (:error out))) + (t/is (true? (:result out))) + + (check-snapshot-ok profile file "version4"))))) From c401151541c3265395ee158b665940c51471bb3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Mon, 19 Oct 2026 12:07:51 +0200 Subject: [PATCH 067/167] :bug: Fix mask group drop shadow in PDF export (#12362) --- render-wasm/src/render/pdf.rs | 1 + render-wasm/src/render/vector.rs | 73 +++++++++++++++++++------------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index c61fef322b..b5b7366509 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -42,6 +42,7 @@ pub fn render_to_pdf( let metadata = skia::pdf::Metadata { creator: "Penpot".to_string(), producer: "Penpot (Skia PDF)".to_string(), + raster_dpi: Some(300.0), ..Default::default() }; diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 15a8dc0c32..7eddda2b8b 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -822,34 +822,7 @@ fn render_group( let child_opts = opts.for_children_of(element); if masked { - // Mirror the GPU mask: render all children (including the mask shape) - // as content, then re-draw the mask silhouette (the group's first child) - // with DstIn to clip everything to it. - let paint = Paint::default(); - let subtree_bounds = element.extrect(tree, scale); - canvas.save_layer( - &skia::canvas::SaveLayerRec::default() - .bounds(&subtree_bounds) - .paint(&paint), - ); - - for child_id in &children { - render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; - } - - if let Some(mask_id) = element.mask_id() { - let mut mask_paint = Paint::default(); - mask_paint.set_blend_mode(skia::BlendMode::DstIn); - canvas.save_layer( - &skia::canvas::SaveLayerRec::default() - .bounds(&subtree_bounds) - .paint(&mask_paint), - ); - render_tree_inner(shared, canvas, mask_id, tree, scale, &child_opts)?; - canvas.restore(); // mask layer - } - - canvas.restore(); // composition layer + render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?; } else { for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; @@ -863,6 +836,42 @@ fn render_group( Ok(()) } +fn render_masked_children( + shared: &mut RenderResources, + canvas: &Canvas, + element: &Shape, + children: &[Uuid], + tree: ShapesPoolRef, + scale: f32, + child_opts: &TreeOpts, +) -> Result<()> { + let subtree_bounds = element.extrect(tree, scale); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&Paint::default()), + ); + + for child_id in children { + render_tree_inner(shared, canvas, child_id, tree, scale, child_opts)?; + } + + if let Some(mask_id) = element.mask_id() { + let mut mask_paint = Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&mask_paint), + ); + render_tree_inner(shared, canvas, mask_id, tree, scale, child_opts)?; + canvas.restore(); // mask layer + } + + canvas.restore(); // composition layer + Ok(()) +} + // --------------------------------------------------------------------------- // Frames // --------------------------------------------------------------------------- @@ -991,8 +1000,12 @@ fn render_container_drop_shadows( let children = children_paint_order(tree, element); let child_opts = opts.for_children_of(element); - for child_id in &children { - render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; + if element.is_masked_group() { + render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?; + } else { + for child_id in &children { + render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; + } } canvas.restore(); From 84d92cfd5a5bf0b05ab104ca923d5ac2b372d8c7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 13:01:35 +0200 Subject: [PATCH 068/167] :sparkles: Add tail buckets and update-file validation timings (#12409) * :sparkles: Extend backend histogram buckets beyond 7.5s Give every backend timing histogram a tail past the shared 7500ms cap so slow RPC calls, CLIMIT waits and background tasks report real quantiles instead of clipping at the top bucket. Request and wait metrics grow to 60s, background tasks to 300s and Redis eval to 10s; the sub-7.5s buckets stay untouched to keep the history comparable. Closes #12408 AI-assisted-by: muse-spark-1.3-contributor * :bug: Fix flaky assertion in climit republish test Read the last published waiting value instead of the exact publication list. The sampler thread started with the component runs its first tick asynchronously, and when it lands inside the recording window it publishes the same count twice, which made the full-suite CI run fail while focused runs stayed green. AI-assisted-by: muse-spark-1.3-contributor * :sparkles: Add file_data blob to JSON decode helper Allow decoding file_data blobs (app.util.blob) to JSON for investigations with jq, offline from a blob file or from nREPL with the backend running. AI-assisted-by: muse-spark-1.3-contributor * :sparkles: Add validation step timings to update-file logs and metrics Measure the six phases inside process-changes-and-validate (resolve-libs, process-changes, soft and hard affected and schema validations) with tpoint and try/finally, so timings are recorded even when a hard validation throws. Publish each phase to a new histogram penpot_rpc_update_file_validation_timing with a fixed step label using the request buckets (up to 60000 ms), and extend the update-file debug log with the same *-ms fields plus file, project and team ids. The deferred log only runs on success, so failures get an inline error log with partial timings. AI-assisted-by: muse-spark-1.3-contributor * :lipstick: Translate decode-file-data comments to English Translate the Spanish docstrings, section headers and test messages of the file_data decode helper to English, keeping code untouched. The repo convention is English in code and comments; the helper was first written in Spanish. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/clojure/design-rules.md | 7 +- .serena/memories/critical-info.md | 2 +- .serena/memories/prod-infra/grafana.md | 2 +- backend/scripts/decode-file-data | 19 ++ backend/src/app/main.clj | 16 +- backend/src/app/metrics.clj | 19 ++ backend/src/app/rpc/commands/files_update.clj | 119 +++++-- backend/src/app/scripts/decode_file_data.clj | 300 ++++++++++++++++++ backend/test/backend_tests/climit_test.clj | 6 +- .../files_update_validation_timing_test.clj | 128 ++++++++ backend/test/backend_tests/metrics_test.clj | 72 +++++ .../scripts_decode_file_data_test.clj | 82 +++++ 12 files changed, 743 insertions(+), 29 deletions(-) create mode 100755 backend/scripts/decode-file-data create mode 100644 backend/src/app/scripts/decode_file_data.clj create mode 100644 backend/test/backend_tests/files_update_validation_timing_test.clj create mode 100644 backend/test/backend_tests/scripts_decode_file_data_test.clj diff --git a/.serena/memories/clojure/design-rules.md b/.serena/memories/clojure/design-rules.md index 48c7f40193..2b439ee038 100644 --- a/.serena/memories/clojure/design-rules.md +++ b/.serena/memories/clojure/design-rules.md @@ -2,10 +2,11 @@ How to shape a function in this codebase. Each rule came from a review round; do not re-derive from taste. -- **Preconditions belong to the boundary, not to the core.** A function documents what it needs (hints, docstring) and assumes it. Absence checks live where the value enters the system: the caller that resolves optional objects, `ig/init-key`, the request handler. A `(when (and (some? a) (some? b)) ...)` inside a leaf function means the guard is in the wrong place — if you cannot get `a` or `b`, you should not be calling it. -- **Optional-by-design data is guarded where the optionality is born.** Example: `ConnectorStatistics` is nil when the server option is off, so the guard is `when-let [cs (some-> server ...)]` at the entry point and the consumer assumes `cs`. +- **Preconditions belong to the boundary, not to the core.** A function documents what it needs (hints, docstring) and assumes it. Absence checks live where the value enters the system: the caller that resolves optional objects, `ig/init-key`, the request handler — including `nil` that is optional-by-design (`when-let [cs (some-> server ...)]` at the entry point, as with `ConnectorStatistics` when the server option is off). A `(when (and (some? a) (some? b)) ...)` inside a leaf function means the guard is in the wrong place — if you cannot get `a` or `b`, you should not be calling it. - **A static set of operations is written statically.** With a fixed, small set (four metrics) write the four calls. Do not build a collection and iterate it (`doseq` over a literal vector): it allocates, adds indirection and hides each operation. Use a collection when the set is dynamic (config, registry, input). -- **The name says what the function is; `!` says it mutates.** `!` marks functions whose contract is to change state or run a command (`run!`, `submit!`, `swap!`, `shutdown-now`). Reporting helpers (log, sample-and-publish) do not take it: the mutation happens in the `!` API they call. Keep a family consistent. +- **Default to no `!`; existing `!` is legacy being removed gradually.** Do not introduce new `!` names except the allowed cases documented in `mem:clojure/idioms` (add and justify the special case there first). Allowed: mutating public fns in `app.db` (`insert!`, `update!`, `delete!`, `run!`, `tx-run!`, ...) and fns whose contract is to work over transients (stdlib `conj!`/`assoc!`/`dissoc!`/`persistent!` and custom helpers over them). Existing `!` elsewhere (e.g. `emit!`, `send!`, `submit!`, `validate-file!`) stays until its refactor; keep a family consistent in the meantime. Reporting helpers (log, sample-and-publish) never take `!`: the mutation happens in the `!` API they call. +- **Functions that obtain data start with `get-` or `fetch-`.** A reader must see at a glance that the function reads (`get-file`, `get-snapshot`, `get-file-json`); a bare noun hides it (`file-json` says nothing about reading). New helpers that fetch by id follow this, never a `current-` prefix: the id parameter already says which one it is. +- **`?` marks a predicate, not a data prop.** A var or function that answers a question takes `?` (`export-error?`, `(defn editable? …)`); a map key that names a property does not (`:export-error`, never `:export-error?`), even when its value is a boolean. Existing `?` keys are legacy to remove gradually; do not add a new one. - **Do not shape production code for tests.** No return values added just to assert them; assert on the observable effect. When preconditions move, move the tests with them. Reference implementation: the http metrics samplers in `app.http` (`sample-worker-metrics` / `sample-connector-metrics` are guard-free; `sample-http-metrics` guards at the boundary). diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 5e84c9834e..63bbce922a 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -7,7 +7,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Edits/stale refs/duplication cleanup: `mem:memory-maintenance`. - Cross-cutting testing principles, TDD workflow, and anti-patterns: `mem:testing`. - Verified Clojure language behaviors that contradict common assumptions (e.g. `int?` covers `Long`; `integer?` is the general predicate): `mem:clojure/idioms` — read before assuming stdlib predicate semantics. -- When designing a solution or an API, read `mem:clojure/design-rules` (function shape, boundary checks, naming). +- Read `mem:clojure/design-rules` (function shape, boundary checks, naming) before writing or modifying Clojure/ClojureScript code — not only when designing a solution or an API. # Development workflow diff --git a/.serena/memories/prod-infra/grafana.md b/.serena/memories/prod-infra/grafana.md index 10af277db2..085308da1b 100644 --- a/.serena/memories/prod-infra/grafana.md +++ b/.serena/memories/prod-infra/grafana.md @@ -6,7 +6,7 @@ Keep this memory free of internal names: no host names, no deployed versions, no ## Datasources -- Metrics — **Prometheus**, uid `xkRmyFBGk`. PromQL. Metric families: `penpot_*` (RPC, task timings, CLIMIT, HTTP, websocket, storage, and the application log events of the backend), `hikaricp_*`, `jvm_*` and `process_*` on the backend (`job="app"`); `pg_*` from the Postgres exporter (`job="postgres"`); `redis_*` from the Redis exporter (`job="redis"`); `node_*` and `container_*` from node-exporter and cAdvisor. Backend histograms are in **milliseconds**. +- Metrics — **Prometheus**, uid `xkRmyFBGk`. PromQL. Metric families: `penpot_*` (RPC, task timings, CLIMIT, HTTP, websocket, storage, and the application log events of the backend), `hikaricp_*`, `jvm_*` and `process_*` on the backend (`job="app"`); `pg_*` from the Postgres exporter (`job="postgres"`); `redis_*` from the Redis exporter (`job="redis"`); `node_*` and `container_*` from node-exporter and cAdvisor. Backend histograms are in **milliseconds**. Tails (ms): requests and waits to 60000, background tasks to 300000, Redis eval to 10000; the sub-7500 buckets are identical everywhere, so old quantiles stay comparable. - Logs — **Loki**, uid `h3CGKhEMk`. LogQL, and filter by the structured metadata `logger`/`level` (`{job="penpot"} | logger="..." | level="ERROR"`). Two things to remember when counting from it: the CLIMIT rejections have their own counters (`penpot_rpc_climit_rejected_total`, by `name` and `reason`) and their `hint="rejected"` line goes out at WARN only when something is left queued behind the call (a lone caller that times out is written at TRACE), so counting lines is not counting rejections -- the same goes for `hint="enqueued"` and `hint="acquired"`; and the only signal that exists nowhere else is the profile behind a queue (`logger="app.rpc.climit"`, whose `id` carries the key), chased there on demand. - SQL — **Postgres**, uid `y8ea9tfMk`. Panels need `rawQuery: true`, `editorMode: "code"` and `format: "table"`. The `task` table is the only truth about the queue depth. It is a **read-only replica**: it rejects any write, `CREATE TEMP TABLE` included, so queries through it must be plain `SELECT`s. It also cuts long-running queries (the MCP path has a short timeout), so bound them (e.g. a list of ids); an empty answer there is not proof that there is no data. - **Postgres (telemetry)**, uid `9YAUuM_Gz`. diff --git a/backend/scripts/decode-file-data b/backend/scripts/decode-file-data new file mode 100755 index 0000000000..04c84ccd73 --- /dev/null +++ b/backend/scripts/decode-file-data @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Decode a file_data blob (app.util.blob) to JSON for inspection with jq. +# +# Offline use (no DB): +# backend/scripts/decode-file-data --input /tmp/blob.bin --format summary | jq . +# +# DB use (backend running, via nREPL): +# ./scripts/nrepl-eval.mjs "(require '[app.scripts.decode-file-data :as dfd] :reload)" +# ./scripts/nrepl-eval.mjs '(dfd/get-file-json sys/system #uuid "..." :format :summary)' + +SCRIPT_DIR=$(dirname "$0") + +source "$SCRIPT_DIR/_env" + +if [ -f "$SCRIPT_DIR/_env.local" ]; then + source "$SCRIPT_DIR/_env.local" +fi + +exec clojure -M:dev -m app.scripts.decode-file-data "$@" diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index be1ccfafb3..9b03601b0d 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -25,7 +25,7 @@ [app.http.session.tasks :as-alias session.tasks] [app.http.websocket :as http.ws] [app.loggers.webhooks :as-alias webhooks] - [app.metrics :as-alias mtx] + [app.metrics :as mtx] [app.metrics.definition :as-alias mdef] [app.msgbus :as-alias mbus] [app.redis :as-alias rds] @@ -66,16 +66,25 @@ ::mdef/help "A total number of bytes processed by update-file." ::mdef/type :counter} + :update-file-validation-timing + {::mdef/name "penpot_rpc_update_file_validation_timing" + ::mdef/help "update-file validation step timings (milliseconds)." + ::mdef/labels ["step"] + ::mdef/buckets mtx/default-request-histogram-buckets + ::mdef/type :histogram} + :rpc-main-timing {::mdef/name "penpot_rpc_main_timing" ::mdef/help "RPC command method call timing for main" ::mdef/labels ["name"] + ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} :rpc-management-timing {::mdef/name "penpot_rpc_management_timing" ::mdef/help "RPC command method call timing for management." ::mdef/labels ["name"] + ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} :websocket-active-connections @@ -103,6 +112,7 @@ {::mdef/name "penpot_tasks_timing" ::mdef/help "Background tasks timing (milliseconds)." ::mdef/labels ["name"] + ::mdef/buckets mtx/default-tasks-histogram-buckets ::mdef/type :histogram} :storage-gc-deleted-objects @@ -115,6 +125,7 @@ {::mdef/name "penpot_redis_eval_timing" ::mdef/help "Redis EVAL commands execution timings (ms)" ::mdef/labels ["name"] + ::mdef/buckets mtx/default-redis-histogram-buckets ::mdef/type :histogram} :rpc-climit-waiting @@ -133,6 +144,7 @@ {::mdef/name "penpot_rpc_climit_timing" ::mdef/help "Summary of the time between queuing and executing on the CLIMIT" ::mdef/labels ["name"] + ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} :audit-http-handler-queue-size @@ -151,12 +163,14 @@ {::mdef/name "penpot_audit_http_handler_timing" ::mdef/help "Summary of the time between queuing and executing on the audit log http handler" ::mdef/labels [] + ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} :http-server-dispatch-timing {::mdef/name "penpot_http_server_dispatch_timing" ::mdef/help "Histogram of dispatch handler" ::mdef/labels [] + ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} :http-worker-queue-size diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj index c02e570e8b..96b55c7271 100644 --- a/backend/src/app/metrics.clj +++ b/backend/src/app/metrics.clj @@ -132,6 +132,25 @@ (def default-histogram-buckets [1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500]) +;; Tail for request and wait timings (RPC, CLIMIT waits, audit handler, +;; HTTP dispatch): covers the 30s/60s CLIMIT timeouts while keeping the +;; sub-7.5s resolution of the default buckets untouched. +(def default-request-histogram-buckets + [1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500 + 10000 15000 30000 60000]) + +;; Tail for background tasks (GC sweeps, file workers): they run until +;; done with no CLIMIT timeout, bounded in practice by the 300s postgres +;; statement timeout. +(def default-tasks-histogram-buckets + [1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500 + 10000 15000 30000 60000 120000 300000]) + +;; Tail for Redis eval: sub-second in practice, one extra bucket covers +;; the pathological (timeout) case without adding idle series. +(def default-redis-histogram-buckets + [1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500 10000]) + (defmulti run-collector! (fn [mdef _] (::mdef/type mdef))) (defmulti create-collector ::mdef/type) diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 0c19c1c315..12d7aa63d6 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -169,7 +169,8 @@ (assoc :file file) (assoc :changes changes)) - cfg (assoc cfg ::timestamp (ct/now)) + cfg (assoc cfg ::timestamp (ct/now) + ::validation-timings (atom {})) tpoint (ct/tpoint)] @@ -210,9 +211,39 @@ (binding [l/*context* (some-> (meta params) (get :app.http/request) (errors/request->context))] - (-> (update-file* cfg params) - (rph/with-defer #(let [elapsed (tpoint)] - (l/trace :hint "update-file" :time (ct/format-duration elapsed)))))))) + (try + (-> (update-file* cfg params) + (rph/with-defer #(let [elapsed (tpoint) + timings @(::validation-timings cfg)] + (l/dbg :hint "update-file" + :file-id (str (:id file)) + :project-id (str (:project-id file)) + :team-id (str (:team-id file)) + :status "success" + :time (ct/format-duration elapsed) + :resolve-libs-ms (get timings :resolve-libs-ms) + :process-changes-ms (get timings :process-changes-ms) + :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))))) + (catch Throwable cause + ;; NOTE: `with-defer` hooks only run on the success path + ;; (`handle-response`), so timings collected before a hard + ;; validation failure would be lost without this inline log. + (let [timings @(::validation-timings cfg)] + (l/dbg :hint "update-file" + :file-id (str (:id file)) + :project-id (str (:project-id file)) + :team-id (str (:team-id file)) + :status "error" + :resolve-libs-ms (get timings :resolve-libs-ms) + :process-changes-ms (get timings :process-changes-ms) + :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))) + (throw cause)))))) (defn- update-file* "Internal function, part of the update-file process, that encapsulates @@ -379,6 +410,16 @@ (catch Throwable cause (l/error :hint "file schema validation error" :cause cause)))) +(defn- record-validation-timing + [cfg step-key step-label elapsed-ms] + (when-let [metrics (::mtx/metrics cfg)] + (when (mtx/metrics? metrics) + (mtx/run! metrics {:id :update-file-validation-timing + :labels [step-label] + :val (double elapsed-ms)}))) + (when-let [timings (::validation-timings cfg)] + (swap! timings assoc step-key elapsed-ms))) + (defn- soft-validate-file! [file libs changes] (try @@ -389,11 +430,21 @@ (defn- process-changes-and-validate [cfg file changes skip-validate] - (let [libs - (when (and (or (contains? cf/flags :file-validation) - (contains? cf/flags :soft-file-validation)) - (not skip-validate)) - (bfc/get-resolved-file-libraries cfg file)) + (let [should-resolve-libs? + (and (or (contains? cf/flags :file-validation) + (contains? cf/flags :soft-file-validation)) + (not skip-validate)) + + tlibs + (ct/tpoint) + + libs + (try + (when should-resolve-libs? + (bfc/get-resolved-file-libraries cfg file)) + (finally + (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 @@ -403,32 +454,56 @@ state (atom {}) - file - (binding [cpc/*state* state] - (-> (files/check-version! file) - (update :revn inc) - (update :data cpc/process-changes changes) - (update :data d/without-nils))) + tproc + (ct/tpoint) file - (if-let [media-refs (-> @state :media-refs not-empty)] - (bfc/update-media-references! cfg file media-refs) - 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) + (finally + (record-validation-timing cfg :process-changes-ms "process-changes" (inst-ms (tproc)))))] (binding [pmap/*tracked* nil] (when (contains? cf/flags :soft-file-validation) - (soft-validate-file! file libs changes)) + (let [t (ct/tpoint)] + (try + (soft-validate-file! file libs changes) + (finally + (record-validation-timing cfg :soft-validate-affected-ms "soft-validate-affected" (inst-ms (t))))))) (when (contains? cf/flags :soft-file-schema-validation) - (soft-validate-file-schema! file)) + (let [t (ct/tpoint)] + (try + (soft-validate-file-schema! file) + (finally + (record-validation-timing cfg :soft-validate-schema-ms "soft-validate-schema" (inst-ms (t))))))) (when (and (contains? cf/flags :file-validation) (not skip-validate)) - (val/validate-file-affected! file libs changes)) + (let [t (ct/tpoint)] + (try + (val/validate-file-affected! file libs changes) + (finally + (record-validation-timing cfg :validate-affected-ms "validate-affected" (inst-ms (t))))))) (when (and (contains? cf/flags :file-schema-validation) (not skip-validate)) - (val/validate-file-schema! file))) + (let [t (ct/tpoint)] + (try + (val/validate-file-schema! file) + (finally + (record-validation-timing cfg :validate-schema-ms "validate-schema" (inst-ms (t)))))))) file)) diff --git a/backend/src/app/scripts/decode_file_data.clj b/backend/src/app/scripts/decode_file_data.clj new file mode 100644 index 0000000000..b7d775eafc --- /dev/null +++ b/backend/src/app/scripts/decode_file_data.clj @@ -0,0 +1,300 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.scripts.decode-file-data + "Decode a file's `file_data` binary blob to `jq`-readable JSON. + + The content of `file_data.data` (and `file.data` on the `legacy-db` + backend, or the `file-data` object on storage) is not text: it is a blob + encoded with `app.util.blob` (versions 1, 3, 4 and 5; transit+json or + fressian plus lz4/zstd). That is why it cannot be inspected with SQL + directly. + + Two uses: + + 1. Offline (no DB, just the blob): dump the `bytea` to a file with + `psql` (`\\copy ...` or `encode(data, 'base64')`) and pass it with `--input`. + Needs no system and no `file_data` read permissions. + + backend/scripts/decode-file-data --input /tmp/blob.bin --format summary | jq . + backend/scripts/decode-file-data --input /tmp/blob.b64 --from-base64 --format pages | jq . + + 2. Inside the penpot context (nREPL with the backend running, where + `sys/system` already exists): call `get-file-json` or `get-snapshot-json`. + They resolve the backend (`legacy-db`, `db`, `storage`), realize the + pointer-maps and objects-maps and return JSON. + + (require '[app.scripts.decode-file-data :as dfd] :reload) + (spit \"/tmp/maquette.json\" + (dfd/get-file-json sys/system #uuid \"9e056cfe-f83f-81f8-8008-cc885732f00d\" + :format :full)) + (spit \"/tmp/snap-518.json\" + (dfd/get-snapshot-json sys/system #uuid \"9e056cfe-f83f-81f8-8008-cc885732f00d\" + #uuid \"\" :format :summary)) + + Then filter with `jq`: `.pages`, `.data[\"pages-index\"] | keys`, + `[.data[\"pages-index\"][] | {id, name}]`, etc." + (:require + [app.binfile.common :as bfc] + [app.common.json :as json] + [app.db :as db] + [app.features.file-snapshots :as fsnap] + [app.util.blob :as blob] + [app.util.objects-map :as omap-legacy] + [app.util.pointer-map :as pmap] + [clojure.java.io :as io] + [clojure.string :as str]) + (:import + java.util.Base64)) + +(set! *warn-on-reflection* true) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; JSON sanitization +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- sanitize-key + [k] + (cond + (keyword? k) (subs (str k) 1) + (symbol? k) (str k) + (uuid? k) (str k) + :else (str k))) + +(declare sanitize) + +(defn- sanitize-map + [m] + (persistent! + (reduce-kv (fn [acc k v] + (assoc! acc (sanitize-key k) (sanitize v))) + (transient {}) + m))) + +(defn sanitize + "Convert a blob-decoded value to plain JSON-ready data. + + - Unrealized pointers become `{\"$pointer\": \"\"}`. + - `objects-map` instances are materialized to a plain map. + - Bytes become `{\"$bytes\": \"\"}`. + - UUIDs, instants, keywords and symbols become strings." + [x] + (cond + (nil? x) nil + (string? x) x + (boolean? x) x + + (number? x) + (if (ratio? x) (double x) x) + + (keyword? x) (subs (str x) 1) + (symbol? x) (str x) + (uuid? x) (str x) + (inst? x) (str x) + + (bytes? x) + {"$bytes" (.encodeToString (Base64/getEncoder) ^bytes x)} + + (pmap/pointer-map? x) + {"$pointer" (str (pmap/get-id x))} + + (omap-legacy/objects-map? x) + (sanitize (into {} x)) + + (map? x) + (sanitize-map x) + + (set? x) + (mapv sanitize x) + + (coll? x) + (mapv sanitize x) + + :else + (str x))) + +(defn data->json + "Encode sanitizable data to a JSON string." + [data] + (json/encode (sanitize data))) + +(defn blob->json + "Decode a `file_data` blob (`app.util.blob`) and return it as JSON." + [^bytes blob-bytes] + (data->json (blob/decode blob-bytes))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; jq-oriented investigation summary +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn summarize-data + "Small, stable summary for `jq`: pages (in `:pages` order) with object + counts, plus component, color and typography counters. Takes the already + decoded `:data` (not the JSON)." + [data] + (let [pages (:pages data) + pages-index (:pages-index data) + page-entry (fn [pid] + {:id (str pid) + :name (get-in pages-index [pid :name]) + :objects (count (get-in pages-index [pid :objects]))})] + {:pages (mapv page-entry (or pages [])) + :stats {:pages (count (or pages [])) + :components (count (:components data)) + :colors (count (:colors data)) + :typographies (count (:typographies data))}})) + +(defn file-data-summary-json + [data] + (data->json (summarize-data data))) + +(defn file-data-pages-json + [data] + (data->json (:pages (summarize-data data)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; DB access (for use from nREPL with the backend running) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- write-output + [^String payload output] + (if output + (do (spit output payload) output) + (print payload)) + payload) + +(defn get-file-json + "Return a file as JSON (realized). For nREPL. + + `format` can be `:full` (whole file), `:data` (`:data` only), + `:summary` or `:pages`. With `:output` it also writes it to a file." + [system file-id & {:keys [format output] :or {format :full}}] + (let [file (db/run! system bfc/get-file file-id :realize? true) + data (case format + :full file + :data (:data file) + :summary (summarize-data (:data file)) + :pages (:pages (summarize-data (:data file))))] + (write-output (data->json data) output))) + +(defn get-snapshot-json + "Return a snapshot as JSON (realized). For nREPL. + + Takes the same `format` and `output` as `get-file-json`." + [system file-id snapshot-id & {:keys [format output] :or {format :full}}] + (let [snapshot (db/run! system + (fn [cfg] + (fsnap/get-snapshot cfg file-id snapshot-id))) + data (case format + :full snapshot + :data (:data snapshot) + :summary (summarize-data (:data snapshot)) + :pages (:pages (summarize-data (:data snapshot))))] + (write-output (data->json data) output))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Offline CLI: decode a file holding the blob +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private cli-usage + (str "Usage: decode-file-data --input PATH [--output PATH] [--format full|data|summary|pages]\n" + " [--from-base64|--from-hex]\n" + "\n" + "Decode a file_data blob (app.util.blob) to JSON for inspection with jq.\n" + "\n" + "Examples:\n" + " psql -c \"\\\\copy (select data from file_data where file_id='...' and id='...') to '/tmp/blob.bin' with binary\"\n" + " decode-file-data --input /tmp/blob.bin --format summary | jq .\n" + " decode-file-data --input /tmp/blob.b64 --from-base64 --format pages | jq .\n")) + +(defn- parse-args + [args] + (loop [args args + opts {:format "full"}] + (if (empty? args) + opts + (let [[a b & rest-args] args] + (cond + (contains? #{"-h" "--help"} a) + (assoc opts :help true) + + (= a "--input") + (recur rest-args (assoc opts :input b)) + + (= a "--output") + (recur rest-args (assoc opts :output b)) + + (= a "--format") + (recur rest-args (assoc opts :format b)) + + (= a "--from-base64") + (recur (cons b rest-args) (assoc opts :from :base64)) + + (= a "--from-hex") + (recur (cons b rest-args) (assoc opts :from :hex)) + + :else + (assoc opts :error (str "unknown argument: " a))))))) + +(defn- hex->bytes + [^String s] + (let [s (str/trim s) + s (cond + (str/starts-with? s "\\x") (subs s 2) + (str/starts-with? s "0x") (subs s 2) + :else s) + s (str/replace s #"\s+" "") + n (/ (count s) 2) + out (byte-array n)] + (dotimes [i n] + (aset-byte out i (unchecked-byte (Integer/parseInt (subs s (* i 2) (+ (* i 2) 2)) 16)))) + out)) + +(defn- read-input-bytes + [{:keys [input from]}] + (cond + (nil? input) + (throw (ex-info "missing --input" {})) + + (= from :base64) + (.decode (Base64/getDecoder) ^String (str/trim (slurp (io/file input)))) + + (= from :hex) + (hex->bytes (slurp (io/file input))) + + :else + (java.nio.file.Files/readAllBytes (.toPath (io/file input))))) + +(defn -main + [& args] + (let [{:keys [help error input format output] :as opts} (parse-args (vec args))] + (cond + help + (do (print cli-usage) nil) + + error + (do (binding [*out* *err*] (println error) (println cli-usage)) (System/exit 2)) + + (nil? input) + (do (binding [*out* *err*] (println "missing --input") (println cli-usage)) (System/exit 2)) + + (not (contains? #{"full" "data" "summary" "pages"} (or format "full"))) + (do (binding [*out* *err*] (println (str "unknown format: " format))) (System/exit 2)) + + :else + (let [blob-bytes (read-input-bytes opts) + decoded (blob/decode blob-bytes) + payload (case format + "full" decoded + "data" (:data decoded (:data decoded decoded)) + "summary" (summarize-data (:data decoded decoded)) + "pages" (:pages (summarize-data (:data decoded decoded)))) + ;; NOTE: the file_data blob already IS the file's :data; but if + ;; someone dumps the whole serialized row, tolerate both. + out (data->json payload)] + (if output + (spit output out) + (println out)))))) diff --git a/backend/test/backend_tests/climit_test.clj b/backend/test/backend_tests/climit_test.clj index c0d82344ef..470f591e5a 100644 --- a/backend/test/backend_tests/climit_test.clj +++ b/backend/test/backend_tests/climit_test.clj @@ -661,7 +661,11 @@ (record-metrics (fn [] (#'climit/republish-waiting (th/metrics-stub) (:counters climit)) - (t/is (= [3] (waiting-value-of name)))))))) + ;; The value is the contract, not the publication count: the + ;; sampler thread started with the component runs its first + ;; tick asynchronously, and when it lands inside this window + ;; it publishes the same count a second time. + (t/is (= 3 (waiting-last name)))))))) (t/deftest halting-the-component-stops-the-republisher ;; The republisher is a daemon thread started with the component: halting it diff --git a/backend/test/backend_tests/files_update_validation_timing_test.clj b/backend/test/backend_tests/files_update_validation_timing_test.clj new file mode 100644 index 0000000000..d34ee359fa --- /dev/null +++ b/backend/test/backend_tests/files_update_validation_timing_test.clj @@ -0,0 +1,128 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.files-update-validation-timing-test + (:require + [app.binfile.common :as bfc] + [app.common.files.validate :as val] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main :as main] + [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] + [app.rpc.commands.files-update :as files-update] + [clojure.test :as t] + [integrant.core :as ig]) + (:import + io.prometheus.client.CollectorRegistry)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Helpers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- bucket-value + [metrics mname label-names label-values le] + (.getSampleValue ^CollectorRegistry (mtx/get-registry metrics) + (str mname "_bucket") + (into-array String (conj (vec label-names) "le")) + (into-array String (conj (vec label-values) le)))) + +(defn- test-file + [] + {:id (uuid/next) + :revn 0 + :version 0 + :data {}}) + +(defn- test-cfg + [metrics] + {::mtx/metrics metrics + ::files-update/validation-timings (atom {})}) + +(def ^:private all-flags + #{:file-validation + :file-schema-validation + :soft-file-validation + :soft-file-schema-validation}) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Tests +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest validation-timing-metric-uses-request-buckets + (t/is (= mtx/default-request-histogram-buckets + (get-in main/default-metrics [:update-file-validation-timing ::mdef/buckets]))) + (t/is (= "penpot_rpc_update_file_validation_timing" + (get-in main/default-metrics [:update-file-validation-timing ::mdef/name])))) + +(t/deftest process-changes-and-validate-records-all-steps + (let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics}) + cfg (test-cfg metrics)] + (binding [cf/flags all-flags] + (with-redefs [bfc/get-resolved-file-libraries (fn [_ _] {}) + val/validate-file-affected! (fn [_ _ _] nil) + val/validate-file-schema! (fn [_] nil)] + (let [file (@#'files-update/process-changes-and-validate cfg (test-file) [] nil) + timings @(::files-update/validation-timings cfg)] + (t/is (map? file)) + (t/is (number? (:resolve-libs-ms timings))) + (t/is (number? (:process-changes-ms timings))) + (t/is (number? (:soft-validate-affected-ms timings))) + (t/is (number? (:soft-validate-schema-ms timings))) + (t/is (number? (:validate-affected-ms timings))) + (t/is (number? (:validate-schema-ms timings))) + (doseq [step ["resolve-libs" "process-changes" + "soft-validate-affected" "soft-validate-schema" + "validate-affected" "validate-schema"]] + (t/is (= 1.0 (bucket-value metrics "penpot_rpc_update_file_validation_timing" + ["step"] [step] "60000.0")) + (str "expected one sample for step " step)))))))) + +(t/deftest process-changes-and-validate-skips-when-skip-validate + (let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics}) + cfg (test-cfg metrics)] + (binding [cf/flags all-flags] + (with-redefs [bfc/get-resolved-file-libraries (fn [_ _] + (throw (ex-info "must not resolve libs" {}))) + val/validate-file-affected! (fn [_ _ _] + (throw (ex-info "must not validate" {}))) + val/validate-file-schema! (fn [_] + (throw (ex-info "must not validate schema" {})))] + ;; soft validations still run: they only check flags, not skip-validate + (let [file (@#'files-update/process-changes-and-validate cfg (test-file) [] true) + timings @(::files-update/validation-timings cfg)] + (t/is (map? file)) + (t/is (nil? (:resolve-libs-ms timings))) + (t/is (number? (:process-changes-ms timings))) + (t/is (number? (:soft-validate-affected-ms timings))) + (t/is (number? (:soft-validate-schema-ms timings))) + (t/is (nil? (:validate-affected-ms timings))) + (t/is (nil? (:validate-schema-ms timings))) + (t/is (= 1.0 (bucket-value metrics "penpot_rpc_update_file_validation_timing" + ["step"] ["process-changes"] "60000.0")))))))) + +(t/deftest process-changes-and-validate-records-timing-on-error + (let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics}) + cfg (test-cfg metrics)] + (binding [cf/flags all-flags] + (with-redefs [bfc/get-resolved-file-libraries (fn [_ _] {}) + val/validate-file-affected! (fn [_ _ _] + (throw (ex-info "bad file" {:code :referential-integrity}))) + val/validate-file-schema! (fn [_] nil)] + (let [timings-atom (::files-update/validation-timings cfg)] + (t/is (thrown? clojure.lang.ExceptionInfo + (@#'files-update/process-changes-and-validate cfg (test-file) [] nil))) + ;; the failing step still recorded its timing via finally + (t/is (number? (:validate-affected-ms @timings-atom))) + (t/is (number? (:resolve-libs-ms @timings-atom))) + (t/is (number? (:process-changes-ms @timings-atom)))))))) + +(t/deftest record-validation-timing-tolerates-missing-collaborators + ;; production code must not blow up when called without metrics + ;; (e.g. scripts) or without the timings collector. + (let [record @#'files-update/record-validation-timing] + (t/is (nil? (record {} :resolve-libs-ms "resolve-libs" 12))) + (t/is (nil? (record {::mtx/metrics nil} :resolve-libs-ms "resolve-libs" 12))))) diff --git a/backend/test/backend_tests/metrics_test.clj b/backend/test/backend_tests/metrics_test.clj index f55e751d5e..15f5fde49d 100644 --- a/backend/test/backend_tests/metrics_test.clj +++ b/backend/test/backend_tests/metrics_test.clj @@ -6,13 +6,16 @@ (ns backend-tests.metrics-test (:require + [app.main :as main] [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] [clojure.test :as t] [integrant.core :as ig] [promesa.exec :as px]) (:import io.prometheus.client.Collector$MetricFamilySamples io.prometheus.client.Collector$MetricFamilySamples$Sample + io.prometheus.client.CollectorRegistry java.util.concurrent.ScheduledThreadPoolExecutor)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -74,3 +77,72 @@ (finally (px/shutdown-now sampler))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HISTOGRAM BUCKETS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest histogram-bucket-tails-extend-past-7-5s + ;; The sub-7.5s prefix stays identical, so p50/p90 resolution and the + ;; history remain comparable; only the tail grows, per latency profile. + (t/is (= mtx/default-histogram-buckets + (subvec mtx/default-request-histogram-buckets 0 14))) + (t/is (= mtx/default-histogram-buckets + (subvec mtx/default-tasks-histogram-buckets 0 14))) + (t/is (= mtx/default-histogram-buckets + (subvec mtx/default-redis-histogram-buckets 0 14))) + + (t/is (= [10000 15000 30000 60000] + (subvec mtx/default-request-histogram-buckets 14))) + (t/is (= [10000 15000 30000 60000 120000 300000] + (subvec mtx/default-tasks-histogram-buckets 14))) + (t/is (= [10000] + (subvec mtx/default-redis-histogram-buckets 14)))) + +(t/deftest default-metrics-wire-bucket-tails-per-profile + ;; Requests and waits cover the 30s/60s CLIMIT timeouts, tasks run + ;; until done (300s postgres statement timeout), Redis only needs the + ;; pathological case covered. + (doseq [id [:rpc-main-timing :rpc-management-timing :rpc-climit-timing + :audit-http-handler-timing :http-server-dispatch-timing + :update-file-validation-timing]] + (t/is (= mtx/default-request-histogram-buckets + (get-in main/default-metrics [id ::mdef/buckets])) + (str "request buckets on " id))) + (t/is (= mtx/default-tasks-histogram-buckets + (get-in main/default-metrics [:tasks-timing ::mdef/buckets]))) + (t/is (= mtx/default-redis-histogram-buckets + (get-in main/default-metrics [:redis-eval-timing ::mdef/buckets])))) + +(defn- bucket-value + "Cumulative count of the `le` bucket of a histogram, read from the + registry. Nil when that boundary does not exist." + [metrics mname label-names label-values le] + (.getSampleValue ^CollectorRegistry (mtx/get-registry metrics) + (str mname "_bucket") + (into-array String (conj (vec label-names) "le")) + (into-array String (conj (vec label-values) le)))) + +(t/deftest histogram-tails-are-registered + ;; A 9s observation must land in the new tail instead of piling up in + ;; +Inf: every histogram used to stop at 7500. + (let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics})] + (mtx/run! metrics :id :rpc-main-timing :val 9000 :labels (into-array String ["update-file"])) + (t/is (= 0.0 (bucket-value metrics "penpot_rpc_main_timing" ["name"] ["update-file"] "7500.0"))) + (t/is (= 1.0 (bucket-value metrics "penpot_rpc_main_timing" ["name"] ["update-file"] "10000.0"))) + (t/is (nil? (bucket-value metrics "penpot_rpc_main_timing" ["name"] ["update-file"] "120000.0"))) + + (mtx/run! metrics :id :tasks-timing :val 200000 :labels (into-array String ["file-gc"])) + (t/is (= 0.0 (bucket-value metrics "penpot_tasks_timing" ["name"] ["file-gc"] "60000.0"))) + (t/is (= 1.0 (bucket-value metrics "penpot_tasks_timing" ["name"] ["file-gc"] "300000.0"))) + + (mtx/run! metrics :id :redis-eval-timing :val 9000 :labels (into-array String ["claim"])) + (t/is (= 1.0 (bucket-value metrics "penpot_redis_eval_timing" ["name"] ["claim"] "10000.0"))) + (t/is (nil? (bucket-value metrics "penpot_redis_eval_timing" ["name"] ["claim"] "15000.0"))) + + (mtx/run! metrics :id :http-server-dispatch-timing :val 45000) + (t/is (= 1.0 (bucket-value metrics "penpot_http_server_dispatch_timing" [] [] "60000.0"))) + + (mtx/run! metrics :id :update-file-validation-timing :val 9000 :labels (into-array String ["validate-affected"])) + (t/is (= 0.0 (bucket-value metrics "penpot_rpc_update_file_validation_timing" ["step"] ["validate-affected"] "7500.0"))) + (t/is (= 1.0 (bucket-value metrics "penpot_rpc_update_file_validation_timing" ["step"] ["validate-affected"] "10000.0"))))) diff --git a/backend/test/backend_tests/scripts_decode_file_data_test.clj b/backend/test/backend_tests/scripts_decode_file_data_test.clj new file mode 100644 index 0000000000..e14618542f --- /dev/null +++ b/backend/test/backend_tests/scripts_decode_file_data_test.clj @@ -0,0 +1,82 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.scripts-decode-file-data-test + (:require + [app.common.json :as json] + [app.common.uuid :as uuid] + [app.scripts.decode-file-data :as dfd] + [app.util.blob :as blob] + [clojure.java.io :as io] + [clojure.test :as t]) + (:import + java.util.Base64)) + +(defn- sample-file-data + [] + (let [p1 (uuid/next) + p2 (uuid/next)] + {:pages [p1 p2] + :pages-index + {p1 {:id p1 :name "Accueil1" :objects {:a 1 :b 2}} + p2 {:id p2 :name "Contact" :objects {:c 3}}} + :components {(uuid/next) {:name "comp"}} + :colors {(uuid/next) {:name "red"}} + :typographies {}})) + +(t/deftest sanitize-converts-special-types + (let [id (uuid/next) + out (dfd/sanitize {:id id + :kw :pages-index + :when (java.util.Date. 0) + :tags #{:a :b} + :raw (.getBytes "hi" "UTF-8")})] + (t/is (= (str id) (get out "id"))) + (t/is (= "pages-index" (get out "kw"))) + (t/is (string? (get out "when"))) + (t/is (= ["a" "b"] (sort (get out "tags")))) + (t/is (= {"$bytes" (.encodeToString (Base64/getEncoder) (.getBytes "hi" "UTF-8"))} + (get out "raw"))))) + +(t/deftest sanitize-keeps-integers-as-integers + (t/is (= 2 (dfd/sanitize 2))) + (t/is (= 2.5 (dfd/sanitize 2.5)))) + +(t/deftest blob-roundtrip-all-versions-to-json + (doseq [version [1 3 4 5]] + (let [data (sample-file-data) + encoded (blob/encode data {:version version}) + payload (dfd/blob->json encoded) + parsed (json/decode payload)] + (t/is (map? parsed) (str "version " version " produces a JSON map")) + (t/is (= 2 (count (get parsed "pages"))) (str "version " version " keeps pages"))))) + +(t/deftest summary-lists-pages-in-order-with-object-counts + (let [data (sample-file-data) + summary (dfd/summarize-data data) + pages (:pages summary)] + (t/is (= 2 (count pages))) + (t/is (= ["Accueil1" "Contact"] (mapv :name pages))) + (t/is (= [2 1] (mapv :objects pages))) + (t/is (= {:pages 2 :components 1 :colors 1 :typographies 0} + (:stats summary))) + (let [pages-json (json/decode (dfd/file-data-pages-json data))] + (t/is (= ["Accueil1" "Contact"] (mapv #(get % "name") pages-json)))))) + +(t/deftest cli-decodes-blob-file-to-json + (let [data (sample-file-data) + tmp-in (java.io.File/createTempFile "blob" ".bin") + tmp-out (java.io.File/createTempFile "out" ".json") + _ (.deleteOnExit tmp-in) + _ (.deleteOnExit tmp-out) + _ (with-open [os (io/output-stream tmp-in)] + (.write os ^bytes (blob/encode data {:version 5}))) + _ (dfd/-main "--input" (.getAbsolutePath tmp-in) + "--output" (.getAbsolutePath tmp-out) + "--format" "summary") + parsed (json/decode (slurp tmp-out))] + (t/is (== 2 (get-in parsed ["stats" "pages"]))) + (t/is (= ["Accueil1" "Contact"] (mapv #(get % "name") (get parsed "pages")))))) From 4c655e6e9e75f8b5499308ca7b6e9f2ba5eb48eb Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 13:40:26 +0200 Subject: [PATCH 069/167] :bug: Keep the picture in place in the SVG preview of an image crop (#12387) With the SVG renderer, resizing a shape with an image fill and the mod key showed the picture scaled during the drag, as in a normal resize. The crop only showed after releasing the mouse. The SVG preview applies the resize matrix to the shape node and ignores the fill transform change carried by the modifiers. Now the pictures of the shape fill patterns get a matrix that takes them from the old fill transform to the new one, which undoes that resize and keeps them in place. This works for shapes and boards. Closes #12349 AI-assisted-by: claude-opus-5-5 --- common/src/app/common/geom/image_crop.cljc | 16 +++++ .../common_tests/geom_image_crop_test.cljc | 28 ++++++++ .../shapes/frame/dynamic_modifiers.cljs | 64 ++++++++++++++++--- 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/common/src/app/common/geom/image_crop.cljc b/common/src/app/common/geom/image_crop.cljc index fdd9f89527..15ad24b9bd 100644 --- a/common/src/app/common/geom/image_crop.cljc +++ b/common/src/app/common/geom/image_crop.cljc @@ -9,6 +9,7 @@ image fill). The shape is a window over a picture that keeps its size and position; the window never flips." (:require + [app.common.geom.matrix :as gmt] [app.common.math :as mth])) (defn crop-axis @@ -36,3 +37,18 @@ :y (/ (- (* y height) dy) height') :width (/ (* w width) width') :height (/ (* h height) height')})) + +(defn preview-image-matrix + "Return the matrix that takes the picture of a `width` x `height` window + from its box under `transform` to its box under `transform'`, both in + the coordinates of the window before the resize. While dragging, the + SVG preview resizes the whole shape with one matrix; this one, applied + to the picture first, undoes that resize so the picture stays in place." + [transform transform' width height] + (let [x (* (get transform :x 0.0) width) + y (* (get transform :y 0.0) height) + x' (* (get transform' :x 0.0) width) + y' (* (get transform' :y 0.0) height) + sx (/ (get transform' :width 1.0) (get transform :width 1.0)) + sy (/ (get transform' :height 1.0) (get transform :height 1.0))] + (gmt/matrix sx 0 0 sy (- x' (* x sx)) (- y' (* y sy))))) diff --git a/common/test/common_tests/geom_image_crop_test.cljc b/common/test/common_tests/geom_image_crop_test.cljc index cdd30e444f..31c686fbab 100644 --- a/common/test/common_tests/geom_image_crop_test.cljc +++ b/common/test/common_tests/geom_image_crop_test.cljc @@ -7,6 +7,8 @@ (ns common-tests.geom-image-crop-test (:require [app.common.geom.image-crop :as gic] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] [app.common.math :as mth] [clojure.test :as t])) @@ -65,3 +67,29 @@ (t/is (close-vals? [(- dy) height] [dy2 h2]) "window back at its original place") (t/is (close-vals? ((juxt :x :y :width :height) tf) ((juxt :x :y :width :height) back))))))) + +(t/deftest preview-image-matrix-test + (let [width 400 + height 300 + tf {:x 0 :y -0.2 :width 1 :height 1.4}] + + (t/testing "the picture keeps its place once the window is resized" + (doseq [[dx dy w' h'] [[0 60 400 240] + [0 300 400 100] + [-50 75 500 150]]] + (let [tf' (gic/update-image-transform tf width height dx dy w' h') + matrix (gic/preview-image-matrix tf tf' width height) + ;; Resize the preview applies to the whole shape: the old + ;; window goes to the new one. + window (-> (gmt/matrix) + (gmt/translate (gpt/point dx dy)) + (gmt/scale (gpt/point (/ w' width) (/ h' height)))) + [x y w h] (picture-box tf [0 0] [width height]) + p1 (gpt/transform (gpt/point x y) (gmt/multiply window matrix)) + p2 (gpt/transform (gpt/point (+ x w) (+ y h)) (gmt/multiply window matrix))] + (t/is (close-vals? [x y (+ x w) (+ y h)] [(:x p1) (:y p1) (:x p2) (:y p2)]) + (str "window " [dx dy w' h']))))) + + (t/testing "no change gives the identity" + (t/is (gmt/close? (gmt/matrix) (gic/preview-image-matrix tf tf width height))) + (t/is (gmt/close? (gmt/matrix) (gic/preview-image-matrix nil nil width height)))))) diff --git a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs index 237ce41434..ace4f726a7 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.geom.image-crop :as gic] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] @@ -20,6 +21,7 @@ [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.timers :as ts] + [cuerdas.core :as str] [rumext.v2 :as mf])) (defn get-shape-node @@ -31,6 +33,14 @@ base-node (dom/query base-node (dm/str "#shape-" id))))) +(defn- get-fill-image-nodes + "Retrieve the pictures of the shape image fills, inside its own fill + patterns" + [shape shape-node] + (when (or (some :fill-image (:fills shape)) + (some? (:fill-image shape))) + (dom/query-all shape-node ":scope > defs > pattern image"))) + (defn get-nodes "Retrieve the DOM nodes to apply the matrix transformation" [base-node {:keys [id parent-id] :as shape}] @@ -43,11 +53,13 @@ masking-child? (:masking-child? (meta shape))] (cond frame? - [shape-node - (dom/query shape-node ".frame-children") - (dom/query (dm/str "#thumbnail-container-" id)) - (dom/query (dm/str "#thumbnail-" id)) - (dom/query (dm/str "#frame-title-" id))] + (d/concat-vec + [shape-node + (dom/query shape-node ".frame-children") + (dom/query (dm/str "#thumbnail-container-" id)) + (dom/query (dm/str "#thumbnail-" id)) + (dom/query (dm/str "#frame-title-" id))] + (get-fill-image-nodes shape shape-node)) ;; For groups we don't want to transform the whole group but only ;; its filters/masks @@ -72,9 +84,38 @@ (dom/query (dm/str "#frame-title-" id)))] :else - [shape-node - (when (dbg/enabled? :shape-titles) - (dom/query (dm/str "#frame-title-" id)))])))) + (d/concat-vec + [shape-node + (when (dbg/enabled? :shape-titles) + (dom/query (dm/str "#frame-title-" id)))] + (get-fill-image-nodes shape shape-node)))))) + +(defn- fill-image-matrix + "Return the matrix that keeps the picture of an image fill `node` in + place while `modifiers` change the fill transform (resize with the mod + key). Return nil when the modifiers do not change the fill." + [shape modifiers node] + (let [changes (->> (:structure-parent modifiers) + (filter #(= :change-property (:type %))) + (d/index-by :property :value)) + selrect (dm/get-prop shape :selrect) + id (dom/get-attribute node "id") + + [transform transform' :as change] + (if (some? id) + ;; Fill images have an id ending with the fill index + (when-let [fills' (get changes :fills)] + (let [index (d/parse-integer (last (str/split id "-")))] + [(dm/get-in (nth (:fills shape) index nil) [:fill-image :transform]) + (dm/get-in (nth fills' index nil) [:fill-image :transform])])) + (when-let [fill-image' (get changes :fill-image)] + [(dm/get-in shape [:fill-image :transform]) + (get fill-image' :transform)]))] + + (when (some? change) + (gic/preview-image-matrix transform transform' + (dm/get-prop selrect :width) + (dm/get-prop selrect :height))))) (defn transform-region! [node modifiers] @@ -167,6 +208,13 @@ (= (dom/get-tag-name node) "pattern") (set-transform-att! node "patternTransform" transform) + ;; Picture of an image fill: it gets the shape transform + ;; through the shape node, and only needs its own when the + ;; fill transform changes + (= (dom/get-tag-name node) "image") + (when-let [matrix (fill-image-matrix shape modifiers node)] + (set-transform-att! node "transform" matrix)) + (and (some? transform) (some? node)) (set-transform-att! node "transform" transform))))))) From 688c0fc398c3c35e35991607a5ba1269f297f6d3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 14:00:13 +0200 Subject: [PATCH 070/167] :bug: Keep file_change revisions on their own deletion schedule (#12419) * :bug: Keep file_change revisions on their own deletion schedule Restoring a file from the trash cleared the deletion mark on every file_change row of that file, revisions included. A revision is born already scheduled for deletion one hour later, and objects-gc only looks at rows that carry a date, so unmarking one left it in the table for ever: production had 358,517 of them across 3,614 files. The file deletion cascade had the mirror problem: it rewrote the date of rows that already had one, pushing a revision out to the trash lifetime. Restrict the restore to durable history and date only the rows that have no date yet, so a revision keeps the schedule it was born with. A pinning test covers the snapshot update path, which cannot reach a revision because it resolves its row through a read filter. The backend memories now carry the model of the three file_data classes and this invariant, in place of the two superseded stubs. Closes #12418 AI-assisted-by: deepseek-v4.1-flash * :books: Add autovacuum and Postgres exporter notes to Grafana memory Record what this Grafana instance can and cannot show about autovacuum so the next session does not have to re-derive it: - The Postgres exporter scrapes the primary as well as the read-only replica, so the primary's pg_stat_activity and pg_settings_* are in Prometheus even though the SQL datasource cannot reach them. - It publishes no pg_stat_progress_* collector, and pg_stat_activity_count has no backend_type label: autovacuum workers are the usename/application_name-less bucket with state=active. The launcher shows up as the AutoVacuumMain wait event, and it is absent on the replica. - A metric the exporter does not publish needs a custom query block in its YAML queries file and a restart of the exporter. - The Postgres server ships its own log to Loki (the job is postgresql, primary only), whose streams carry only filename, instance, job and level. The completed vacuum lines live there: a per-table history, not a view of what runs now. - The PostgreSQL section now ends with the vacuum panels, with the Vacuum TXID age table last; an in-flight per-table view would need a pg_stat_progress_vacuum collector in the exporter itself. AI-assisted-by: deepseek-v4.1-flash --- .../auth-permissions-product-domains.md | 4 +- .serena/memories/backend/core.md | 3 +- .../http-storage-filedata-subtleties.md | 31 --- .../backend/rpc-db-worker-subtleties.md | 5 - .serena/memories/backend/subtleties.md | 9 +- .serena/memories/prod-infra/grafana.md | 8 +- backend/src/app/rpc/commands/files.clj | 10 +- backend/src/app/tasks/delete_object.clj | 8 +- .../file_change_retention_test.clj | 209 ++++++++++++++++++ 9 files changed, 242 insertions(+), 45 deletions(-) delete mode 100644 .serena/memories/backend/http-storage-filedata-subtleties.md delete mode 100644 .serena/memories/backend/rpc-db-worker-subtleties.md create mode 100644 backend/test/backend_tests/file_change_retention_test.clj diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index 130cd62609..7a18794d60 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -9,7 +9,7 @@ - LDAP login validates credentials against the external directory, fetches identity data, then logs in or registers a matching Penpot profile. LDAP registration is not a separate Penpot signup flow. - 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. -- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/http-storage-filedata-subtleties`. +- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`. ## Permission model @@ -38,4 +38,4 @@ - Enable LDAP login locally with frontend flag `enable-login-with-ldap`; the devenv includes a configured test LDAP service. - OIDC testing requires external provider app credentials plus matching backend/frontend config. - Backend domain tests usually live under `backend/test/backend_tests/rpc/commands/*_test.clj` or nearby backend test namespaces. Use focused `clojure -M:dev:test --focus ...` from `backend/` when possible. -- For auth/session or HTTP behavior, combine backend tests with the HTTP/session notes in `mem:backend/http-storage-filedata-subtleties` because RPC-level tests may not exercise cookie/header transforms. +- For auth/session or HTTP behavior, combine backend tests with the HTTP/session notes in `mem:backend/subtleties` because RPC-level tests may not exercise cookie/header transforms. diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index a8756775a8..42e7c7b756 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -4,9 +4,8 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m ## Focused memories -- RPC, DB helpers, workers, cron, CLIMIT: `mem:backend/subtleties` +- RPC, DB helpers, workers, cron, CLIMIT, config and HTTP/session middleware, http self-metrics, media processing, and file data persistence: `mem:backend/subtleties` - Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`. -- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`. - Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains` - Audit-log event collection (RPC wrapper, frontend ingestion), telemetry duality, webhook fan-out, error reporters, Nexus archival and retention: `mem:backend/audit-log` - Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`. diff --git a/.serena/memories/backend/http-storage-filedata-subtleties.md b/.serena/memories/backend/http-storage-filedata-subtleties.md deleted file mode 100644 index fb735b6c56..0000000000 --- a/.serena/memories/backend/http-storage-filedata-subtleties.md +++ /dev/null @@ -1,31 +0,0 @@ -# Backend HTTP, Storage, Media, and File Data Subtleties - -## Config and HTTP/session middleware - -- `app.config/config` and `flags` are dynamic `defonce` vars populated from `PENPOT_*` env vars through the shared schema string transformer. Tests and tooling can bind them. -- `parse-flags` automatically adds `:disable-secure-session-cookies` when `public-uri` is plain HTTP and not localhost. This changes cookie defaults without an explicit env flag. -- The backend sets Clojure `*assert*` globally from the `:backend-asserts` feature flag. Assertion-dependent checks can therefore differ by runtime flags. -- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. -- Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling. -- Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`; JWTs with `kid=1` and `ver=1` are decoded as v1 session tokens, otherwise they are treated as legacy tokens. -- Shared-key auth requires `x-shared-key` as ` ` and stores the lowercased key id on the request. If no shared keys are configured it always rejects. -- Session management uses DB storage unless the DB pool is read-only, then falls back to the in-memory manager. DB sessions support both legacy string ids and v2 UUID session ids. -- Session cookies are renewed when using a legacy string id or when `modified-at` is older than the renewal interval. SameSite is `none` for CORS, otherwise strict/lax based on config. - -## Storage and media - -- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`. -- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing. -- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8. -- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error. -- Font processing shells out to FontForge and WOFF conversion tools and can derive TTF/OTF/WOFF variants from uploaded fonts. - -## File data persistence - -- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data. -- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob. -- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. -- File snapshots (versions) keep their metadata row in `file_change` and their payload in `file_data` (`type = 'snapshot'`). With the `db` backend `file_change.data` stays NULL: that column is only written by the `legacy-db` backend, plus the rows written before the 2025-07 file storage refactor. -- Tell a snapshot apart by `label IS NOT NULL` (with `created_by` / `deleted_at` as needed), never by `file_change.data IS NOT NULL`: the label holds with every file data backend, and the payload column stopped meaning "this is a snapshot" when the payload moved out. Queries that identify versions must use the label. -- The version quota counts of `app.rpc.quotes` (`sql:get-snapshots-per-file`, `sql:get-snapshots-per-team`) are served by the partial index `file_change__user_snapshots__idx`, whose predicate mirrors their where clause; changing one without the other drops the index only scan and the count walks the whole table again. -- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. diff --git a/.serena/memories/backend/rpc-db-worker-subtleties.md b/.serena/memories/backend/rpc-db-worker-subtleties.md deleted file mode 100644 index 9ee1d462c1..0000000000 --- a/.serena/memories/backend/rpc-db-worker-subtleties.md +++ /dev/null @@ -1,5 +0,0 @@ -# Backend RPC/DB/Worker Subtleties (superseded) - -This memory was superseded by `mem:backend/subtleties`, which holds everything it had (RPC wrappers, DB helpers, workers and cron) plus the CLIMIT notes, config/HTTP middleware, http self-metrics, storage and file data. Read that one. - -Nothing should point here any more. diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index ed7f390af6..cee764853a 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -74,4 +74,11 @@ - File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data. - `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob. - Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. -- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. \ No newline at end of file +- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. +- A `file_data` row is one of three classes, told apart by `type`: `main` is the file's current state and carries `id = file_id`; `snapshot` is a point-in-time copy whose `id` is the one its `file_change` row shares; `fragment` is a piece of the pointer-map, referenced from the file's data. +- Their lifecycles differ: `main` and `snapshot` live with the file (dated when it is deleted, undated when it is restored), while a `fragment` carries no date and is collected by `file_gc` once the file stops referencing it. Neither is a session buffer, so do not read a `fragment` as an ephemeral revision. +- File snapshots (versions) keep their metadata row in `file_change` and their payload in `file_data` (`type = 'snapshot'`). With the `db` backend `file_change.data` stays NULL: that column is only written by the `legacy-db` backend, plus the rows written before the 2025-07 file storage refactor. +- Tell a snapshot apart by `label IS NOT NULL` (with `created_by` / `deleted_at` as needed), never by `file_change.data IS NOT NULL`: the label holds with every file data backend, and the payload column stopped meaning "this is a snapshot" when the payload moved out. Queries that identify versions must use the label. +- The version quota counts of `app.rpc.quotes` (`sql:get-snapshots-per-file`, `sql:get-snapshots-per-team`) are served by the partial index `file_change__user_snapshots__idx`, whose predicate mirrors their where clause; changing one without the other drops the index only scan and the count walks the whole table again. +- Revisions live only in `file_change` and have no `file_data` counterpart. They are born already scheduled for deletion one hour later (`app.rpc.commands.files-update`) and the snapshot paths cannot reach one, because they resolve their row through a `label IS NOT NULL` filter. +- A deletion date is assigned once and never moved: `restore-files` (`app.rpc.commands.files`) unmarks only durable history, the `:file` method of `app.tasks.delete-object` dates only rows that have no date yet, and `objects-gc` collects only rows that carry one. Unmarking a revision leaves it invisible to `objects-gc` for ever, because nothing sets that date again. \ No newline at end of file diff --git a/.serena/memories/prod-infra/grafana.md b/.serena/memories/prod-infra/grafana.md index 085308da1b..533b09516f 100644 --- a/.serena/memories/prod-infra/grafana.md +++ b/.serena/memories/prod-infra/grafana.md @@ -7,8 +7,13 @@ Keep this memory free of internal names: no host names, no deployed versions, no ## Datasources - Metrics — **Prometheus**, uid `xkRmyFBGk`. PromQL. Metric families: `penpot_*` (RPC, task timings, CLIMIT, HTTP, websocket, storage, and the application log events of the backend), `hikaricp_*`, `jvm_*` and `process_*` on the backend (`job="app"`); `pg_*` from the Postgres exporter (`job="postgres"`); `redis_*` from the Redis exporter (`job="redis"`); `node_*` and `container_*` from node-exporter and cAdvisor. Backend histograms are in **milliseconds**. Tails (ms): requests and waits to 60000, background tasks to 300000, Redis eval to 10000; the sub-7500 buckets are identical everywhere, so old quantiles stay comparable. + - The Postgres exporter scrapes **the primary as well as the read-only replica**, so the primary's `pg_stat_activity`, `pg_settings_*` and `pg_stat_bgwriter_*` are in Prometheus even though the SQL datasource cannot reach them (the replica's views describe the replica, not the primary). + - Per-database data from the exporter is **aggregate only**: `pg_settings_*` (the whole server configuration, so autovacuum, checkpoint and memory parameters are readable from Prometheus), `pg_stat_database_*`, `pg_stat_bgwriter_*`, `pg_stat_activity_*`, and replication/lock counts. It publishes **no per-table series** (`pg_stat_user_tables_*` resolves empty), so dead tuples, per-table reloptions and relation sizes are SQL-only questions. + - It publishes **no `pg_stat_progress_*` collector** and `pg_stat_activity_count` carries no `backend_type` label, so a session is only identifiable by its remaining labels: autovacuum workers are the bucket with `usename` and `application_name` empty plus `state="active"`, and `pg_wait_events_connections{wait_event="AutoVacuumMain"}` is the autovacuum launcher alive (absent on the replica). + - A metric the exporter does not publish itself has to be added as a custom query block in its YAML queries file on the exporter host (that is where `pg_wait_events_connections` comes from), and in this build the file is read at startup: restart the exporter after editing. There is no per-metric flag to turn on. - Logs — **Loki**, uid `h3CGKhEMk`. LogQL, and filter by the structured metadata `logger`/`level` (`{job="penpot"} | logger="..." | level="ERROR"`). Two things to remember when counting from it: the CLIMIT rejections have their own counters (`penpot_rpc_climit_rejected_total`, by `name` and `reason`) and their `hint="rejected"` line goes out at WARN only when something is left queued behind the call (a lone caller that times out is written at TRACE), so counting lines is not counting rejections -- the same goes for `hint="enqueued"` and `hint="acquired"`; and the only signal that exists nowhere else is the profile behind a queue (`logger="app.rpc.climit"`, whose `id` carries the key), chased there on demand. -- SQL — **Postgres**, uid `y8ea9tfMk`. Panels need `rawQuery: true`, `editorMode: "code"` and `format: "table"`. The `task` table is the only truth about the queue depth. It is a **read-only replica**: it rejects any write, `CREATE TEMP TABLE` included, so queries through it must be plain `SELECT`s. It also cuts long-running queries (the MCP path has a short timeout), so bound them (e.g. a list of ids); an empty answer there is not proof that there is no data. + - The Postgres server also ships its own log (`job="postgresql"`, primary only). Those streams carry just `filename`, `instance`, `job` and `level` — no `environment`/`hostname` — so selectors need `instance=~"$host.$env.*"`. `log_autovacuum_min_duration` is low enough that every autovacuum leaves an `automatic vacuum of table "db.schema.table"` line with pages, tuples, index scans and `elapsed`: a per-table history of **finished** vacuums (retrospective, not a view of what runs now). A day of vacuums is only a couple of hundred lines, so pulling them raw over `query_range` and parsing the table, the pages scanned, `tuples: N removed` and the `removable cutoff` XID age in the client works well — less fighting than making LogQL's `pattern`/`regexp` chew a multi-line message. +- SQL — **Postgres**, uid `y8ea9tfMk`. Panels need `rawQuery: true`, `editorMode: "code"` and `format: "table"`. The `task` table is the only truth about the queue depth. It is a **read-only replica**: it rejects any write, `CREATE TEMP TABLE` included, so queries through it must be plain `SELECT`s. Catalog data (`pg_class`, `pg_partition_tree`, `pg_stats`, and `reloptions`) describes the primary faithfully, because catalogs replicate; the statistics views (`pg_stat_user_tables`, `pg_stat_all_tables`) read as zero there, since statistics are per-instance and are not replicated. It also cuts long-running queries (the MCP path has a short timeout), so bound them (e.g. a list of ids); an empty answer there is not proof that there is no data. - **Postgres (telemetry)**, uid `9YAUuM_Gz`. - `$__rate_interval` and `$__interval` are substituted by Grafana; use them instead of fixed windows in graphs. @@ -16,6 +21,7 @@ Keep this memory free of internal names: no host names, no deployed versions, no - `Production status`, uid `penpot-production-status`. Sections: general state and deploy, `RPC y CLIMIT`, PostgreSQL and pool, Redis/msgbus, tasks, JVM/process. Variables `$env` and `$host` (custom), no auto refresh. Every section opens collapsed except `RPC y CLIMIT`, which is the one read on a normal day. - Per-host investigation dashboards are separate, and the alert rules deeplink into them. +- The PostgreSQL section ends with a block of vacuum panels: two stats (autovacuum workers active on the primary, and the launcher alive), a Loki timeseries of finished vacuums per interval and table, and then the `Vacuum TXID age` table, which is the last panel of the section. The worker stat is an approximation — it counts the `pg_stat_activity` bucket described above, because the exporter publishes no `backend_type` — and the Loki panel is retrospective by construction. A per-table, in-flight view (table, phase, progress) needs a `pg_stat_progress_vacuum` collector added to the exporter itself. ## Conventions diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index c5169643bc..584ba73e35 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1305,8 +1305,16 @@ WHERE file_id = ANY(?::uuid[])") (def ^:private sql:restore-file-changes + ;; Only durable history comes back: a snapshot (carries `label`) or a legacy + ;; payload row (carries `data`, the column the new model replaces with + ;; `file_data`). Revisions have neither and are deliberately left alone: they + ;; are born already scheduled for deletion one hour later, and the collector + ;; only looks at rows that carry a date, so unmarking one here would keep it + ;; in the table for ever. The `data` half is a guard for the rows still + ;; pending migration, not a dependency: drop it with the last one. "UPDATE file_change SET deleted_at = null - WHERE file_id = ANY(?::uuid[])") + WHERE file_id = ANY(?::uuid[]) + AND (label IS NOT NULL OR data IS NOT NULL)") (def ^:private sql:restore-file-data "UPDATE file_data SET deleted_at = null diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj index 00d0046f27..b6a30e2092 100644 --- a/backend/src/app/tasks/delete_object.clj +++ b/backend/src/app/tasks/delete_object.clj @@ -60,10 +60,14 @@ :file-id id :cause cause)))) - ;; Mark file change to be deleted + ;; Mark file change to be deleted, but only the rows that do not carry a + ;; date yet. A revision is born already scheduled for deletion one hour + ;; later, and rewriting its date here would push it out to the trash + ;; lifetime; the collector would still take it, just much later than it + ;; should. (db/update! conn :file-change {:deleted-at deleted-at} - {:file-id id} + {:file-id id :deleted-at nil} {::db/return-keys false}) ;; Mark file data fragment to be deleted diff --git a/backend/test/backend_tests/file_change_retention_test.clj b/backend/test/backend_tests/file_change_retention_test.clj new file mode 100644 index 0000000000..7cede5a164 --- /dev/null +++ b/backend/test/backend_tests/file_change_retention_test.clj @@ -0,0 +1,209 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.file-change-retention-test + "Retention of `file_change` rows through the file lifecycle. + + A file has two kinds of row. Durable history: the snapshots, which a restore + brings back. And ephemeral revisions: the xlog of an edit, born already + scheduled for deletion one hour later, which nobody needs once the sessions + that were going to read them are gone. + + The invariant under test is that a deletion date is assigned once and never + moved: restoring a file must not unmark a revision, and deleting a file must + not push a revision's date out to the trash lifetime. When either happens the + revision becomes invisible to `objects-gc`, which only looks at rows that + carry a date, and it stays in the table for ever." + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.db :as db] + [app.rpc :as-alias rpc] + [app.rpc.commands.files :as files] + [app.util.blob :as blob] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(def ^:private test-now + "The instant every test pins the clock to. Whole seconds on purpose: a value + with a sub-second part does not survive the round trip through the database, + and the test could not compare it for equality." + (ct/inst "2026-10-01T00:00:00Z")) + +(def ^:private revision-lifetime + "The lifetime the `update-file` flow gives a revision when it inserts it." + (ct/duration {:hours 1})) + +(defn- create-file + [i] + (let [profile (th/create-profile* i {:is-active true})] + [profile (th/create-file* i {:profile-id (:id profile) + :project-id (:default-project-id profile)})])) + +(defn- insert-change + "Inserts a `file_change` row directly, so the test builds the exact shape it + needs. The row class is decided by what the caller passes: a revision carries + only `changes`, a snapshot carries `label`, and a legacy payload row carries + `data` without a label. Returns the new row id." + [file-id {:keys [label data changes created-at deleted-at created-by]}] + (let [created-at (or created-at (ct/now)) + id (uuid/next)] + (th/db-insert! :file-change + {:id id + :file-id file-id + :created-at created-at + :updated-at created-at + :deleted-at deleted-at + :revn 1 + :version 0 + :created-by (or created-by "system") + :label label + :data data + :changes changes} + {::db/return-keys false}) + id)) + +(defn- insert-revision + "A revision exactly as the `update-file` flow writes it: no label, no data and + `changes` filled, already scheduled for deletion one hour after creation." + [file-id created-at] + (insert-change file-id {:created-at created-at + :changes (blob/encode {:foo "bar"}) + :deleted-at (ct/plus created-at revision-lifetime)})) + +(defn- find-change + [id] + (th/db-get :file-change {:id id} {::db/remove-deleted false})) + +(defn- find-file + [id] + (th/db-get :file {:id id} {::db/remove-deleted false})) + +(defn- restore-file + [file-id] + (db/tx-run! th/*system* + (fn [{:keys [::db/conn]}] + (#'files/restore-files conn [file-id])))) + +(t/deftest restoring-a-file-leaves-the-revision-schedule-alone + (binding [ct/*clock* (ct/fixed-clock test-now)] + (let [[_ file] (create-file 1) + file-id (:id file) + trash-at (ct/in-future {:days 7}) + old-at (ct/in-past {:hours 2}) + + revision-id (insert-revision file-id old-at) + revision-at (:deleted-at (find-change revision-id)) + + snapshot-id (insert-change file-id {:created-at old-at + :created-by "user" + :label "a snapshot" + :deleted-at trash-at}) + + legacy-id (insert-change file-id {:created-at old-at + :data (blob/encode {:legacy true}) + :deleted-at trash-at})] + + (restore-file file-id) + + (t/testing "the revision keeps the schedule it was born with" + (t/is (= revision-at (:deleted-at (find-change revision-id))))) + + (t/testing "the snapshot comes back" + (t/is (nil? (:deleted-at (find-change snapshot-id))))) + + (t/testing "the legacy payload row comes back" + (t/is (nil? (:deleted-at (find-change legacy-id)))))))) + +(t/deftest deleting-a-file-does-not-push-the-revision-schedule-out + (binding [ct/*clock* (ct/fixed-clock test-now)] + (let [[_ file] (create-file 1) + file-id (:id file) + trash-at (ct/in-future {:days 7}) + old-at (ct/in-past {:hours 2}) + system-at (ct/in-future {:days 2}) + + revision-id (insert-revision file-id old-at) + revision-at (:deleted-at (find-change revision-id)) + + snapshot-id (insert-change file-id {:created-at old-at + :created-by "user" + :label "a snapshot"}) + + system-id (insert-change file-id {:created-at old-at + :label "internal/snapshot/1" + :deleted-at system-at})] + + (th/run-task! :delete-object {:object :file + :deleted-at trash-at + :id file-id}) + + (t/testing "the revision keeps the schedule it was born with" + (t/is (= revision-at (:deleted-at (find-change revision-id))))) + + (t/testing "the snapshot takes the file's deletion date" + (t/is (= (:deleted-at (find-file file-id)) + (:deleted-at (find-change snapshot-id))))) + + (t/testing "the system snapshot keeps the date it already had" + (t/is (= system-at (:deleted-at (find-change system-id)))))))) + +(t/deftest updating-a-snapshot-cannot-reach-a-revision + ;; The snapshot paths clear the deletion mark of the row they touch, which is + ;; only legitimate for durable history. They reach a row through + ;; `sql:snapshots`, which filters `label IS NOT NULL`, and a revision never + ;; carries one. This test pins that: the row has to keep the date it was born + ;; with, and the RPC has to reject the id. If a future change loosens that + ;; read filter, this goes red instead of the revision losing its date in + ;; silence. + (binding [ct/*clock* (ct/fixed-clock test-now)] + (let [[profile file] (create-file 1) + file-id (:id file) + revision-id (insert-revision file-id (ct/in-past {:hours 2})) + revision-at (:deleted-at (find-change revision-id)) + + out (th/command! {::th/type :update-file-snapshot + ::rpc/profile-id (:id profile) + :id revision-id + :label "renamed"})] + + (t/testing "the RPC rejects the id" + (t/is (th/ex-info? (:error out))) + (t/is (= :not-found (:type (ex-data (:error out)))))) + + (t/testing "and the revision keeps the date it was born with" + (t/is (= revision-at (:deleted-at (find-change revision-id)))))))) + +(t/deftest a-deleted-then-restored-file-does-not-leak-its-revisions + ;; The lifecycle that leaked 358,517 rows in production: a file is edited, it + ;; ends up in the trash, it comes back, and the revisions that were scheduled + ;; to die an hour after they were written have to die anyway. + (binding [ct/*clock* (ct/fixed-clock test-now)] + (let [[_ file] (create-file 1) + file-id (:id file) + + revision-id (insert-revision file-id (ct/in-past {:hours 2})) + revision-at (:deleted-at (find-change revision-id))] + + (th/run-task! :delete-object {:object :file + :deleted-at (ct/in-future {:days 7}) + :id file-id}) + + (t/testing "deleting the file leaves the revision schedule alone" + (t/is (= revision-at (:deleted-at (find-change revision-id))))) + + (restore-file file-id) + + (t/testing "restoring the file leaves it alone too" + (t/is (= revision-at (:deleted-at (find-change revision-id))))) + + (th/run-task! :objects-gc {}) + + (t/testing "so the collector takes it" + (t/is (nil? (find-change revision-id))))))) From 18ca25518e61a1c59d1bc46baaa80cd6f1cbf544 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 14:28:03 +0200 Subject: [PATCH 071/167] :recycle: Use a DS icon button to close the nudge amount modal (#12439) * :recycle: Use a DS icon button to close the nudge amount modal The close button of the nudge amount modal (main menu, Preferences) was a raw button with a deprecated icon and no accessible name. Replace it with a DS ghost icon button labeled "Close", shown in its DS tooltip. The class that places it in the corner goes on the tooltip wrapper, so the tooltip anchors to the button. The numeric inputs stay for the inputs group. Add a visual test that opens the modal from the main menu. AI-assisted-by: claude-opus-5-5 * :recycle: Move nudge modal styles off the deprecated styles The close button is a DS icon button, so its placeholder goes away; it is now 32px wide instead of 28px, and sits 4px from the edge instead of 6px so the icon stays in the same place. Spacing, sizes and the color aliases move to DS tokens and logical properties, the flex mixin is inlined, and the label rule targets its class instead of the label element. The title matches the DS headline-medium typography exactly. The content and labels use body-large and the wrapper body-small, whose line-heights differ by 0.1 from the legacy ones; the labels keep their 1.5 override, so nothing visible changes. refactor/ stays only for the shared modal and labeled input placeholders. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 19 ++++++++++ frontend/src/app/main/ui/workspace/nudge.cljs | 10 ++++-- frontend/src/app/main/ui/workspace/nudge.scss | 36 +++++++++++-------- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index f074bc20a2..e67e97ce97 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -219,6 +219,25 @@ test.describe("Palette", () => { }); }); +test.describe("Nudge modal", () => { + test("Shows the nudge amount modal", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + await page.getByRole("button", { name: "Main menu" }).click(); + await page.locator("#file-menu-preferences").click(); + await page.locator("#file-menu-nudge").click(); + + await expect(page.getByText("Nudge amount")).toBeVisible(); + await page.mouse.move(0, 0); + await expect(workspace.page).toHaveScreenshot("nudge-modal.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Selected colors", () => { test("Shows the more colors button", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/nudge.cljs b/frontend/src/app/main/ui/workspace/nudge.cljs index 7c2ee4f01c..fb44f4435d 100644 --- a/frontend/src/app/main/ui/workspace/nudge.cljs +++ b/frontend/src/app/main/ui/workspace/nudge.cljs @@ -12,7 +12,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.numeric-input :as deprecated-input] - [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.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as k] @@ -44,8 +45,11 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} (tr "modals.nudge-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)} [:div {:class (stl/css :input-wrapper)} [:label {:class (stl/css :modal-msg) diff --git a/frontend/src/app/main/ui/workspace/nudge.scss b/frontend/src/app/main/ui/workspace/nudge.scss index 892de2576d..2fe8b7739a 100644 --- a/frontend/src/app/main/ui/workspace/nudge.scss +++ b/frontend/src/app/main/ui/workspace/nudge.scss @@ -4,7 +4,11 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared modal and labeled input placeholders, which move to +// the DS with the modals and the inputs. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -13,45 +17,47 @@ .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.headline-medium-typography; + @include use-typography("headline-medium"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } .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 use-typography("body-large"); - gap: deprecated.$s-24; - - @include deprecated.body-large-typography; - - margin-bottom: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .input-wrapper { @extend %input-with-label; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - label { + // Nested so it wins over the label rule of %input-with-label. + .modal-msg { text-transform: none; } } .modal-msg { - @include deprecated.body-large-typography; + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); line-height: 1.5; } From c99980327fd7630a143b82006f9f5736e5d9ceb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 19 Oct 2026 15:06:56 +0200 Subject: [PATCH 072/167] :bug: Resize fit-content flex boards while typing in v3 text editor (#12330) * :bug: Resize fit-content flex boards while typing in v3 text editor Typing in an auto-width text did not resize its fit-content layout parent until the editor closed. The v3 editor now previews the live text size in the layout on each keystroke, also when the text sits in a group. * :bug: Preview text layout when typing with a pending caret style Both v3 editor commit paths share v3-update-editor-content. * :bug: Skip the layout preview for absolute texts Absolute layout items do not affect their board. * :zap: Preview text layout only when the text size changes Each preview propagates modifiers, so skip unchanged sizes. * :bug: Leave interactive render mode when the v3 editor closes Finalize only ends it when it applies geometry. --- .../data/text-editor/get-file-12325.json | 727 ++++++++++++++++++ .../ui/specs/text-editor-v3.spec.js | 74 +- .../src/app/main/data/workspace/texts.cljs | 73 +- .../ui/workspace/shapes/text/v3_editor.cljs | 9 +- 4 files changed, 866 insertions(+), 17 deletions(-) create mode 100644 frontend/playwright/data/text-editor/get-file-12325.json diff --git a/frontend/playwright/data/text-editor/get-file-12325.json b/frontend/playwright/data/text-editor/get-file-12325.json new file mode 100644 index 0000000000..2c039f5bb8 --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-12325.json @@ -0,0 +1,727 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type", + "text-editor/v2" + ] + }, + "~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Bug 12325", + "~:revn": 3, + "~:modified-at": "~m1753957736516", + "~:vern": 0, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags" + ] + }, + "~:version": 67, + "~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669", + "~:created-at": "~m1753957644225", + "~:data": { + "~:pages": ["~u238a17e0-75ff-8075-8006-934586ea2231"], + "~:pages-index": { + "~u238a17e0-75ff-8075-8006-934586ea2231": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~uc0a701d6-7df9-80ac-8008-c9e6c5b7f7f4", + "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2" + ] + } + }, + "~uc0a701d6-7df9-80ac-8008-c9e6c5b7f7f4": { + "~#shape": { + "~:id": "~uc0a701d6-7df9-80ac-8008-c9e6c5b7f7f4", + "~:name": "Text board", + "~:type": "~:frame", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:shapes": ["~uc0a701d6-7df9-80ac-8008-c9e6c47fbce4"], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:strokes": [], + "~:hide-fill-on-export": false, + "~:show-content": true, + "~:proportion-lock": false, + "~:proportion": 1, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:layout": "~:flex", + "~:layout-flex-dir": "~:row", + "~:layout-wrap-type": "~:nowrap", + "~:layout-gap-type": "~:multiple", + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:layout-padding-type": "~:simple", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:layout-align-items": "~:start", + "~:layout-justify-content": "~:start", + "~:layout-align-content": "~:stretch", + "~:layout-item-h-sizing": "~:auto", + "~:layout-item-v-sizing": "~:auto", + "~:x": 0, + "~:y": 0, + "~:width": 99.99999904632568, + "~:height": 17, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 99.99999904632568, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 99.99999904632568, + "~:y": 17 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 17 + } + } + ], + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 99.99999904632568, + "~:height": 17, + "~:x1": 0, + "~:y1": 0, + "~:x2": 99.99999904632568, + "~:y2": 17 + } + } + } + }, + "~uc0a701d6-7df9-80ac-8008-c9e6c47fbce4": { + "~#shape": { + "~:id": "~uc0a701d6-7df9-80ac-8008-c9e6c47fbce4", + "~:name": "Hello wide world", + "~:type": "~:text", + "~:grow-type": "~:auto-width", + "~:parent-id": "~uc0a701d6-7df9-80ac-8008-c9e6c5b7f7f4", + "~:frame-id": "~uc0a701d6-7df9-80ac-8008-c9e6c5b7f7f4", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:type": "paragraph", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Hello wide world" + } + ] + } + ] + } + ] + }, + "~:x": 0, + "~:y": 0, + "~:width": 99.99999904632568, + "~:height": 17, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 99.99999904632568, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 99.99999904632568, + "~:y": 17 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 17 + } + } + ], + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 99.99999904632568, + "~:height": 17, + "~:x1": 0, + "~:y1": 0, + "~:x2": 99.99999904632568, + "~:y2": 17 + } + } + } + }, + "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2": { + "~#shape": { + "~:id": "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2", + "~:name": "Group board", + "~:type": "~:frame", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:shapes": ["~uc0a701d6-7df9-80ac-8008-c9e6ca7a304a"], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:strokes": [], + "~:hide-fill-on-export": false, + "~:show-content": true, + "~:proportion-lock": false, + "~:proportion": 1, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:layout": "~:flex", + "~:layout-flex-dir": "~:row", + "~:layout-wrap-type": "~:nowrap", + "~:layout-gap-type": "~:multiple", + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:layout-padding-type": "~:simple", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:layout-align-items": "~:start", + "~:layout-justify-content": "~:start", + "~:layout-align-content": "~:stretch", + "~:layout-item-h-sizing": "~:auto", + "~:layout-item-v-sizing": "~:auto", + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 117 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 117 + } + } + ], + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:x1": 0, + "~:y1": 100, + "~:x2": 120.99999821186066, + "~:y2": 117 + } + } + } + }, + "~uc0a701d6-7df9-80ac-8008-c9e6ca7a304a": { + "~#shape": { + "~:id": "~uc0a701d6-7df9-80ac-8008-c9e6ca7a304a", + "~:name": "Group", + "~:type": "~:group", + "~:parent-id": "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2", + "~:frame-id": "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2", + "~:shapes": ["~uc0a701d6-7df9-80ac-8008-c9e6c781a50f"], + "~:fills": [], + "~:strokes": [], + "~:proportion-lock": false, + "~:proportion": 1, + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 117 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 117 + } + } + ], + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:x1": 0, + "~:y1": 100, + "~:x2": 120.99999821186066, + "~:y2": 117 + } + } + } + }, + "~uc0a701d6-7df9-80ac-8008-c9e6c781a50f": { + "~#shape": { + "~:id": "~uc0a701d6-7df9-80ac-8008-c9e6c781a50f", + "~:name": "Hello grouped world", + "~:type": "~:text", + "~:grow-type": "~:auto-width", + "~:parent-id": "~uc0a701d6-7df9-80ac-8008-c9e6ca7a304a", + "~:frame-id": "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:type": "paragraph", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Hello grouped world" + } + ] + } + ] + } + ] + }, + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 117 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 117 + } + } + ], + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:x1": 0, + "~:y1": 100, + "~:x2": 120.99999821186066, + "~:y2": 117 + } + } + } + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2231", + "~:name": "Page 1" + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index fa49a0704a..894640a028 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -23,7 +23,6 @@ async function openEditorAndSelectAll(workspace) { await workspace.page.keyboard.press("ControlOrMeta+a"); } - test("Typography at a collapsed caret only styles newly typed text", async ({ page, }) => { @@ -218,8 +217,6 @@ test.describe("BUG 11083 - Changing typography must not quit the editor", () => }); }); - - test("BUG 10467 - Auto-width text captures every typed character", async ({ page, }) => { @@ -681,3 +678,74 @@ test.describe("Text Editor context menu", () => { await expect(menuItem(page, "Select all")).not.toHaveAttribute("disabled"); }); }); + +test.describe("BUG 12325 - Fit-content flex board follows an auto-width text while typing", () => { + // Two grey fit-content flex boards, one per test. The content + // (0, 0)-(121, 117) loads centred in the viewport at zoom 1. + const CONTENT_CENTER = { x: 60.5, y: 58.5 }; + + // Screenshot of a small canvas patch centred on (x, y), in canvas + // coordinates. + async function patchAt(workspace, x, y) { + const viewport = await workspace.viewport.boundingBox(); + const px = viewport.x + viewport.width / 2 + x - CONTENT_CENTER.x; + const py = viewport.y + viewport.height / 2 + y - CONTENT_CENTER.y; + return workspace.page.screenshot({ + clip: { x: px - 2, y: py - 2, width: 4, height: 4 }, + }); + } + + async function openFile(page) { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-12325.json"); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + return workspace; + } + + // Shrinks `text` to its first character while editing and checks that a spot + // in the right part of its board, at `boardY`, shows the empty canvas. + async function expectBoardShrinksWhileTyping(workspace, text, boardY) { + const x = 80; + const inside = boardY + 8; + const outside = boardY + 50; + expect(await patchAt(workspace, x, inside)).not.toEqual( + await patchAt(workspace, x, outside), + ); + + await workspace.textEditor.startEditing(); + await workspace.page.keyboard.press("End"); + for (let i = 0; i < text.length - 1; i++) { + await workspace.page.keyboard.press("Backspace"); + } + + // Without leaving the editor, the board already shrank around the text, so + // the spot shows the empty canvas. + await expect + .poll(async () => + (await patchAt(workspace, x, inside)).equals( + await patchAt(workspace, x, outside), + ), + ) + .toBe(true); + } + + test("The board shrinks with the text", async ({ page }) => { + const workspace = await openFile(page); + await workspace.clickToggableLayer("Text board"); + await workspace.clickLeafLayer("Hello wide world"); + + await expectBoardShrinksWhileTyping(workspace, "Hello wide world", 0); + }); + + test("The board shrinks with a text inside a group", async ({ page }) => { + const workspace = await openFile(page); + await workspace.clickToggableLayer("Group board"); + // The first "Group" match is the board row. + await workspace.clickToggableLayer("Group", {}, 1); + await workspace.clickLeafLayer("Hello grouped world"); + + await expectBoardShrinksWhileTyping(workspace, "Hello grouped world", 100); + }); +}); diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 1eb94ca1c0..f737947e3a 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -1453,18 +1453,77 @@ (gsh/transform-shape (ctm/change-size shape width height)))))) {:undo-group (when new-shape? id)})))))))) +(defn- layout-item + "The ancestor of `shape` (or `shape` itself) that is a direct child of its board." + [objects shape] + (loop [item shape] + (let [parent-id (:parent-id item)] + (if (or (= parent-id (:frame-id shape)) + (nil? (get objects parent-id))) + item + (recur (get objects parent-id)))))) + +(defn- v3-sync-text-layout + "Previews an auto-grow text's live size in its layout, so a fit-content parent + follows it while typing. Geometry is committed on finalize." + [id] + (ptk/reify ::v3-sync-text-layout + ptk/WatchEvent + (watch [_ state _] + (let [objects (dsh/lookup-page-objects state) + shape (get objects id) + preview? (and (cfh/text-shape? shape) + (not= :fixed (:grow-type shape)) + (ctl/any-layout-descent? objects shape) + (not (ctl/position-absolute? (layout-item objects shape)))) + size (when preview? + (dwwt/get-wasm-text-new-size shape)) + previous (or (get-in state [::layout-preview id]) + (select-keys (:selrect shape) [:width :height]))] + ;; Modifiers are costly, so skip keystrokes that keep the size. + (if (and (some? size) + (not (and (mth/close? (:width size) (:width previous)) + (mth/close? (:height size) (:height previous))))) + (rx/of #(assoc-in % [::layout-preview id] size) + (dwm/set-wasm-modifiers (dwwt/resize-wasm-text-modifiers shape))) + (rx/empty)))))) + +(defn v3-stop-text-layout-preview + "Drops the layout preview of the text `id` and leaves interactive render mode." + [id] + (ptk/reify ::v3-stop-text-layout-preview + ptk/WatchEvent + (watch [_ state _] + (if (contains? (get state ::layout-preview) id) + (rx/of #(update % ::layout-preview dissoc id) + (dwm/clear-local-transform)) + (rx/empty))))) + +(defn v3-update-editor-content + "Commits `content` from the WASM editor to the text `shape-id` and, while + editing, previews its new size in the layout." + [shape-id content & {:keys [finalize?]}] + (let [text (txt/content->text content) + name (when (not= text "") + (txt/generate-shape-name text)) + update-event (v2-update-text-shape-content shape-id content + :update-name? true + :name name + :finalize? finalize?)] + (if finalize? + update-event + (ptk/reify ::v3-update-editor-content + ptk/WatchEvent + (watch [_ _ _] + (rx/of update-event + (v3-sync-text-layout shape-id))))))) + (defn v3-sync-editor-content "Event pushing the WASM editor content back into the shape, or nil when there is nothing to sync. Every text edit commits through it, menu or keystroke alike." [& {:keys [finalize?]}] (when-let [{:keys [shape-id content]} (wasm.text-editor/text-editor-sync-content)] - (let [text (txt/content->text content) - name (when (not= text "") - (txt/generate-shape-name text))] - (v2-update-text-shape-content shape-id content - :update-name? true - :name name - :finalize? finalize?)))) + (v3-update-editor-content shape-id content :finalize? finalize?))) (defn- sync-editor-content-stream "Stream of the sync event for `reason`, after asking WASM to repaint." diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index ca486c0fe1..74f95eea13 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -9,7 +9,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.common.types.text :as txt] [app.config :as cf] [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] @@ -87,12 +86,7 @@ ;; Sync first so the cached content stays index-aligned with WASM. (text-editor/text-editor-sync-content) (if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)] - (let [text (txt/content->text content) - name (when (not= text "") (txt/generate-shape-name text))] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :name name))) + (st/emit! (dwt/v3-update-editor-content shape-id content)) (sync-wasm-text-editor-content!)))) (defn- collapse-input-caret @@ -663,6 +657,7 @@ (fn [] (on-blur) (st/emit! dw/hide-context-menu + (dwt/v3-stop-text-layout-preview shape-id) (dwu/commit-undo-transaction shape-id)) (text-editor/text-editor-dispose) (wasm.api/request-render-preserving-target "text-editor-dispose")))) From 655300e4d19688cf1b2c85e3b6dd5715e1b41856 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 19 Oct 2026 15:29:40 +0200 Subject: [PATCH 073/167] :bug: Include board drop shadow in multi-element raster export (#12440) Headless raster/PDF used the board selrect so overflowing children stayed cropped, but that also clipped the board's own drop shadow. Use export_root_bounds: own strokes/shadows/blur without joining children. Closes #12388 --- render-wasm/src/render/pdf.rs | 12 +--- render-wasm/src/render/raster.rs | 12 +--- render-wasm/src/render/svg/tests.rs | 87 ++++++++++++++++++++++++++++- render-wasm/src/shapes.rs | 20 ++++++- 4 files changed, 109 insertions(+), 22 deletions(-) diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index b5b7366509..cf53731c30 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -24,15 +24,9 @@ pub fn render_to_pdf( let shape = tree .get(id) .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?; - // Boards export at their own bounds (like the classic exporter, which - // screenshots exactly the board box): content overflowing a board lands - // outside the page/surface and is cropped. Other shapes keep the extended - // rect so their own shadows/blur are included. - let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) { - shape.selrect() - } else { - shape.extrect(tree, scale) - }; + // Boards crop overflowing children (classic board box) but keep their own + // strokes/shadows/blur. Other shapes use full extrect. + let bounds = shape.export_root_bounds(tree, scale); let page_w = bounds.width() * scale; let page_h = bounds.height() * scale; diff --git a/render-wasm/src/render/raster.rs b/render-wasm/src/render/raster.rs index 740d20b8d9..52dd798006 100644 --- a/render-wasm/src/render/raster.rs +++ b/render-wasm/src/render/raster.rs @@ -79,15 +79,9 @@ pub fn render_to_raster( let Some(shape) = tree.get(id) else { return Ok((Vec::new(), 0, 0)); }; - // Boards export at their own bounds (like the classic exporter, which - // screenshots exactly the board box): content overflowing a board lands - // outside the page/surface and is cropped. Other shapes keep the extended - // rect so their own shadows/blur are included. - let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) { - shape.selrect() - } else { - shape.extrect(tree, scale) - }; + // Boards crop overflowing children (classic board box) but keep their own + // strokes/shadows/blur. Other shapes use full extrect. + let bounds = shape.export_root_bounds(tree, scale); let width = (bounds.width() * scale).ceil() as i32; let height = (bounds.height() * scale).ceil() as i32; diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 7557eebb7c..0a047e8d07 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -3639,17 +3639,98 @@ fn group_with_empty_path(group_fills: Vec) -> ShapesPool { } fn raster_png(pool: &ShapesPool, id: Uuid) -> Vec { + let (bytes, _, _) = raster_png_size(pool, id); + bytes +} + +fn raster_png_size(pool: &ShapesPool, id: Uuid) -> (Vec, i32, i32) { let mut resources = crate::render::RenderResources::try_new_headless().expect("headless resources"); - let (bytes, _, _) = crate::render::raster::render_to_raster( + crate::render::raster::render_to_raster( &mut resources, &id, pool, 1.0, crate::render::raster::RasterFormat::Png, ) - .expect("raster export"); - bytes + .expect("raster export") +} + +/// #12388: multi-element (headless) raster must include a board's own drop +/// shadow, matching single-export sizing (spread 4 → +4px each side). +#[test] +fn raster_export_of_board_includes_own_drop_shadow_spread() { + let mut pool = ShapesPool::new(); + let frame_id = uid(1); + add_frame( + &mut pool, + frame_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(240, 240, 240), + true, + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.add_shadow(Shadow::new( + skia::Color::from_rgb(0, 200, 0), + 0.0, + 4.0, + (0.0, 0.0), + ShadowStyle::Drop, + false, + )); + } + + let (_, width, height) = raster_png_size(&pool, frame_id); + assert_eq!( + (width, height), + (208, 128), + "board raster must grow by drop-shadow spread, not stay at selrect" + ); +} + +/// Overflowing children stay cropped; only the board's own effects expand +/// the headless raster page (classic exporter board-box rule). +#[test] +fn raster_export_of_board_crops_overflowing_child_but_keeps_own_shadow() { + let mut pool = ShapesPool::new(); + let frame_id = uid(1); + let child = uid(2); + add_frame( + &mut pool, + frame_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(240, 240, 240), + true, + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.add_shadow(Shadow::new( + skia::Color::from_rgb(0, 200, 0), + 0.0, + 4.0, + (0.0, 0.0), + ShadowStyle::Drop, + false, + )); + frame.add_child(child); + } + add_solid_rect( + &mut pool, + child, + frame_id, + (150.0, 80.0, 280.0, 200.0), + skia::Color::from_rgb(255, 0, 0), + ); + + let (_, width, height) = raster_png_size(&pool, frame_id); + assert_eq!( + (width, height), + (208, 128), + "overflowing child must not grow board raster; own shadow still must" + ); } #[test] diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 3f75443a16..e766b723d3 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1148,10 +1148,28 @@ impl Shape { extrect } - fn own_extrect_bounds(&self) -> Bounds { + pub(crate) fn own_extrect_bounds(&self) -> Bounds { self.expand_own_bounds(self.own_base_bounds(), true) } + /// Bounds for a headless export rooted at this shape. + /// + /// Frames keep the classic board-box crop of overflowing children, but + /// still expand by their own strokes / drop shadows / layer blur so those + /// effects are not clipped (parity with single-export `extrect` for the + /// board itself). Other shapes use full [`Self::extrect`]. + pub fn export_root_bounds(&self, tree: ShapesPoolRef, scale: f32) -> math::Rect { + if matches!(self.shape_type, Type::Frame(_)) { + let mut bounds = self.own_extrect_bounds(); + if !self.transform.is_identity() { + bounds.transform_mut(&self.centered_transform()); + } + bounds.to_rect() + } else { + self.extrect(tree, scale) + } + } + /// The shape's own geometry bounds, before stroke/shadow/blur margins. fn own_base_bounds(&self) -> Bounds { let shape = self; From c345c7cbaa20643e7a718b3a01efd58c44bea7d4 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 15:35:44 +0200 Subject: [PATCH 074/167] :recycle: Use a DS icon button to remove imported SVG attributes (#12382) * :recycle: Use a DS icon button to remove imported SVG attributes Replace the raw remove button of each imported SVG attribute with the DS ghost icon button and the DS remove icon. The button had no title, so it gets a new "Remove attribute" label, which the DS tooltip shows. Drop the wrapper div that only held the button. Add a visual test that selects a group imported from SVG so the section shows. AI-assisted-by: claude-opus-5-5 * :recycle: Move imported SVG attributes styles to DS tokens The remove button is a DS icon button now, so .attr-actions and .attr-action-btn with their button placeholders go away. Inline the flex and ellipsis mixins, move spacing to DS tokens and logical properties, sizes to px2rem and the title color alias to its DS token. Use the DS body-small typography; its line-height is 1.3 instead of 1.4. Drop the line clamp rules of the attribute name: its later display: inline-block already overrode the -webkit-box they need. The refactor/ import stays only for %input-element on the value input, which moves with the inputs migration. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 26 +++++++++ .../sidebar/options/menus/svg_attrs.cljs | 11 ++-- .../sidebar/options/menus/svg_attrs.scss | 58 ++++++++----------- frontend/translations/en.po | 4 ++ frontend/translations/es.po | 4 ++ 5 files changed, 64 insertions(+), 39 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index e67e97ce97..e3cf6f1358 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -261,3 +261,29 @@ test.describe("Selected colors", () => { }); }); }); + +test.describe("Imported SVG attributes", () => { + test("Shows the attributes of an imported SVG shape", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9930.json"); + await workspace.goToWorkspace({ + fileId: "8f843b59-7fbb-81ce-8005-aa6d47ae3111", + pageId: "fb9798e7-a547-80ae-8005-9ffda4a13e2c", + }); + + // The fourth "workspace" board holds the imported SVG groups. + await workspace.clickToggableLayer("workspace", {}, 3); + await workspace.clickToggableLayer("Canvas"); + await workspace.clickLeafLayer("Rectangle 760 (Stroke)"); + + const section = workspace.rightSidebar.getByText("Imported SVG Attributes"); + await section.scrollIntoViewIfNeeded(); + await expect(section).toBeVisible(); + // The attributes are a small part of the sidebar: compare every pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs index ad07f27f6d..3dbd22eabb 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs @@ -11,7 +11,8 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.store :as st] [app.main.ui.components.title-bar :refer [title-bar*]] - [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.util.dom :as dom] [app.util.functions :as uf] [app.util.i18n :refer [tr]] @@ -48,10 +49,10 @@ [:div {:class (stl/css :attr-input)} [:input {:value @last-value :on-change handle-change}]] - [:div {:class (stl/css :attr-actions)} - [:button {:class (stl/css :attr-action-btn) - :on-click handle-delete} - deprecated-icon/remove-icon]]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.sidebar.options.svg-attrs.remove") + :on-click handle-delete + :icon i/remove}]] [:div {:class (stl/css :attr-nested-content)} [:div {:class (stl/css :attr-title)} (str (d/name (last attr)))] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss index ed73954e38..949590ec94 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss @@ -4,77 +4,67 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for %input-element: inputs move to the DS in their own plan. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .element-set { margin: 0; } .title-spacing-svg-attrs { - padding-left: deprecated.$s-2; + padding-inline-start: var(--sp-xxs); margin: 0; } .element-set-content { - @include deprecated.flex-column; - - margin: deprecated.$s-4 0 0 0; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin-block-start: var(--sp-xs); } .attr-content { display: flex; - gap: deprecated.$s-4; + gap: var(--sp-xs); } .attr-name { - @include deprecated.body-small-typography; - @include deprecated.two-line-text-ellipsis; + @include use-typography("body-small"); - width: deprecated.$s-88; - margin: auto deprecated.$s-4; - margin-right: 0; display: inline-block; - color: var(--title-foreground-color); + overflow: hidden; + text-overflow: ellipsis; + inline-size: px2rem(88); + max-inline-size: 99%; + margin-block: auto; + margin-inline: var(--sp-xs) 0; + color: var(--color-foreground-secondary); } .attr-input { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - width: deprecated.$s-124; -} - -.attr-actions { - display: flex; - gap: deprecated.$s-4; -} - -.attr-action-btn { - @extend %button-tertiary; - - width: deprecated.$s-28; - height: deprecated.$s-32; - - svg { - @extend %button-icon; - } + inline-size: px2rem(124); } .attr-nested-content { display: grid; - row-gap: deprecated.$s-4; + row-gap: var(--sp-xs); } .attr-title { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - font-size: deprecated.$fs-10; + font-size: px2rem(10); text-transform: uppercase; - margin-inline-start: deprecated.$s-4; + margin-inline-start: var(--sp-xs); color: var(--color-foreground-primary); } .attr-row { display: flex; - gap: deprecated.$s-4; + gap: var(--sp-xs); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index f5cfa817f4..a735f09beb 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8696,6 +8696,10 @@ msgstr "Shapes" msgid "workspace.sidebar.layers.texts" msgstr "Texts" +#: src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:53 +msgid "workspace.sidebar.options.svg-attrs.remove" +msgstr "Remove attribute" + #: src/app/main/ui/inspect/attributes/svg.cljs:56, src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:113 msgid "workspace.sidebar.options.svg-attrs.title" msgstr "Imported SVG Attributes" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 4ef52e1afe..6cc8b78b9d 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8787,6 +8787,10 @@ msgstr "Formas" msgid "workspace.sidebar.layers.texts" msgstr "Textos" +#: src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:53 +msgid "workspace.sidebar.options.svg-attrs.remove" +msgstr "Eliminar atributo" + #: src/app/main/ui/inspect/attributes/svg.cljs:56, src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:113 msgid "workspace.sidebar.options.svg-attrs.title" msgstr "Atributos del SVG Importado" From 398678e54c39b7b69ef151cad416d35dea0442fa Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 15:48:43 +0200 Subject: [PATCH 075/167] :recycle: Use a DS icon button for the layout element margin mode (#12423) * :recycle: Use a DS icon button for the layout element margin mode The button that switches a flex or grid element between simple and multiple margins was a raw button with a deprecated icon. Replace it with a DS ghost icon button that shows the multiple state through aria-pressed. Its title moves to the label, shown in the DS tooltip above and to the left since the button sits at the right edge of the sidebar. The grid column class goes on the tooltip wrapper. The legacy margin icons label the numeric inputs and stay for the numeric inputs group; the radio buttons are out of scope. Add a visual test that selects a flex element and shows both margin modes. AI-assisted-by: claude-opus-5-5 * :recycle: Remove deprecated button styles from layout element options The margin mode button is now a DS icon button, so its tertiary button, icon and selected placeholders and the deprecated 32px height go away. The class only places the tooltip wrapper in the third grid column. The refactor/ import stays for %input-element, still used by the legacy numeric inputs. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 44 +++++++++++++++++++ .../sidebar/options/menus/layout_item.cljs | 14 +++--- .../sidebar/options/menus/layout_item.scss | 11 ----- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index e3cf6f1358..6eba9b5bd5 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -133,6 +133,50 @@ test.describe("Design tab", () => { threshold: 0, }); }); + + test("Shows the margin modes of a flex layout element", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-7760.json"); + await workspace.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-fragment-7760.json", + ); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + await workspace.goToWorkspace({ + fileId: "cd90e028-326a-80b4-8004-7cdec16ffad5", + pageId: "cd90e028-326a-80b4-8004-7cdec16ffad6", + }); + + await workspace.clickToggableLayer("Flex Board"); + await workspace.clickLeafLayer("Rectangle"); + + const section = workspace.rightSidebar.getByRole("region", { + name: "Layout item section", + }); + const marginMode = section.getByRole("button", { + name: "Margin - multiple", + }); + await expect(marginMode).toBeVisible(); + // The margin button is a small part of the section: compare every pixel. + await expect(section).toHaveScreenshot("margin-simple.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await marginMode.click(); + await marginMode.blur(); + await page.mouse.move(0, 0); + await expect(section.getByRole("textbox", { name: "Top margin" })) + .toBeVisible(); + await expect(section).toHaveScreenshot("margin-multiple.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); }); test.describe("Assets tab", () => { 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..28443e1e7e 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 @@ -19,6 +19,7 @@ [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.components.title-bar :refer [title-bar*]] + [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.workspace.sidebar.options.common :as soc] @@ -408,12 +409,13 @@ (= type :multiple) [:> margin-multiple* props])] - [:button {:class (stl/css-case - :margin-mode true - :selected (= type :multiple)) - :title "Margin - multiple" - :on-click on-type-change'} - deprecated-icon/margin]])) + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :margin-mode) + :tooltip-placement "top-left" + :aria-pressed (= type :multiple) + :aria-label "Margin - multiple" + :icon i/margin + :on-click on-type-change'}]])) (mf/defc element-behaviour-horizontal {::mf/props :obj diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss index 52b6a165e3..914bc85ffc 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss @@ -78,18 +78,7 @@ } .margin-mode { - @extend %button-tertiary; - grid-column: 3; - height: deprecated.$s-32; - - svg { - @extend %button-icon; - } - - &.selected { - @extend %button-icon-selected; - } } .margin-simple { From 3d6823eacd957ff838f1452f7dc6ca6aab54412b Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 15:48:59 +0200 Subject: [PATCH 076/167] :recycle: Use DS icon buttons for the flex wrap and padding mode (#12428) * :recycle: Use DS icon buttons for the flex wrap and padding mode The wrap button of a flex layout and the button that shows the four padding sides (flex and grid layouts) were raw buttons with deprecated icons. Replace them with DS ghost icon buttons that show their state through aria-pressed, with the tooltip above and to the left since they sit at the right edge of the sidebar. The wrap label is now always "Wrap": its title switched to "No wrap" while pressed, which conflicts with the pressed state. Add a visual test that selects a flex board and shows both buttons at rest and pressed. AI-assisted-by: claude-opus-5-5 * :recycle: Remove deprecated wrap and padding toggle styles The wrap and padding mode buttons are now DS icon buttons, so their tertiary button, icon and selected placeholders go away. The tooltip wrappers take the same grid cells the buttons had. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 47 +++++++++++++++++++ .../options/menus/layout_container.cljs | 28 +++++------ .../options/menus/layout_container.scss | 38 --------------- 3 files changed, 60 insertions(+), 53 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 6eba9b5bd5..a9be81bf91 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -110,6 +110,53 @@ test.describe("Design tab", () => { await expect(workspace.page).toHaveScreenshot(); }); + test("Shows the wrap and padding modes of a flex layout", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-7760.json"); + await workspace.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-fragment-7760.json", + ); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + await workspace.goToWorkspace({ + fileId: "cd90e028-326a-80b4-8004-7cdec16ffad5", + pageId: "cd90e028-326a-80b4-8004-7cdec16ffad6", + }); + + await workspace.clickLeafLayer("Flex Board"); + + const section = workspace.rightSidebar.getByRole("region", { + name: "Layout container section", + }); + const wrap = section.getByRole("button", { name: /^(No w|W)rap$/ }); + const padding = section.getByRole("button", { + name: "Show 4 sided padding options", + }); + await expect(wrap).toBeVisible(); + // The buttons are a small part of the section: compare every pixel. + await expect(section).toHaveScreenshot("flex-layout-rest.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await wrap.click(); + await padding.click(); + await padding.blur(); + await page.mouse.move(0, 0); + await expect(section.getByRole("textbox", { name: /top padding/i })) + .toBeVisible(); + await expect(section).toHaveScreenshot("flex-layout-pressed.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); + test("Shows the align options when selecting two shapes", async ({ page, }) => { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 4fb0ebde64..5e043118d2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -192,13 +192,12 @@ (mf/defc wrap-row {::mf/props :obj} [{:keys [wrap-type on-click]}] - [:button {:class (stl/css-case :wrap-button true - :selected (= wrap-type :wrap)) - :title (if (= :wrap wrap-type) - "No wrap" - "Wrap") - :on-click on-click} - deprecated-icon/wrap]) + [:> icon-button* {:variant "ghost" + :tooltip-placement "top-left" + :aria-pressed (= wrap-type :wrap) + :aria-label "Wrap" + :icon i/wrap + :on-click on-click}]) (mf/defc align-row {::mf/props :obj} @@ -662,14 +661,13 @@ (= type :multiple) [:> multiple-padding-selection* props])] - [:button {:class (stl/css-case - :padding-toggle true - :selected (= type :multiple)) - :title (tr "workspace.layout-grid.editor.padding.expand") - :aria-label (tr "workspace.layout-grid.editor.padding.expand") - :data-type (d/name type) - :on-click on-type-change'} - deprecated-icon/padding-extended]])) + [:> icon-button* {:variant "ghost" + :tooltip-placement "top-left" + :aria-pressed (= type :multiple) + :aria-label (tr "workspace.layout-grid.editor.padding.expand") + :data-type (d/name type) + :icon i/padding-extended + :on-click on-type-change'}]])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; GAP diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index 7182dc5729..123efabf29 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -61,25 +61,6 @@ grid-column: span 4; } -// TODO: Replace this buttons with DS buttons -.wrap-button { - @extend %button-tertiary; - - border-radius: $br-8; - block-size: $sz-32; - inline-size: $sz-32; - - svg { - @extend %button-icon; - - stroke: var(--color-foreground-secondary); - } - - &.selected { - @extend %button-icon-selected; - } -} - .second-row, .third-row { grid-column: 1 / -1; @@ -155,25 +136,6 @@ gap: var(--sp-xs); } -// TODO: Replace this buttons with DS buttons -.padding-toggle { - @extend %button-tertiary; - - block-size: $sz-32; - inline-size: $sz-32; - border-radius: $br-8; - - svg { - @extend %button-icon; - - stroke: var(--color-foreground-secondary); - } - - &.selected { - @extend %button-icon-selected; - } -} - .grid-layout-menu { @include sidebar.option-grid-structure; } From e4da0527fcfd6b0784a34e038bd796a640dc56e6 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 15:49:14 +0200 Subject: [PATCH 077/167] :recycle: Use DS buttons and icons in the grid layout options (#12431) * :recycle: Use DS buttons and icons in the grid layout options The "Edit grid" button of a grid layout and the "Exit" button of the grid editor were raw buttons with the deprecated secondary style. Replace them with DS secondary buttons; their text and actions stay the same. Drop the "alt" attribute of "Edit grid": it is not valid on a button. The expand and add buttons of the column and row headers are 52px parts of a custom header, not DS buttons, so they stay raw; only their deprecated icons move to the DS icon, as does the direction icon of each track. Add a visual test that shows the grid layout options and the grid editor with the column tracks expanded. AI-assisted-by: claude-opus-5-5 * :recycle: Remove deprecated button styles from the grid layout options "Edit grid" and "Exit" are now DS buttons, so their secondary placeholder and typography go away; the classes only place them in the grid and center their label. The track header and direction icons drop the deprecated icon placeholder and get their colors through classes. The header buttons keep their secondary and tertiary placeholders, since they stay raw. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 38 +++++++++++++ .../options/menus/layout_container.cljs | 24 ++++---- .../options/menus/layout_container.scss | 55 +++++-------------- 3 files changed, 66 insertions(+), 51 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index a9be81bf91..7ae69e0520 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -53,6 +53,44 @@ test.describe("Viewport", () => { }); }); +test.describe("Grid layout", () => { + test("Shows the grid layout options and the grid editor", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9042.json"); + await workspace.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-fragment-9042.json", + ); + await workspace.goToWorkspace({ + fileId: "af2494d0-39ba-8184-8005-230696f6df5c", + pageId: "af2494d0-39ba-8184-8005-230696f6df5d", + }); + + await workspace.clickLeafLayer("Board"); + const section = page.getByTestId("inspect-layout"); + await expect(section.getByRole("button", { name: "Edit grid" })) + .toBeVisible(); + // The buttons are a small part of the section: compare every pixel. + await expect(section).toHaveScreenshot("grid-layout-options.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await section.getByRole("button", { name: "Edit grid" }).click(); + await expect(workspace.rightSidebar.getByText("GRID LAYOUT")).toBeVisible(); + // Expand the column tracks so their rows show too. + await workspace.rightSidebar.getByText(/^Columns? \d/).click(); + await page.mouse.move(0, 0); + await expect(workspace.rightSidebar).toHaveScreenshot( + "grid-layout-editor.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + }); +}); + test.describe("Design tab", () => { test("Shows the boolean options when selecting two shapes", async ({ page, diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 5e043118d2..f8dcee251d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -25,8 +25,9 @@ [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.components.select :refer [select]] [app.main.ui.components.title-bar :refer [title-bar*]] + [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.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.formats :as fmt] [app.main.ui.hooks :as h] [app.main.ui.icons :as deprecated-icon] @@ -845,10 +846,9 @@ (if-not active? (st/emit! (udw/start-edition-mode id)) (st/emit! :interrupt))))] - [:button - {:class (stl/css :edit-mode-btn) - :alt "Grid edit mode" - :on-click toggle-edit-mode} + [:> button* {:variant "secondary" + :class (stl/css :edit-mode-btn) + :on-click toggle-edit-mode} (tr "workspace.layout-grid.editor.options.edit-grid")])) (mf/defc align-grid-row @@ -988,7 +988,8 @@ [:div {:class (stl/css :track-info-container)} [:div {:class (stl/css :track-info-dir-icon) :on-click handle-select-track} - (if is-column deprecated-icon/flex-vertical deprecated-icon/flex-horizontal)] + [:> icon* {:icon-id (if is-column i/flex-vertical i/flex-horizontal) + :class (stl/css :track-info-dir)}]] [:div {:class (stl/css :track-info-value)} [:> deprecated-input/numeric-input* {:no-validate true @@ -1036,11 +1037,13 @@ [:div {:class (stl/css :grid-tracks) :data-testid testid} [:div {:class (stl/css :grid-track-header)} - [:button {:class (stl/css :expand-icon) :on-click toggle} deprecated-icon/menu] + [:button {:class (stl/css :expand-icon) :on-click toggle} + [:> icon* {:icon-id i/menu :class (stl/css :expand-icon-svg)}]] [:div {:class (stl/css :track-title) :on-click toggle} [:div {:class (stl/css :track-name) :title track-name} track-name] [:div {:class (stl/css :track-detail) :title track-detail} track-detail]] - [:button {:class (stl/css :add-column) :on-click add-track} deprecated-icon/add]] + [:button {:class (stl/css :add-column) :on-click add-track} + [:> icon* {:icon-id i/add :size "s" :class (stl/css :add-column-svg)}]]] (when expanded? [:> h/sortable-container* {} @@ -1568,8 +1571,9 @@ :aria-label (tr "labels.help-center") :on-click open-grid-help :icon i/help}] - [:button {:class (stl/css :exit-btn) - :on-click #(st/emit! (udw/clear-edition-mode))} + [:> button* {:variant "secondary" + :class (stl/css :exit-btn) + :on-click #(st/emit! (udw/clear-edition-mode))} (tr "workspace.layout-grid.editor.options.exit")]] [:div {:class (stl/css :row :first-row)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index 123efabf29..34f3d81b43 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -176,22 +176,14 @@ grid-column: span 5; } -// TODO: Replace this buttons with DS buttons .edit-mode-btn { - @extend %button-secondary; - @include t.use-typography("headline-small"); - inline-size: 100%; - padding: var(--sp-s); + justify-content: center; grid-column: span 7; } -// TODO: Replace this buttons with DS buttons .exit-btn { - @extend %button-secondary; - @include t.use-typography("headline-small"); - - padding: var(--sp-s) var(--sp-xl); + justify-content: center; grid-column: span 2; } @@ -237,22 +229,17 @@ grid-column: 1 / span 7; } -// TODO: Replace this buttons with DS buttons .track-info-dir-icon { + display: flex; + align-items: center; cursor: pointer; border-radius: $br-8 0 0 $br-8; background-color: var(--color-background-tertiary); padding: 0 var(--sp-s); + color: var(--color-foreground-secondary); - svg { - @extend %button-icon; - - stroke: var(--color-foreground-secondary); - block-size: 100%; - } - - &:hover svg { - stroke: var(--color-foreground-primary); + &:hover { + color: var(--color-foreground-primary); } } @@ -309,7 +296,8 @@ color: var(--color-foreground-secondary); } -// TODO: Replace this buttons with DS buttons +// Raw buttons on purpose: they are 52px segments of the track header, not +// DS buttons. The nested icon rules win over the placeholder svg colors. .expand-icon { @extend %button-secondary; @@ -317,38 +305,23 @@ border-radius: $br-8 0 0 $br-8; border-inline-end: $b-1 solid var(--color-background-primary); - svg { - @extend %button-icon; - + .expand-icon-svg { stroke: var(--color-foreground-secondary); - fill: var(--color-foreground-secondary); } - &:hover, - &:active { - svg { - stroke: var(--color-accent-primary); - fill: var(--color-accent-primary); - } + &:hover .expand-icon-svg, + &:active .expand-icon-svg { + stroke: var(--color-accent-primary); } } -// TODO: Replace this buttons with DS buttons .add-column { @extend %button-tertiary; block-size: px2rem(52); - svg { - display: flex; - justify-content: center; - align-items: center; - color: transparent; - stroke-width: px2rem(1); - block-size: $sz-12; - inline-size: $sz-12; + .add-column-svg { stroke: var(--color-foreground-secondary); - fill: var(--color-foreground-secondary); } } From d6e1cb23e0283d99a9e684f32f9a7f04a4b7f467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 19 Oct 2026 15:49:41 +0200 Subject: [PATCH 078/167] :whale: Serve Penpot and the Admin Console from a subpath (#12358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :whale: Serve the frontend under the path of PENPOT_PUBLIC_URI Requests below the path are rewritten to the root, so a reverse proxy can forward them unchanged. The Admin Console keeps receiving the original path. AI-assisted-by: Claude Signed-off-by: David Barragán Merino * :whale: Strip the public path before proxying to the Admin Console The Admin Console is served at the root of its own server and applies the public path at runtime, so it needs a matching Admin Console image. AI-assisted-by: Claude Signed-off-by: David Barragán Merino * :books: Document subpath deployments in docker-compose.yaml AI-assisted-by: Claude Signed-off-by: David Barragán Merino * :books: Describe subpath deployments without proxy rewrites The frontend now serves the subpath itself, so the reverse proxy must forward it unchanged. AI-assisted-by: Claude Signed-off-by: David Barragán Merino --------- Signed-off-by: David Barragán Merino --- docker/images/docker-compose.yaml | 5 +++++ ...ginx-admin-console-locations.conf.template | 9 +++++++- docker/images/files/nginx-entrypoint.sh | 22 +++++++++++++++++-- docker/images/files/nginx.conf.template | 6 +++-- docs/technical-guide/configuration.md | 11 +++------- .../technical-guide/getting-started/docker.md | 12 +++++----- 6 files changed, 45 insertions(+), 20 deletions(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index e667bc3049..4c69760e24 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -26,6 +26,9 @@ x-flags: &penpot-flags PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console +## Public URI where Penpot is reached. To serve Penpot from a subpath, include +## it with a trailing slash, e.g. https://example.com/penpot/. A reverse proxy in +## front must forward the subpath unchanged, without rewriting it. x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: http://localhost:9001 @@ -110,6 +113,8 @@ services: # ## internet with HTTPS using traefik. # - "traefik.http.routers.penpot-https.rule=Host(``)" + # ## When Penpot is served from a subpath, match it too: + # - "traefik.http.routers.penpot-https.rule=Host(``) && PathPrefix(`/`)" # - "traefik.http.routers.penpot-https.entrypoints=websecure" # - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt" # - "traefik.http.routers.penpot-https.tls=true" diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template index fead3783c9..2b0a4f95b8 100644 --- a/docker/images/files/nginx-admin-console-locations.conf.template +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -1,7 +1,14 @@ +# The Admin Console is served at the root of its own server: strip the public +# path and admin-console/ from the original request URI. location /admin-console { + set $admin_console_uri "/"; + if ($request_uri ~ "^${PENPOT_PUBLIC_PATH_REGEX}admin-console(/.*)$") { + set $admin_console_uri $1; + } + proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $http_cf_connecting_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI$admin_console_uri; } diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 3ec97403a8..e141db2d3a 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -51,6 +51,24 @@ update_oidc_name /var/www/app/js/config.js ## Nginx Config ######################################### +# Path of PENPOT_PUBLIC_URI (e.g. /penpot/ for https://example.com/penpot/). +# Requests below it are rewritten to the root, so the proxy in front of this +# server forwards them unchanged and $request_uri keeps the original path. +PENPOT_PUBLIC_PATH="$(sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://[^/]*||; s|[?#].*$||' <<< "${PENPOT_PUBLIC_URI:-}")" +PENPOT_PUBLIC_PATH="/${PENPOT_PUBLIC_PATH#/}" +export PENPOT_PUBLIC_PATH="${PENPOT_PUBLIC_PATH%/}/" + +# shellcheck disable=SC2016 # literal regex metacharacters +PENPOT_PUBLIC_PATH_REGEX="$(sed -E 's/[][\.*^$(){}+?|]/\\&/g' <<< "$PENPOT_PUBLIC_PATH")" +export PENPOT_PUBLIC_PATH_REGEX + +if [ "$PENPOT_PUBLIC_PATH" != "/" ]; then + export PENPOT_PUBLIC_PATH_DIRECTIVES="location = ${PENPOT_PUBLIC_PATH%/} { absolute_redirect off; return 301 ${PENPOT_PUBLIC_PATH}\$is_args\$args; } + rewrite ^${PENPOT_PUBLIC_PATH_REGEX}(.*)\$ /\$1 last;" +else + export PENPOT_PUBLIC_PATH_DIRECTIVES="" +fi + export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB @@ -58,12 +76,12 @@ export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::] if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then export PENPOT_IPV6_LISTEN_DIRECTIVE="" fi -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE,\$PENPOT_PUBLIC_PATH,\$PENPOT_PUBLIC_PATH_DIRECTIVES" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000} - envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \ + envsubst "\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_PUBLIC_PATH_REGEX" \ < /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf else rm -f /etc/nginx/overrides/server.d/admin-console-locations.conf diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 0451e7a24a..c8800b0503 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -102,6 +102,8 @@ http { root /var/www/app/; + ${PENPOT_PUBLIC_PATH_DIRECTIVES} + location @handle_redirect { set $redirect_uri "$upstream_http_location"; set $redirect_host "$upstream_http_x_host"; @@ -167,7 +169,7 @@ http { location /readyz { access_log off; proxy_set_header Host $proxy_host; - proxy_pass $PENPOT_BACKEND_URI$request_uri; + proxy_pass $PENPOT_BACKEND_URI$uri$is_args$args; } location /ws/notifications { @@ -198,7 +200,7 @@ http { } location ~ ^/[^/]+/(.*)$ { - return 301 " /404"; + return 301 " ${PENPOT_PUBLIC_PATH}404"; } if ($penpot_link_preview_agent) { diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 695a7e3a02..db72a68b0d 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -384,16 +384,11 @@ PENPOT_PUBLIC_URI: https://mycompany.com/penpot/ Your reverse proxy or Ingress must also: -- Route requests under `/penpot/` to the Penpot frontend. -- Remove the `/penpot` prefix before forwarding each request. The frontend - continues to serve its internal routes from `/`. +- Route requests under `/penpot/` to the Penpot frontend without rewriting the + path. The frontend expects to receive the subpath. - Preserve WebSocket upgrade headers for notifications and other services that use WebSockets. -Changing only `PENPOT_PUBLIC_URI` is not enough. Without the matching proxy -rewrite, static assets, API calls, and WebSocket connections will use routes that -the frontend does not recognize. - #### Migrate an existing installation to a subpath Moving an installation from `/` to a subpath does not require a database or @@ -403,7 +398,7 @@ the existing storage. Before changing the public URL: 1. Back up the database, assets, and current deployment configuration. -2. Configure the proxy or Ingress route and prefix rewrite. +2. Configure the proxy or Ingress route for the subpath. 3. Update `PENPOT_PUBLIC_URI` for every Penpot component that uses it. 4. Keep the existing `PENPOT_SECRET_KEY`, database, and asset storage configuration. diff --git a/docs/technical-guide/getting-started/docker.md b/docs/technical-guide/getting-started/docker.md index 3e8f9740a8..d6d9a0ae4b 100644 --- a/docs/technical-guide/getting-started/docker.md +++ b/docs/technical-guide/getting-started/docker.md @@ -325,8 +325,7 @@ First, set the complete public URL in `docker-compose.yaml`: PENPOT_PUBLIC_URI: https://mycompany.com/penpot/ ``` -Then route the subpath to the Penpot frontend and remove the prefix before -forwarding the request: +Then route the subpath to the Penpot frontend, forwarding the path unchanged: ```nginx server { @@ -344,8 +343,6 @@ server { } location /penpot/ { - rewrite ^/penpot/(.*)$ /$1 break; - proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Scheme $scheme; @@ -359,9 +356,10 @@ server { } ``` -This single location also covers the API and WebSocket endpoints because NGINX -forwards them after removing `/penpot`. Keep the trailing slash in -`PENPOT_PUBLIC_URI`. +This single location covers every Penpot endpoint, WebSockets included. Keep +the trailing slash in `PENPOT_PUBLIC_URI`, and do not add a path to `proxy_pass` +(such as `http://localhost:9001/`): NGINX would then replace the `/penpot/` +prefix instead of forwarding it. ### Example with CADDY SERVER From 1c70b0ce2d5d0c3d5f2b1b566dbbdcf8e0c72bc4 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 15:52:48 +0200 Subject: [PATCH 079/167] :recycle: Use the DS icon in the workspace left header (#12432) * :recycle: Use the DS icon in the workspace left header The shared library badge of the left header used a deprecated icon; use the DS library icon instead. Drop the deprecated icons inside the save status dot. They rendered as 500px SVGs with no stroke, so they never showed: the dot is only its background color, and its title keeps the status text. The Penpot logo is brand art and stays. Add a visual test that opens a shared file and shows the header with the badge, then the saved status after drawing a shape. AI-assisted-by: claude-opus-5-5 * :recycle: Move workspace left header styles off the deprecated styles Inline the flex and ellipsis mixins, move spacing, sizes and borders to DS tokens and logical properties, and resolve the color aliases to their DS tokens. The status dot takes its color from a local variable. The badge icon gets its color through a class instead of an svg selector. The project name uses the DS headline-small typography: 12px instead of 11px. The file name matches title-small exactly. Left with a TODO: the rename input font size and z-index have no DS match, and the pending status orange (#f5a91b) has no DS color. left_header.scss no longer imports refactor/. AI-assisted-by: claude-opus-5-5 * :recycle: Resolve the TODOs left in the workspace left header styles The status dot used the legacy warning orange #f5a91b, which drifted from the DS one when both replaced #fe4811 a few days apart in early 2025. Use the DS --color-accent-warning (#fe9c07), the orange of every other DS warning, so the dot looks a little more orange while saving. The rename input had z-index 20, which did nothing: the input is neither positioned nor a flex or grid item. Remove it. The rename input only set its font size and got the rest from the global reset; it now uses the DS title-small typography, like the file name. Its line-height goes from normal to 1.2, so it grows from 16 to 16.8px. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 33 +++++ .../app/main/ui/workspace/left_header.cljs | 13 +- .../app/main/ui/workspace/left_header.scss | 134 ++++++++++-------- 3 files changed, 110 insertions(+), 70 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 7ae69e0520..73c5e2a524 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -320,6 +320,39 @@ test.describe("Assets tab", () => { }); }); +test.describe("Left header", () => { + test("Shows the shared badge and the save status", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-library.json"); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + await workspace.goToWorkspace({ + fileId: "c1249a66-fce0-8175-8004-7433fe4be8bc", + pageId: "c1249a66-fce0-8175-8004-7433fe4be8bd", + }); + + const header = page.locator("header", { hasText: "Testing library 1" }); + await expect(header).toBeVisible(); + // The badge and the status dot are tiny: compare every pixel. + await expect(header).toHaveScreenshot("left-header-shared.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await workspace.rectShapeButton.click(); + await workspace.clickWithDragViewportAt(128, 128, 200, 100); + await expect(header.getByTitle("Saved")).toBeVisible(); + await page.mouse.move(640, 600); + await expect(header).toHaveScreenshot("left-header-saved.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Palette", () => { test("Shows the bottom palette expanded and collapsed", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 37e6247b87..3eec46c77d 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -15,6 +15,7 @@ [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.main-menu :as main-menu] [app.util.dom :as dom] @@ -124,17 +125,11 @@ :saved (tr "workspace.header.saved") :retrying (tr "workspace.header.retrying") :error (tr "workspace.header.save-error") - nil)} - (case persistence-status - :pending deprecated-icon/status-alert - :saving deprecated-icon/status-alert - :saved deprecated-icon/status-tick - :retrying deprecated-icon/status-alert - :error deprecated-icon/status-wrong - nil)] + nil)}] [:div {:class (stl/css :file-name-label)} file-name]])] (when ^boolean shared? - [:span {:class (stl/css :shared-badge)} deprecated-icon/library]) + [:span {:class (stl/css :shared-badge)} + [:> icon* {:icon-id i/library :class (stl/css :shared-badge-icon)}]]) [:div {:class (stl/css :menu-section)} [:> main-menu/menu* {:layout layout :file file}]]])) diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss index 663c7465b2..a1542d48a9 100644 --- a/frontend/src/app/main/ui/workspace/left_header.scss +++ b/frontend/src/app/main/ui/workspace/left_header.scss @@ -4,27 +4,32 @@ // // 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 *; .workspace-header-left { display: flex; align-items: center; - padding: deprecated.$s-12 deprecated.$s-12 deprecated.$s-8 deprecated.$s-12; - min-height: deprecated.$s-52; + padding: var(--sp-m) var(--sp-m) var(--sp-s) var(--sp-m); + min-block-size: px2rem(52); } .main-icon { - @include deprecated.flex-center; - - width: deprecated.$s-32; - height: deprecated.$s-32; - min-height: deprecated.$s-32; - margin-right: deprecated.$s-4; + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-32; + block-size: $sz-32; + min-block-size: $sz-32; + margin-inline-end: var(--sp-xs); + // The Penpot logo is brand art from the legacy icon set and has no class. svg { - min-height: deprecated.$s-32; - width: deprecated.$s-32; - fill: var(--icon-foreground-hover); + min-block-size: $sz-32; + inline-size: $sz-32; + fill: var(--color-foreground-primary); } } @@ -32,49 +37,60 @@ position: relative; flex-grow: 1; flex-shrink: 1; - min-width: 0; - height: deprecated.$s-32; - min-height: deprecated.$s-32; + min-inline-size: 0; + block-size: $sz-32; + min-block-size: $sz-32; } .project-name, .file-name { - @include deprecated.uppercase-title-typography; - @include deprecated.text-ellipsis; - - height: deprecated.$s-16; - width: 100%; - padding-bottom: deprecated.$s-2; - color: var(--title-foreground-color); + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + block-size: $sz-16; + inline-size: 100%; + padding-block-end: var(--sp-xxs); cursor: pointer; } -.file-name { - @include deprecated.small-title-typography; +.project-name { + @include use-typography("headline-small"); - text-transform: none; - color: var(--title-foreground-color-hover); + color: var(--color-foreground-secondary); +} + +.file-name { + @include use-typography("title-small"); + + color: var(--color-foreground-primary); align-items: center; display: flex; flex-direction: row; } .file-name-label { - @include deprecated.text-ellipsis; + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .file-name-input { - @include deprecated.flex-center; + @include use-typography("title-small"); - width: 100%; + display: flex; + justify-content: center; + align-items: center; + inline-size: 100%; margin: 0; border: 0; padding: 0; - border-radius: deprecated.$br-4; - background-color: var(--input-background-color); - font-size: deprecated.$fs-14; - color: var(--input-foreground-color); - z-index: deprecated.$z-index-20; + border-radius: $br-4; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-secondary); white-space: break-spaces; &:focus { @@ -83,46 +99,42 @@ } .shared-badge { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-16; + block-size: $sz-32; + margin-inline-end: var(--sp-xs); +} - width: deprecated.$s-16; - height: deprecated.$s-32; - margin-right: deprecated.$s-4; - - svg { - stroke: var(--button-secondary-foreground-color-rest); - fill: none; - height: deprecated.$s-16; - width: deprecated.$s-16; - } +.shared-badge-icon { + color: var(--color-foreground-secondary); } .status-notification { - width: deprecated.$s-6; - height: deprecated.$s-6; - border-radius: 50%; - margin-right: deprecated.$s-4; + --status-notification-background-color: transparent; + + inline-size: $sz-6; + block-size: $sz-6; + border-radius: $br-circle; + margin-inline-end: var(--sp-xs); flex-shrink: 0; + background-color: var(--status-notification-background-color); - &.pending-status { - background-color: var(--status-widget-background-color-warning); - } - - &.saving-status { - background-color: var(--status-widget-background-color-pending); + &.pending-status, + &.saving-status, + &.retrying-status { + --status-notification-background-color: var(--color-accent-warning); } &.saved-status { - background-color: var(--status-widget-background-color-success); + --status-notification-background-color: var(--color-accent-success); + animation: jump 0.3s ease-out; } - &.retrying-status { - background-color: var(--status-widget-background-color-warning); - } - &.error-status { - background-color: var(--status-widget-background-color-error); + --status-notification-background-color: var(--color-foreground-error); } } From c7f16d3236940bc3276f87bd6767d0f43bdee840 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 19 Oct 2026 15:56:05 +0200 Subject: [PATCH 080/167] :bug: Keep accented letters in organization avatar initials (#12328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get-initials used an ASCII-only [a-zA-Z] match, so names like Ábaco and Órbita skipped the leading accented letter. Match Unicode letters with \p{L} (CLJS RegExp + u/g flags) so org default avatars match the expected first letter. Closes #12324. AI-assisted-by: Cursor --- common/src/app/common/data.cljc | 12 ++++++++++-- common/test/common_tests/data_test.cljc | 13 ++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 8a1efbe5d3..9a4aaa3020 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1202,12 +1202,20 @@ (str/replace (str s) #"([*_~`\[\]()>#+=\-|{}.!@\\])" (fn [[_ c]] (str "\\" c))) "")) +;; \p{L} (Unicode letter) works in Java regex natively, but in JavaScript it +;; requires the "u" flag which ClojureScript regex literals don't support. +;; Do not use the "g" flag: a shared global RegExp mutates lastIndex and +;; corrupts later get-initials calls (org avatar lists in the team switcher). +(def ^:private letter-re + #?(:clj #"\p{L}" + :cljs (js/RegExp. "\\p{L}" "u"))) + (defn get-initials "Returns up to two uppercase initials extracted from a string. - Non-letter prefixes in each token are ignored." + Non-letter prefixes in each token are ignored (Unicode letters)." [name] (->> (str/split (str/trim (or name "")) #"\s+") - (keep #(first (re-seq #"[a-zA-Z]" %))) + (keep #(re-find letter-re %)) (take 2) (map str/upper) (apply str))) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 353435bab8..fa56367800 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -34,7 +34,18 @@ (t/is (= "AB" (d/get-initials "123 Alpha ## beta"))) (t/is (= "PD" (d/get-initials " penpot design tool "))) (t/is (= "" (d/get-initials nil))) - (t/is (= "" (d/get-initials "!!! ???")))) + (t/is (= "" (d/get-initials "!!! ???"))) + ;; Accented / Unicode letters (org default avatars) + (t/is (= "Á" (d/get-initials "Ábaco"))) + (t/is (= "Ó" (d/get-initials "Órbita"))) + (t/is (= "ÁN" (d/get-initials "Álvaro Núñez"))) + ;; Sequential calls must stay stable (CLJS global RegExp lastIndex) + (t/is (= ["Á" "BB" "JG" "L" "Á"] + (mapv d/get-initials ["Ábaco" + "Black Bird" + "Jean Grey Academy" + "Library" + "Ábaco"])))) (t/deftest normalize-string-test ;; nil input returns empty string From 38a6961ff1ddde8538895e63f17ebbcd4d40ed5b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 16:08:47 +0200 Subject: [PATCH 081/167] :sparkles: Make keyboard shortcuts layout-independent (#12326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert ":bug: Fix shortcuts with modifiers on letters and digits (#12292)" This reverts commit 21ecfb67b70ac2d6f4defb05a543f0fc18d692a2. * :bug: Migrate mousetrap to key with sane modifiers Finish the half-done mousetrap migration from code/keyCode to key: canonical lowercase tokens on both bind and event sides so modifiers match again, US-only shift remap and keypress legacy removed, and AltGr phantom ctrl filtered on Windows. Record punctuation positionally through getLayoutMap (American fallback) so Shift+Period stores shift+. on every layout, while letters and digits stay glyph-based for AZERTY. Drop dead 219 aliases and revert the stray CamelCase escape command. AI-assisted-by: muse-spark-1.3-contributor * :recycle: Rewrite mousetrap as ESM class without behavior changes Turn the 2012 constructor-plus-prototype shape into a class with underscore-private methods, const tables and arrow listeners. Drop dead _belongsTo, fold one-line prevent/stop helpers into _fireCallback, and let handleKey absorb the old _handleKey indirection. Public API and matching logic stay identical; verified by existing tests plus a runtime smoke. AI-assisted-by: muse-spark-1.3-contributor * :tada: Add node:test suite for mousetrap shortcuts Cover canonical key normalization, modifiers with AltGr, bind arrays plus aliases keyup reset overwrite, sequences with fake timers, and the full stopCallback matrix, all through isolated instances and synthetic events. Hook the zero-dependency suite into frontend/scripts/test for CI. AI-assisted-by: muse-spark-1.3-contributor * :bug: Record shortcuts by glyph instead of position Drop the getLayoutMap positional layer and the American fallback: punctuation now records event.key lowercased, the same token Mousetrap dispatches, so Shift combos match with and without a layout map. Keeps the Numpad numN branch both sides share. Updates the pinned positional tests and adds the Spanish Shift-plus regression. AI-assisted-by: muse-spark-1.3-contributor * :bug: Resolve shifted digits to the digit itself Shift+DigitN now resolves to the digit on every layout, so shift+0 fires on QWERTY ()) as well as AZERTY (0), like the old keyCode behavior. Applies in the recorder and Mousetrap; unshifted digits and numpad are untouched. Also stages the layout-map suite left untracked. AI-assisted-by: muse-spark-1.3-contributor * :bug: Cover punctuation defaults with layout twins Some defaults only fired where their glyph is unshifted (+ dead on US layouts, shift++ dead outside ES/DE, \ dead where it needs AltGr). Bind the twin token per layout family and freeze the display with :show-command. No tooltip or translation changes. AI-assisted-by: muse-spark-1.3-contributor * :sparkles: Warn when the keyboard layout API is missing Shows a warning on the shortcuts settings page in browsers without navigator.keyboard.getLayoutMap, where Shift with punctuation may not fire. Uses a synchronous probe, English and Spanish strings, and documents the layout limitations in the user guide. AI-assisted-by: muse-spark-1.3-contributor * :bug: Fix hide-ui twins and qualify layout docs The ctrl+alt+\ twin could never fire (the AltGr filter drops Ctrl), so it goes away; alt+º covers Chrome on Spanish layouts where AltGr+º resolves to º. The user guide now states which claims need the layout map and which hold everywhere. AI-assisted-by: muse-spark-1.3-contributor --------- Signed-off-by: Eva Marco Co-authored-by: Eva Marco --- docs/user-guide/first-steps/shortcuts.njk | 4 +- frontend/packages/mousetrap/README.md | 19 + frontend/packages/mousetrap/index.js | 1362 +++++++---------- frontend/packages/mousetrap/package.json | 3 + .../mousetrap/test/bind-dispatch.test.js | 183 +++ frontend/packages/mousetrap/test/helpers.js | 89 ++ .../mousetrap/test/layout-map.test.js | 95 ++ .../packages/mousetrap/test/modifiers.test.js | 63 + .../packages/mousetrap/test/normalize.test.js | 109 ++ .../packages/mousetrap/test/sequences.test.js | 108 ++ .../packages/mousetrap/test/smoke.test.js | 31 + .../mousetrap/test/stop-callback.test.js | 102 ++ .../scripts/check-translations/words.es.txt | 1 + frontend/scripts/test | 5 + .../app/main/data/dashboard/shortcuts.cljs | 3 +- frontend/src/app/main/data/shortcuts.cljs | 31 + .../src/app/main/data/viewer/shortcuts.cljs | 3 +- .../data/workspace/grid_layout/shortcuts.cljs | 3 +- .../main/data/workspace/path/shortcuts.cljs | 9 +- .../app/main/data/workspace/shortcuts.cljs | 12 +- .../src/app/main/ui/settings/shortcuts.cljs | 5 + .../src/app/main/ui/settings/shortcuts.scss | 4 + frontend/src/app/main/ui/shortcuts.cljs | 155 +- frontend/test/frontend_tests/runner.cljs | 2 - .../ui/settings_shortcuts_test.cljs | 169 +- .../ui/shortcuts_recording_test.cljs | 60 - frontend/translations/en.po | 7 + frontend/translations/es.po | 15 +- 28 files changed, 1714 insertions(+), 938 deletions(-) create mode 100644 frontend/packages/mousetrap/README.md create mode 100644 frontend/packages/mousetrap/test/bind-dispatch.test.js create mode 100644 frontend/packages/mousetrap/test/helpers.js create mode 100644 frontend/packages/mousetrap/test/layout-map.test.js create mode 100644 frontend/packages/mousetrap/test/modifiers.test.js create mode 100644 frontend/packages/mousetrap/test/normalize.test.js create mode 100644 frontend/packages/mousetrap/test/sequences.test.js create mode 100644 frontend/packages/mousetrap/test/smoke.test.js create mode 100644 frontend/packages/mousetrap/test/stop-callback.test.js delete mode 100644 frontend/test/frontend_tests/ui/shortcuts_recording_test.cljs diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk index e4708c9800..ab8279486a 100644 --- a/docs/user-guide/first-steps/shortcuts.njk +++ b/docs/user-guide/first-steps/shortcuts.njk @@ -35,7 +35,9 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface

    Paste cannot be customized.

    Keyboard layouts

    -

    Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so they work the same on AZERTY, QWERTZ and other layouts. Letters and numbers are recorded by the character they type. With Shift, the base key is recorded, not the shifted character.

    +

    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.

    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.

    diff --git a/frontend/packages/mousetrap/README.md b/frontend/packages/mousetrap/README.md new file mode 100644 index 0000000000..848c8d8bfc --- /dev/null +++ b/frontend/packages/mousetrap/README.md @@ -0,0 +1,19 @@ +# @penpot/mousetrap + +Vendored keyboard-shortcut library (based on Mousetrap 1.6.5), migrated +from `code`/`keyCode` to `event.key` with lowercase canonical tokens so +shortcuts behave the same across keyboard layouts. + +## Tests + +Unit suite with the Node standard runner, no browser and no extra +dependencies: + +```bash +pnpm test # node --test "test/*.test.js" +``` + +The suite builds real `Mousetrap` instances over fake targets and +synthetic events, with fake timers for sequences. It runs in the frontend +CI through `frontend/scripts/test` and must stay deterministic (no +network, no real time, no platform reads). diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js index 4558a758b0..e3864396a4 100644 --- a/frontend/packages/mousetrap/index.js +++ b/frontend/packages/mousetrap/index.js @@ -21,233 +21,103 @@ */ const globalDocument = globalThis?.document; /** - * mapping of special keycodes to their corresponding keys + * Canonical key names, all lowercase. * - * everything in this dictionary cannot use keypress events - * so it has to be here to map to the correct keycodes for - * keyup/keydown events + * `event.key` (final character for the active layout) is the source of + * truth. Single printable characters are lowercased, `" "` becomes + * `"space"`, and named keys map through this table.modifiers pressed + * alone (`Shift`, `Control`, ...) normalize to `shift`, `ctrl`, `alt`, + * `meta` so bind-time and event-time tokens always match. * * @type {Object} */ -var _MAP = { - 8: "backspace", - 9: "tab", - 13: "enter", - 16: "shift", - 17: "ctrl", - 18: "alt", - 20: "capslock", - 27: "esc", - 32: "space", - 33: "pageup", - 34: "pagedown", - 35: "end", - 36: "home", - 37: "left", - 38: "up", - 39: "right", - 40: "down", - 45: "ins", - 46: "del", - 91: "meta", - 93: "meta", - 224: "meta", - 219: "219", +const _KEY_NAMES = { + " ": "space", + Spacebar: "space", + ArrowUp: "up", + Up: "up", + ArrowDown: "down", + Down: "down", + ArrowLeft: "left", + Left: "left", + ArrowRight: "right", + Right: "right", + Escape: "escape", + Esc: "escape", + Enter: "enter", + Backspace: "backspace", + Delete: "del", + Del: "del", + Insert: "ins", + Ins: "ins", + Tab: "tab", + Home: "home", + End: "end", + PageUp: "pageup", + PageDown: "pagedown", + CapsLock: "capslock", + Shift: "shift", + Control: "ctrl", + Alt: "alt", + Meta: "meta", }; /** - * mapping for special characters so they can support - * - * this dictionary is only used incase you want to bind a - * keyup or keydown event to one of these keys + * Aliases accepted in bind strings, all resolving to lowercase canonical + * tokens. `mod` follows the platform (Meta on Apple, Ctrl elsewhere). * * @type {Object} */ -var _KEYCODE_MAP = { - 106: "*", - 107: "+", - 109: "-", - 110: ".", - 111: "/", - 186: ";", - 187: "=", - 188: ",", - 189: "-", - 190: ".", - 191: "/", - 192: "`", - 219: "[", - 220: "\\", - 221: "]", - 222: "'", -}; +const globalNavigator = globalThis.navigator; -/** - * this is a mapping of keys that require shift on a US keypad - * back to the non shift equivelents - * - * this is so you can use keyup events with these keys - * - * note that this will only work reliably on US keyboards - * - * @type {Object} - */ -var _SHIFT_MAP = { - "~": "`", - "!": "1", - "@": "2", - "#": "3", - $: "4", - "%": "5", - "^": "6", - "&": "7", - "*": "8", - "(": "9", - ")": "0", - _: "-", - "+": "=", - ":": ";", - '\"': "'", - "<": ",", - ">": ".", - "?": "/", - "|": "\\", -}; - -/** - * this is a list of special strings you can use to map - * to modifier keys when you specify your keyboard shortcuts - * - * @type {Object} - */ - -var globalNavigator = globalThis.navigator; - -var _SPECIAL_ALIASES = { +const _SPECIAL_ALIASES = { + ctrl: "ctrl", + control: "ctrl", + alt: "alt", option: "alt", + shift: "shift", + meta: "meta", command: "meta", - return: "enter", - escape: "esc", - plus: "+", mod: /Mac|iPod|iPhone|iPad/.test(globalNavigator?.platform) ? "meta" : "ctrl", + return: "enter", + enter: "enter", + esc: "escape", + escape: "escape", + del: "del", + delete: "del", + ins: "ins", + insert: "ins", + space: "space", + plus: "+", }; /** - * variable to store the flipped version of _MAP from above - * needed to check if we should use keypress or not when no action - * is specified + * 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. * - * @type {Object|undefined} + * @type {Set} */ -var _REVERSE_MAP; +const _PUNCTUATION_CODES = new Set([ + "Minus", + "Equal", + "BracketLeft", + "BracketRight", + "Backslash", + "Semicolon", + "Quote", + "Backquote", + "Comma", + "Period", + "Slash", +]); /** - * loop through the f keys, f1 to f19 and add them to the map - * programatically + * Resolves an event to its canonical key. Implemented as + * `Mousetrap#_characterFromEvent` so punctuation can consult the + * instance layout map (see `setLayoutMap`). */ -for (var i = 1; i < 20; ++i) { - _MAP[111 + i] = "f" + i; -} - -/** - * loop through to map numbers on the numeric keypad - */ -for (i = 0; i <= 9; ++i) { - // This needs to use a string cause otherwise since 0 is falsey - // mousetrap will never fire for numpad 0 pressed as part of a keydown - // event. - // - // @see https://github.com/ccampbell/mousetrap/pull/258 - _MAP[i + 96] = i.toString(); -} - -/** - * cross browser add event method - * - * @param {Element|HTMLDocument} object - * @param {string} type - * @param {Function} callback - * @returns void - */ -function _addEvent(object, type, callback) { - if (object.addEventListener) { - object.addEventListener(type, callback, false); - return; - } - - object.attachEvent("on" + type, callback); -} - -/** - * true for the letter (A-Z) and main-row digit (0-9) keycodes - * - * @param {number} which - * @return {boolean} - */ -function _isLetterOrDigit(which) { - return (which >= 48 && which <= 57) || (which >= 65 && which <= 90); -} - -/** - * takes the event and returns the key character - * - * @param {Event} e - * @return {string} - */ -function _characterFromEvent(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) { - var suffix = e.code.substring(6); - if (suffix.length === 1 && suffix >= "0" && suffix <= "9") { - return "num" + suffix; - } - } - - // for keypress events we should return the character as is - if (e.type == "keypress") { - var character = String.fromCharCode(e.which); - - // if the shift key is not pressed then it is safe to assume - // that we want the character to be lowercase. this means if - // you accidentally have caps lock on then your key bindings - // will continue to work - // - // the only side effect that might not be desired is if you - // bind something like 'A' cause you want to trigger an - // event when capital A is pressed caps lock will no longer - // trigger the event. shift+a will though. - if (!e.shiftKey) { - character = character.toLowerCase(); - } - - return character; - } - - // for non keypress events the special maps are needed - if (_MAP[e.which]) { - return _MAP[e.which]; - } - - if (_KEYCODE_MAP[e.which]) { - return _KEYCODE_MAP[e.which]; - } - - // keydown bindings name the key, not the glyph it types under modifiers - // ("fi" for Option+Shift+5, ")" for Shift+0) - if (_isLetterOrDigit(e.which)) { - return String.fromCharCode(e.which).toLowerCase(); - } - - // if it is not in the special map - if (typeof e.key === "string") { - return e.key.toLowerCase(); - } - - // with keydown and keyup events the character seems to always - // come in as an uppercase character whether you are pressing shift - // or not. we should make sure it is always lowercase for comparisons - return String.fromCharCode(e.which).toLowerCase(); -} /** * checks if two arrays are equal @@ -263,11 +133,18 @@ function _modifiersMatch(modifiers1, modifiers2) { /** * 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. + * * @param {Event} e * @returns {Array} */ function _eventModifiers(e) { - var modifiers = []; + const modifiers = []; + const altGraph = + typeof e.getModifierState === "function" && + e.getModifierState("AltGraph"); if (e.shiftKey) { modifiers.push("shift"); @@ -277,7 +154,7 @@ function _eventModifiers(e) { modifiers.push("alt"); } - if (e.ctrlKey) { + if (e.ctrlKey && !altGraph) { modifiers.push("ctrl"); } @@ -289,92 +166,15 @@ function _eventModifiers(e) { } /** - * prevents default for this event + * determines if the key is a modifier key or not * - * @param {Event} e - * @returns void - */ -function _preventDefault(e) { - if (e.preventDefault) { - e.preventDefault(); - return; - } - - e.returnValue = false; -} - -/** - * stops propogation for this event - * - * @param {Event} e - * @returns void - */ -function _stopPropagation(e) { - if (e.stopPropagation) { - e.stopPropagation(); - return; - } - - e.cancelBubble = true; -} - -/** - * determines if the keycode specified is a modifier key or not - * - * @param {string} key + * @param {string} key canonical lowercase key * @returns {boolean} */ function _isModifier(key) { return key == "shift" || key == "ctrl" || key == "alt" || key == "meta"; } -/** - * reverses the map lookup so that we can look for specific keys - * to see what can and can't use keypress - * - * @return {Object} - */ -function _getReverseMap() { - if (!_REVERSE_MAP) { - _REVERSE_MAP = {}; - for (var key in _MAP) { - // pull out the numeric keypad from here cause keypress should - // be able to detect the keys from the character - if (key > 95 && key < 112) { - continue; - } - - if (_MAP.hasOwnProperty(key)) { - _REVERSE_MAP[_MAP[key]] = key; - } - } - } - return _REVERSE_MAP; -} - -/** - * picks the best action based on the key combination - * - * @param {string} key - character for key - * @param {Array} modifiers - * @param {string=} action passed in - */ -function _pickBestAction(key, modifiers, action) { - // if no action was picked in we should try to pick the one - // that we think would work best for this key - if (!action) { - action = _getReverseMap()[key] ? "keydown" : "keypress"; - } - - // modifier keys don't work as expected with keypress, - // switch to keydown - if (action == "keypress" && modifiers.length) { - action = "keydown"; - } - - return action; -} - /** * Converts from a string key combination to an array * @@ -398,29 +198,24 @@ function _keysFromString(combination) { * @returns {Object} */ function _getKeyInfo(combination, action) { - var keys; - var key; - var i; - var modifiers = []; + let keys; + let key; + const modifiers = []; // take the keys from this pattern and figure out what the actual // pattern is all about keys = _keysFromString(combination); - for (i = 0; i < keys.length; ++i) { + for (let i = 0; i < keys.length; ++i) { key = keys[i]; - // normalize key names + // normalize key names to lowercase canonical tokens if (_SPECIAL_ALIASES[key]) { key = _SPECIAL_ALIASES[key]; - } - - // if this is not a keypress event then we should - // be smart about using shift keys - // this will only work for US keyboards however - if (action && action != "keypress" && _SHIFT_MAP[key]) { - key = _SHIFT_MAP[key]; - modifiers.push("shift"); + } else if (key.length === 1) { + key = key.toLowerCase(); + } else { + key = (_KEY_NAMES[key] || key).toLowerCase(); } // if this key is a modifier then add it to the list of modifiers @@ -429,9 +224,10 @@ function _getKeyInfo(combination, action) { } } - // depending on what the key combination is - // we will try to pick the best event for it - action = _pickBestAction(key, modifiers, action); + // everything runs on keydown unless the caller asked for keyup + if (!action) { + action = "keydown"; + } return { key: key, @@ -440,240 +236,297 @@ function _getKeyInfo(combination, action) { }; } -function _belongsTo(element, ancestor) { - if (element === null || element === globalDocument) { - return false; - } +class Mousetrap { + constructor(targetElement = globalDocument) { + /** + * a list of all the callbacks setup via Mousetrap.bind() + * + * @type {Object} + */ + this._callbacks = {}; - if (element === ancestor) { - return true; - } + /** + * direct map of string combinations to callbacks used for trigger() + * + * @type {Object} + */ + this._directMap = {}; - return _belongsTo(element.parentNode, ancestor); -} + /** + * keeps track of what level each sequence is at since multiple + * sequences can start out with the same sequence + * + * @type {Object} + */ + this._sequenceLevels = {}; -function Mousetrap(targetElement) { - var self = this; + /** + * variable to store the setTimeout call + * + * @type {null|number} + */ + this._resetTimer = undefined; - targetElement = targetElement || globalDocument; + /** + * temporary state where we will ignore the next keyup + * + * @type {boolean|string} + */ + this._ignoreNextKeyup = false; - /** - * element to attach key events to - * - * @type {Element} - */ - self.target = targetElement; + /** + * are we currently inside of a sequence? + * type of action ("keyup" or "keydown") or false + * + * @type {boolean|string} + */ + this._nextExpectedAction = false; - /** - * a list of all the callbacks setup via Mousetrap.bind() - * - * @type {Object} - */ - self._callbacks = {}; + /** + * layout map for positional punctuation resolution, as returned by + * `navigator.keyboard.getLayoutMap()` (an object with `.get(code)`). + * `null` means glyph fallback. Set via `setLayoutMap`. + * + * @type {Object|null} + */ + this._layoutMap = null; - /** - * direct map of string combinations to callbacks used for trigger() - * - * @type {Object} - */ - self._directMap = {}; - - /** - * keeps track of what level each sequence is at since multiple - * sequences can start out with the same sequence - * - * @type {Object} - */ - var _sequenceLevels = {}; - - /** - * variable to store the setTimeout call - * - * @type {null|number} - */ - var _resetTimer; - - /** - * temporary state where we will ignore the next keyup - * - * @type {boolean|string} - */ - var _ignoreNextKeyup = false; - - /** - * temporary state where we will ignore the next keypress - * - * @type {boolean} - */ - var _ignoreNextKeypress = false; - - /** - * are we currently inside of a sequence? - * type of action ("keyup" or "keydown" or "keypress") or false - * - * @type {boolean|string} - */ - var _nextExpectedAction = false; - - /** - * resets all sequence counters except for the ones passed in - * - * @param {Object} doNotReset - * @returns void - */ - function _resetSequences(doNotReset) { - doNotReset = doNotReset || {}; - - var activeSequences = false, - key; - - for (key in _sequenceLevels) { - if (doNotReset[key]) { - activeSequences = true; - continue; - } - _sequenceLevels[key] = 0; - } - - if (!activeSequences) { - _nextExpectedAction = false; + if (targetElement) { + targetElement.addEventListener("keydown", (e) => this._handleKeyEvent(e)); + targetElement.addEventListener("keyup", (e) => this._handleKeyEvent(e)); } } /** - * finds all callbacks that match based on the keycode, modifiers, - * and action + * binds an event to mousetrap * - * @param {string} character - * @param {Array} modifiers - * @param {Event|Object} e - * @param {string=} sequenceName - name of the sequence we are looking for - * @param {string=} combination - * @param {number=} level - * @returns {Array} - */ - function _getMatches( - character, - modifiers, - e, - sequenceName, - combination, - level, - ) { - var i; - var callback; - var matches = []; - var action = e.type; - - // if there are no events related to this keycode - if (!self._callbacks[character]) { - return []; - } - - // if a modifier key is coming up on its own we should allow it - if (action == "keyup" && _isModifier(character)) { - modifiers = [character]; - } - - // loop through all callbacks for the key that was pressed - // and see if any of them match - for (i = 0; i < self._callbacks[character].length; ++i) { - callback = self._callbacks[character][i]; - - // if a sequence name is not specified, but this is a sequence at - // the wrong level then move onto the next match - if ( - !sequenceName && - callback.seq && - _sequenceLevels[callback.seq] != callback.level - ) { - continue; - } - - // if the action we are looking for doesn't match the action we got - // then we should keep going - if (action != callback.action) { - continue; - } - - // if this is a keypress event and the meta key and control key - // are not pressed that means that we need to only look at the - // character, otherwise check the modifiers as well - // - // chrome will not fire a keypress if meta or control is down - // safari will fire a keypress if meta or meta+shift is down - // firefox will fire a keypress if meta or control is down - if ( - (action == "keypress" && !e.metaKey && !e.ctrlKey) || - _modifiersMatch(modifiers, callback.modifiers) - ) { - // when you bind a combination or sequence a second time it - // should overwrite the first one. if a sequenceName or - // combination is specified in this call it does just that - // - // @todo make deleting its own method? - var deleteCombo = !sequenceName && callback.combo == combination; - var deleteSequence = - sequenceName && - callback.seq == sequenceName && - callback.level == level; - if (deleteCombo || deleteSequence) { - self._callbacks[character].splice(i, 1); - } - - matches.push(callback); - } - } - - return matches; - } - - /** - * actually calls the callback function + * can be a single key, a combination of keys separated with +, + * an array of keys, or a sequence of keys separated by spaces * - * if your callback function returns false this will use the jquery - * convention - prevent default and stop propogation on the event + * be sure to list the modifier keys first to make sure that the + * correct key ends up getting bound (the last key in the pattern) * + * @param {string|Array} keys * @param {Function} callback - * @param {Event} e + * @param {string=} action - 'keydown' or 'keyup' (defaults to 'keydown') * @returns void */ - function _fireCallback(callback, e, combo, sequence) { - // if this event should not happen stop here - if (self.stopCallback(e, e.target || e.srcElement, combo, sequence)) { - return; + bind(keys, callback, action, overwrite) { + keys = keys instanceof Array ? keys : [keys]; + this._bindMultiple(keys, callback, action, overwrite); + return this; + } + + /** + * unbinds an event to mousetrap + * + * the unbinding sets the callback function of the specified key combo + * to an empty function and deletes the corresponding key in the + * _directMap dict. + * + * TODO: actually remove this from the _callbacks dictionary instead + * of binding an empty function + * + * the keycombo+action has to be exactly the same as + * it was defined in the bind method + * + * @param {string|Array} keys + * @param {string} action + * @returns void + */ + unbind(keys, action) { + return this.bind(keys, function () {}, action); + } + + /** + * triggers an event that has already been bound + * + * @param {string} keys + * @param {string=} action + * @returns void + */ + trigger(keys, action) { + if (this._directMap[keys + ":" + action]) { + this._directMap[keys + ":" + action]({}, keys); + } + return this; + } + + /** + * resets the library back to its initial state. this is useful + * if you want to clear out the current keyboard shortcuts and bind + * new ones - for example if you switch to another page + * + * @returns void + */ + reset() { + this._callbacks = {}; + this._directMap = {}; + return this; + } + + /** + * sets the keyboard layout map used for positional punctuation + * resolution (the same map the shortcut recorder uses). + * + * Pass the `KeyboardLayoutMap` from + * `navigator.keyboard.getLayoutMap()`, or `null` to go back to the + * glyph fallback. Read live at dispatch time, so call it whenever + * the map resolves without rebinding. + * + * @param {Object|null} map - object with `.get(code)`, or null + * @returns void + */ + setLayoutMap(map) { + this._layoutMap = map; + return this; + } + + /** + * takes the event and returns the canonical key + * + * 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. + * + * @param {Event} e + * @return {string|null} + */ + _characterFromEvent(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); + if (suffix.length === 1 && suffix >= "0" && suffix <= "9") { + return "num" + suffix; + } } - if (callback(e, combo) === false) { - _preventDefault(e); - _stopPropagation(e); + // 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]; + } + + if (key.length === 1) { + return key.toLowerCase(); + } + + if (/^F\d{1,2}$/.test(key)) { + return key.toLowerCase(); + } + + if (key === "Dead") { + return null; + } + + return key.toLowerCase(); + } + + /** + * should we stop this event before firing off callbacks + * + * @param {Event} e + * @param {Element} element + * @return {boolean} + */ + stopCallback(e, element, combo) { + // if the element has the data attribute "mousetrap-dont-stop" then no need + // to stop. It should be used like
    ...
    + // or :div {:data-mousetrap-dont-stop true} + if ("mousetrapDontStop" in element.dataset) { + return false; + } + + if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) { + return false; + } + + // Keyup events need to be dispatched always. Otherwise some events can be stuck + if (e.type == "keyup") { + return false; + } + + if ("composedPath" in e && typeof e.composedPath === "function") { + // For open shadow trees, update `element` so that the following check works. + const initialEventTarget = e.composedPath()[0]; + if (initialEventTarget !== e.target) { + element = initialEventTarget; + } + } + + // stop for input, select, textarea and button + const shouldStop = + element.tagName == "INPUT" || + element.tagName == "SELECT" || + element.tagName == "TEXTAREA" || + (element.tagName == "BUTTON" && + typeof combo === "string" && + combo.includes("tab")) || + (element.contentEditable && + (element.contentEditable == "true" || + element.contentEditable === "plaintext-only")); + + return shouldStop; } /** * handles a character key event * + * exposed publicly so it can be overwritten by extensions + * * @param {string} character * @param {Array} modifiers * @param {Event} e * @returns void */ - self._handleKey = function (character, modifiers, e) { - var callbacks = _getMatches(character, modifiers, e); - var i; - var doNotReset = {}; - var maxLevel = 0; - var processedSequenceCallback = false; + handleKey(character, modifiers, e) { + const callbacks = this._getMatches(character, modifiers, e); + const doNotReset = {}; + let maxLevel = 0; + let processedSequenceCallback = false; // Calculate the maxLevel for sequences so we can only execute the longest callback sequence - for (i = 0; i < callbacks.length; ++i) { + for (let i = 0; i < callbacks.length; ++i) { if (callbacks[i].seq) { maxLevel = Math.max(maxLevel, callbacks[i].level); } } // loop through matching callbacks for this key event - for (i = 0; i < callbacks.length; ++i) { + for (let i = 0; i < callbacks.length; ++i) { // fire for all sequence callbacks // this is because if for example you have multiple sequences // bound such as "g i" and "g t" they both need to fire the @@ -696,7 +549,7 @@ function Mousetrap(targetElement) { // keep a list of which sequences were matches for later doNotReset[callbacks[i].seq] = 1; - _fireCallback( + this._fireCallback( callbacks[i].callback, e, callbacks[i].combo, @@ -708,12 +561,12 @@ function Mousetrap(targetElement) { // if there were no sequence matches but we are still here // that means this is a regular match so we should fire that if (!processedSequenceCallback) { - _fireCallback(callbacks[i].callback, e, callbacks[i].combo); + this._fireCallback(callbacks[i].callback, e, callbacks[i].combo); } } // if the key you pressed matches the type of sequence without - // being a modifier (ie "keyup" or "keypress") then we should + // being a modifier (ie "keyup" or "keydown") then we should // reset all sequences that were not matched by this event // // this is so, for example, if you have the sequence "h a t" and you @@ -723,136 +576,29 @@ function Mousetrap(targetElement) { // modifier keys are ignored because you can have a sequence // that contains modifiers such as "enter ctrl+space" and in most // cases the modifier key will be pressed before the next key - // - // also if you have a sequence such as "ctrl+b a" then pressing the - // "b" key will trigger a "keypress" and a "keydown" - // - // the "keydown" is expected when there is a modifier, but the - // "keypress" ends up matching the _nextExpectedAction since it occurs - // after and that causes the sequence to reset - // - // we ignore keypresses in a sequence that directly follow a keydown - // for the same character - var ignoreThisKeypress = e.type == "keypress" && _ignoreNextKeypress; - if ( - e.type == _nextExpectedAction && - !_isModifier(character) && - !ignoreThisKeypress - ) { - _resetSequences(doNotReset); + if (e.type == this._nextExpectedAction && !_isModifier(character)) { + this._resetSequences(doNotReset); } - - _ignoreNextKeypress = processedSequenceCallback && e.type == "keydown"; - }; - - /** - * handles a keydown event - * - * @param {Event} e - * @returns void - */ - function _handleKeyEvent(e) { - // normalize e.which for key events - // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion - if (typeof e.which !== "number") { - e.which = e.keyCode; - } - - var character = _characterFromEvent(e); - - // no character found then stop - if (!character) { - return; - } - - // need to use === for the character check because the character can be 0 - if (e.type == "keyup" && _ignoreNextKeyup === character) { - _ignoreNextKeyup = false; - return; - } - - self.handleKey(character, _eventModifiers(e), e); } /** - * called to set a 1 second timeout on the specified sequence + * binds multiple combinations to the same callback * - * this is so after each key press in the sequence you have 1 second - * to press the next key before you have to start over - * - * @returns void - */ - function _resetSequenceTimer() { - clearTimeout(_resetTimer); - _resetTimer = setTimeout(_resetSequences, 1000); - } - - /** - * binds a key sequence to an event - * - * @param {string} combo - combo specified in bind call - * @param {Array} keys + * @param {Array} combinations * @param {Function} callback - * @param {string=} action + * @param {string|undefined} action * @returns void */ - function _bindSequence(combo, keys, callback, action) { - // start off by adding a sequence level record for this combination - // and setting the level to 0 - _sequenceLevels[combo] = 0; - - /** - * callback to increase the sequence level for this sequence and reset - * all other sequences that were active - * - * @param {string} nextAction - * @returns {Function} - */ - function _increaseSequence(nextAction) { - return function () { - _nextExpectedAction = nextAction; - ++_sequenceLevels[combo]; - _resetSequenceTimer(); - }; - } - - /** - * wraps the specified callback inside of another function in order - * to reset all sequence counters as soon as this sequence is done - * - * @param {Event} e - * @returns void - */ - function _callbackAndReset(e) { - _fireCallback(callback, e, combo); - - // we should ignore the next key up if the action is key down - // or keypress. this is so if you finish a sequence and - // release the key the final key will not trigger a keyup - if (action !== "keyup") { - _ignoreNextKeyup = _characterFromEvent(e); - } - - // weird race condition if a sequence ends with the key - // another sequence begins with - setTimeout(_resetSequences, 10); - } - - // loop through keys one at a time and bind the appropriate callback - // function. for any key leading up to the final one it should - // increase the sequence. after the final, it should reset all sequences - // - // if an action is specified in the original bind call then that will - // be used throughout. otherwise we will pass the action that the - // next key in the sequence should match. this allows a sequence - // to mix and match keypress and keydown events depending on which - // ones are better suited to the key provided - for (var i = 0; i < keys.length; ++i) { - var isFinal = i + 1 === keys.length; - var wrappedCallback = isFinal - ? _callbackAndReset - : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); - _bindSingle(keys[i], wrappedCallback, action, combo, i); + _bindMultiple(combinations, callback, action, overwrite) { + for (let i = 0; i < combinations.length; ++i) { + this._bindSingle( + combinations[i], + callback, + action, + undefined, + undefined, + overwrite, + ); } } @@ -866,39 +612,31 @@ function Mousetrap(targetElement) { * @param {number=} level - what part of the sequence the command is * @returns void */ - function _bindSingle( - combination, - callback, - action, - sequenceName, - level, - overwrite, - ) { + _bindSingle(combination, callback, action, sequenceName, level, overwrite) { // store a direct mapped reference for use with Mousetrap.trigger - self._directMap[combination + ":" + action] = callback; + this._directMap[combination + ":" + action] = callback; // make sure multiple spaces in a row become a single space combination = combination.replace(/\s+/g, " "); - var sequence = combination.split(" "); - var info; + const sequence = combination.split(" "); // if this pattern is a sequence of keys then run through this method // to reprocess each pattern one key at a time if (sequence.length > 1) { - _bindSequence(combination, sequence, callback, action); + this._bindSequence(combination, sequence, callback, action); return; } - info = _getKeyInfo(combination, action); + const info = _getKeyInfo(combination, action); // make sure to initialize array if this is the first time // a callback is added for this key - self._callbacks[info.key] = self._callbacks[info.key] || []; + this._callbacks[info.key] = this._callbacks[info.key] || []; // remove an existing match if there is one if (overwrite) { - _getMatches( + this._getMatches( info.key, info.modifiers, { type: info.action }, @@ -914,7 +652,7 @@ function Mousetrap(targetElement) { // // this is important because the way these are processed expects // the sequence ones to come first - self._callbacks[info.key][sequenceName ? "unshift" : "push"]({ + this._callbacks[info.key][sequenceName ? "unshift" : "push"]({ callback: callback, modifiers: info.modifiers, action: info.action, @@ -925,168 +663,218 @@ function Mousetrap(targetElement) { } /** - * binds multiple combinations to the same callback + * binds a key sequence to an event * - * @param {Array} combinations + * @param {string} combo - combo specified in bind call + * @param {Array} keys * @param {Function} callback - * @param {string|undefined} action + * @param {string=} action * @returns void */ - self._bindMultiple = function (combinations, callback, action, overwrite) { - for (var i = 0; i < combinations.length; ++i) { - _bindSingle( - combinations[i], - callback, - action, - undefined, - undefined, - overwrite, - ); - } - }; + _bindSequence(combo, keys, callback, action) { + // start off by adding a sequence level record for this combination + // and setting the level to 0 + this._sequenceLevels[combo] = 0; - if (targetElement) { - _addEvent(targetElement, "keypress", _handleKeyEvent); - _addEvent(targetElement, "keydown", _handleKeyEvent); - _addEvent(targetElement, "keyup", _handleKeyEvent); - } -} + /** + * callback to increase the sequence level for this sequence and reset + * all other sequences that were active + * + * @param {string} nextAction + * @returns {Function} + */ + const _increaseSequence = (nextAction) => () => { + this._nextExpectedAction = nextAction; + ++this._sequenceLevels[combo]; + this._resetSequenceTimer(); + }; -/** - * binds an event to mousetrap - * - * can be a single key, a combination of keys separated with +, - * an array of keys, or a sequence of keys separated by spaces - * - * be sure to list the modifier keys first to make sure that the - * correct key ends up getting bound (the last key in the pattern) - * - * @param {string|Array} keys - * @param {Function} callback - * @param {string=} action - 'keypress', 'keydown', or 'keyup' - * @returns void - */ -Mousetrap.prototype.bind = function (keys, callback, action, overwrite) { - var self = this; - keys = keys instanceof Array ? keys : [keys]; - self._bindMultiple.call(self, keys, callback, action, overwrite); - return self; -}; + /** + * wraps the specified callback inside of another function in order + * to reset all sequence counters as soon as this sequence is done + * + * @param {Event} e + * @returns void + */ + const _callbackAndReset = (e) => { + this._fireCallback(callback, e, combo); -/** - * unbinds an event to mousetrap - * - * the unbinding sets the callback function of the specified key combo - * to an empty function and deletes the corresponding key in the - * _directMap dict. - * - * TODO: actually remove this from the _callbacks dictionary instead - * of binding an empty function - * - * the keycombo+action has to be exactly the same as - * it was defined in the bind method - * - * @param {string|Array} keys - * @param {string} action - * @returns void - */ -Mousetrap.prototype.unbind = function (keys, action) { - var self = this; - return self.bind.call(self, keys, function () {}, action); -}; + // we should ignore the next key up if the action is keydown. + // this is so if you finish a sequence and + // release the key the final key will not trigger a keyup + if (action !== "keyup") { + this._ignoreNextKeyup = this._characterFromEvent(e); + } -/** - * triggers an event that has already been bound - * - * @param {string} keys - * @param {string=} action - * @returns void - */ -Mousetrap.prototype.trigger = function (keys, action) { - var self = this; - if (self._directMap[keys + ":" + action]) { - self._directMap[keys + ":" + action]({}, keys); - } - return self; -}; + // weird race condition if a sequence ends with the key + // another sequence begins with + setTimeout(() => this._resetSequences(), 10); + }; -/** - * resets the library back to its initial state. this is useful - * if you want to clear out the current keyboard shortcuts and bind - * new ones - for example if you switch to another page - * - * @returns void - */ -Mousetrap.prototype.reset = function () { - var self = this; - self._callbacks = {}; - self._directMap = {}; - return self; -}; - -/** - * should we stop this event before firing off callbacks - * - * @param {Event} e - * @param {Element} element - * @return {boolean} - */ -Mousetrap.prototype.stopCallback = function (e, element, combo) { - // if the element has the data attribute "mousetrap-dont-stop" then no need - // to stop. It should be used like
    ...
    - // or :div {:data-mousetrap-dont-stop true} - if ("mousetrapDontStop" in element.dataset) { - return false; - } - - if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) { - return false; - } - - // Keyup events need to be dispatched always. Otherwise some events can be stuck - if (e.type == "keyup") { - return false; - } - - if ("composedPath" in e && typeof e.composedPath === "function") { - // For open shadow trees, update `element` so that the following check works. - const initialEventTarget = e.composedPath()[0]; - if (initialEventTarget !== e.target) { - element = initialEventTarget; + // loop through keys one at a time and bind the appropriate callback + // function. for any key leading up to the final one it should + // increase the sequence. after the final, it should reset all sequences + // + // if an action is specified in the original bind call then that will + // be used throughout. otherwise we will pass the action that the + // next key in the sequence should match. + for (let i = 0; i < keys.length; ++i) { + const isFinal = i + 1 === keys.length; + const wrappedCallback = isFinal + ? _callbackAndReset + : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); + this._bindSingle(keys[i], wrappedCallback, action, combo, i); } } - // stop for input, select, textarea and button - const shouldStop = - element.tagName == "INPUT" || - element.tagName == "SELECT" || - element.tagName == "TEXTAREA" || - (element.tagName == "BUTTON" && combo.includes("tab")) || - (element.contentEditable && - (element.contentEditable == "true" || - element.contentEditable === "plaintext-only")); + /** + * resets all sequence counters except for the ones passed in + * + * @param {Object} doNotReset + * @returns void + */ + _resetSequences(doNotReset) { + doNotReset = doNotReset || {}; - return shouldStop; -}; + let activeSequences = false; -/** - * exposes _handleKey publicly so it can be overwritten by extensions - */ -Mousetrap.prototype.handleKey = function () { - var self = this; - return self._handleKey.apply(self, arguments); -}; + for (const key in this._sequenceLevels) { + if (doNotReset[key]) { + activeSequences = true; + continue; + } + this._sequenceLevels[key] = 0; + } -/** - * allow custom key mappings - */ -export function addKeycodes(object) { - for (var key in object) { - if (object.hasOwnProperty(key)) { - _MAP[key] = object[key]; + if (!activeSequences) { + this._nextExpectedAction = false; } } - _REVERSE_MAP = null; + + /** + * called to set a 1 second timeout on the specified sequence + * + * this is so after each key press in the sequence you have 1 second + * to press the next key before you have to start over + * + * @returns void + */ + _resetSequenceTimer() { + clearTimeout(this._resetTimer); + this._resetTimer = setTimeout(() => this._resetSequences(), 1000); + } + + /** + * finds all callbacks that match based on the key, modifiers, + * and action + * + * @param {string} character canonical lowercase key + * @param {Array} modifiers + * @param {Event|Object} e + * @param {string=} sequenceName - name of the sequence we are looking for + * @param {string=} combination + * @param {number=} level + * @returns {Array} + */ + _getMatches(character, modifiers, e, sequenceName, combination, level) { + const matches = []; + const action = e.type; + + // if there are no events related to this key + if (!this._callbacks[character]) { + return []; + } + + // if a modifier key is coming up on its own we should allow it + if (action == "keyup" && _isModifier(character)) { + modifiers = [character]; + } + + // loop through all callbacks for the key that was pressed + // and see if any of them match + for (let i = 0; i < this._callbacks[character].length; ++i) { + const callback = this._callbacks[character][i]; + + // if a sequence name is not specified, but this is a sequence at + // the wrong level then move onto the next match + if ( + !sequenceName && + callback.seq && + this._sequenceLevels[callback.seq] != callback.level + ) { + continue; + } + + // if the action we are looking for doesn't match the action we got + // then we should keep going + if (action != callback.action) { + continue; + } + + if (_modifiersMatch(modifiers, callback.modifiers)) { + // when you bind a combination or sequence a second time it + // should overwrite the first one. if a sequenceName or + // combination is specified in this call it does just that + // + // @todo make deleting its own method? + const deleteCombo = !sequenceName && callback.combo == combination; + const deleteSequence = + sequenceName && + callback.seq == sequenceName && + callback.level == level; + if (deleteCombo || deleteSequence) { + this._callbacks[character].splice(i, 1); + } + + matches.push(callback); + } + } + + return matches; + } + + /** + * actually calls the callback function + * + * if your callback function returns false this will use the jquery + * convention - prevent default and stop propogation on the event + * + * @param {Function} callback + * @param {Event} e + * @returns void + */ + _fireCallback(callback, e, combo, sequence) { + // if this event should not happen stop here + if (this.stopCallback(e, e.target || e.srcElement, combo, sequence)) { + return; + } + + if (callback(e, combo) === false) { + e.preventDefault(); + e.stopPropagation(); + } + } + + /** + * handles a key event + * + * @param {Event} e + * @returns void + */ + _handleKeyEvent(e) { + const character = this._characterFromEvent(e); + // no character found then stop + if (!character) { + return; + } + + if (e.type == "keyup" && this._ignoreNextKeyup === character) { + this._ignoreNextKeyup = false; + return; + } + + this.handleKey(character, _eventModifiers(e), e); + } } /** @@ -1095,6 +883,8 @@ export function addKeycodes(object) { * This method is needed to allow the global mousetrap functions to work * now that mousetrap is a constructor function. */ -var instance = new Mousetrap(globalDocument); +const instance = new Mousetrap(globalDocument); + +export { Mousetrap }; export default instance; diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index d5c2a52548..103baefbe2 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -4,6 +4,9 @@ "description": "Simple library for handling keyboard shortcuts", "main": "index.js", "type": "module", + "scripts": { + "test": "node --test \"test/*.test.js\"" + }, "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" diff --git a/frontend/packages/mousetrap/test/bind-dispatch.test.js b/frontend/packages/mousetrap/test/bind-dispatch.test.js new file mode 100644 index 0000000000..7512151126 --- /dev/null +++ b/frontend/packages/mousetrap/test/bind-dispatch.test.js @@ -0,0 +1,183 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent, makeSpiedEvent } from "./helpers.js"; + +describe("bind and dispatch", () => { + it("binds a single key", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("m", () => { + calls++; + }); + fire(target, makeKeyEvent({ key: "m", code: "KeyM" })); + assert.equal(calls, 1); + }); + + it("binds an array of combos to the same callback", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + const cb = () => { + calls++; + }; + mousetrap.bind(["del", "backspace"], cb); + fire(target, makeKeyEvent({ key: "Delete", code: "Delete" })); + fire(target, makeKeyEvent({ key: "Backspace", code: "Backspace" })); + assert.equal(calls, 2); + }); + + it("supports the plus forms", () => { + const { mousetrap, target } = freshInstance(); + let plus = 0; + let shiftPlus = 0; + mousetrap.bind("+", () => { + plus++; + }); + mousetrap.bind("shift++", () => { + shiftPlus++; + }); + fire(target, makeKeyEvent({ key: "+", code: "Equal" })); + 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); + }); + + it("resolves bind-string aliases to canonical tokens", () => { + const cases = [ + ["control+z", { key: "z", code: "KeyZ", ctrlKey: true }], + ["command+z", { key: "z", code: "KeyZ", metaKey: true }], + ["option+q", { key: "q", code: "KeyQ", altKey: true }], + ["mod+z", { key: "z", code: "KeyZ", ctrlKey: true }], + ["return", { key: "Enter", code: "Enter" }], + ["esc", { key: "Escape", code: "Escape" }], + ["plus", { key: "+", code: "Equal" }], + ]; + for (const [combo, eventOver] of cases) { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind(combo, () => { + calls++; + }); + fire(target, makeKeyEvent(eventOver)); + assert.equal(calls, 1, combo); + } + }); + + it("defaults to keydown and honors explicit keyup", () => { + const { mousetrap, target } = freshInstance(); + let downs = 0; + let ups = 0; + mousetrap.bind("x", () => { + downs++; + }); + mousetrap.bind( + "y", + () => { + ups++; + }, + "keyup", + ); + fire(target, makeKeyEvent({ key: "x", code: "KeyX", type: "keyup" })); + fire(target, makeKeyEvent({ key: "y", code: "KeyY" })); + fire(target, makeKeyEvent({ key: "y", code: "KeyY", type: "keyup" })); + assert.equal(downs, 0); + assert.equal(ups, 1); + }); + + it("fires a bare modifier on keyup", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind( + "shift", + () => { + calls++; + }, + "keyup", + ); + fire(target, makeKeyEvent({ key: "Shift", code: "ShiftLeft", type: "keyup", shiftKey: true })); + assert.equal(calls, 1); + }); + + it("calls preventDefault and stopPropagation when the callback returns false", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.bind("ctrl+s", () => false); + const { event, calls } = makeSpiedEvent({ key: "s", code: "KeyS", ctrlKey: true }); + fire(target, event); + assert.equal(calls.preventDefault, 1); + assert.equal(calls.stopPropagation, 1); + }); + + it("reset() clears every binding", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("a", () => { + calls++; + }); + mousetrap.reset(); + fire(target, makeKeyEvent({ key: "a", code: "KeyA" })); + assert.equal(calls, 0); + }); + + it("overwrite replaces the previous callback for the combo", () => { + const { mousetrap, target } = freshInstance(); + const seen = []; + mousetrap.bind("ctrl+z", () => { + seen.push("old"); + }); + mousetrap.bind( + "ctrl+z", + () => { + seen.push("new"); + }, + undefined, + true, + ); + fire(target, makeKeyEvent({ key: "z", code: "KeyZ", ctrlKey: true })); + assert.deepEqual(seen, ["new"]); + }); + + it("unbind pins the current noop behavior (TODO: really remove)", () => { + // NOTE: unbind() binds an empty function on top instead of removing + // the original, so the original still fires. Pinned as-is; fixing it + // is a behavior change out of scope. + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("a", () => { + calls++; + }); + mousetrap.unbind("a"); + fire(target, makeKeyEvent({ key: "a", code: "KeyA" })); + assert.equal(calls, 1); + }); + + it("trigger dispatches a bind registered with an explicit action", () => { + const { mousetrap } = freshInstance(); + let calls = 0; + let seenCombo = null; + mousetrap.bind( + "ctrl+q", + (e, combo) => { + calls++; + seenCombo = combo; + }, + "keydown", + ); + mousetrap.trigger("ctrl+q", "keydown"); + assert.equal(calls, 1); + assert.equal(seenCombo, "ctrl+q"); + }); + + it("trigger documents the default-action quirk (TODO)", () => { + // NOTE: binds without an explicit action are stored under + // "combo:undefined", so trigger(combo, "keydown") misses them. + // Pinned as-is; fixing it is a behavior change out of scope. + const { mousetrap } = freshInstance(); + let calls = 0; + mousetrap.bind("ctrl+q", () => { + calls++; + }); + mousetrap.trigger("ctrl+q", "keydown"); + assert.equal(calls, 0); + }); +}); diff --git a/frontend/packages/mousetrap/test/helpers.js b/frontend/packages/mousetrap/test/helpers.js new file mode 100644 index 0000000000..d6061984a1 --- /dev/null +++ b/frontend/packages/mousetrap/test/helpers.js @@ -0,0 +1,89 @@ +import { Mousetrap } from "../index.js"; + +/** + * Fake event target capturing listeners. The Mousetrap class only needs + * `addEventListener`, so tests run in plain Node without a DOM. + */ +export function makeTarget() { + const listeners = {}; + const target = { + listeners, + addEventListener(type, callback) { + listeners[type] = listeners[type] || []; + listeners[type].push(callback); + }, + }; + return target; +} + +/** + * Fake focused element for `stopCallback`. Covers every property the + * implementation reads: dataset, className, tagName, contentEditable. + */ +export function makeElement(over = {}) { + return { + dataset: {}, + className: "", + tagName: "DIV", + contentEditable: "false", + ...over, + }; +} + +/** + * Synthetic keyboard event with sane defaults. Override any field per + * case (`key`, `code`, `shiftKey`, ...). The `target` defaults to a plain + * DIV so `stopCallback` lets the event through. + */ +export function makeKeyEvent(over = {}) { + return { + type: "keydown", + key: "a", + code: "KeyA", + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + target: makeElement(), + ...over, + }; +} + +/** + * Dispatches a synthetic event through every listener registered for + * its type on a fake target. + */ +export function fire(target, event) { + for (const callback of target.listeners[event.type] || []) { + callback(event); + } +} + +/** + * Event spies recording `preventDefault`/`stopPropagation` calls for the + * `callback → false` convention. + */ +export function makeSpiedEvent(over = {}) { + const calls = { preventDefault: 0, stopPropagation: 0 }; + const event = makeKeyEvent({ + preventDefault() { + calls.preventDefault++; + }, + stopPropagation() { + calls.stopPropagation++; + }, + ...over, + }); + return { event, calls }; +} + +/** + * Fresh isolated instance plus its fake target. Timers used by sequences + * are real by default; tests needing determinism enable `mock.timers` + * themselves and restore it afterwards. + */ +export function freshInstance() { + const target = makeTarget(); + const mousetrap = new Mousetrap(target); + return { mousetrap, target }; +} diff --git a/frontend/packages/mousetrap/test/layout-map.test.js b/frontend/packages/mousetrap/test/layout-map.test.js new file mode 100644 index 0000000000..3cb34eb722 --- /dev/null +++ b/frontend/packages/mousetrap/test/layout-map.test.js @@ -0,0 +1,95 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent } from "./helpers.js"; + +function firedWithMap(map, bindStr, eventOver) { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(map); + let calls = 0; + mousetrap.bind(bindStr, () => { + calls++; + }); + fire(target, makeKeyEvent(eventOver)); + return calls; +} + +const spanishMap = { + get(code) { + return code === "BracketRight" ? "+" : undefined; + }, +}; + +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("matches the unshifted primary on the same position", () => { + const event = { key: "+", code: "BracketRight" }; + assert.equal(firedWithMap(spanishMap, "+", event), 1); + }); + + it("falls back to the glyph when the map misses the code", () => { + const miss = { get: () => undefined }; + const event = { key: "*", code: "BracketRight", shiftKey: true }; + assert.equal(firedWithMap(miss, "shift+*", event), 1); + assert.equal(firedWithMap(miss, "shift++", event), 0); + }); + + it("falls back to the glyph when the map throws", () => { + const broken = { + get() { + throw new Error("no map"); + }, + }; + assert.equal( + firedWithMap(broken, "shift+*", { key: "*", code: "BracketRight", shiftKey: true }), + 1, + ); + }); + + it("setLayoutMap(null) restores the glyph fallback", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(spanishMap); + mousetrap.setLayoutMap(null); + let calls = 0; + mousetrap.bind("shift+*", () => { + calls++; + }); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + assert.equal(calls, 1); + }); + + it("maps stay per instance", () => { + const a = freshInstance(); + const b = freshInstance(); + a.mousetrap.setLayoutMap(spanishMap); + let callsA = 0; + let callsB = 0; + a.mousetrap.bind("shift++", () => { + callsA++; + }); + b.mousetrap.bind("shift+*", () => { + callsB++; + }); + const event = { key: "*", code: "BracketRight", shiftKey: true }; + fire(a.target, makeKeyEvent(event)); + fire(b.target, makeKeyEvent(event)); + assert.equal(callsA, 1); + assert.equal(callsB, 1); + }); + + it("letters still resolve by glyph with a map set", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(spanishMap); + let calls = 0; + mousetrap.bind("a", () => { + calls++; + }); + // AZERTY: the "A" label sits on the KeyQ position. + fire(target, makeKeyEvent({ key: "a", code: "KeyQ" })); + assert.equal(calls, 1); + }); +}); diff --git a/frontend/packages/mousetrap/test/modifiers.test.js b/frontend/packages/mousetrap/test/modifiers.test.js new file mode 100644 index 0000000000..748b71feeb --- /dev/null +++ b/frontend/packages/mousetrap/test/modifiers.test.js @@ -0,0 +1,63 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent } from "./helpers.js"; + +function fired(bindStr, eventOver) { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind(bindStr, () => { + calls++; + }); + fire(target, makeKeyEvent(eventOver)); + return calls; +} + +describe("modifiers", () => { + it("matches each flag on its own", () => { + assert.equal(fired("shift+x", { key: "x", code: "KeyX", shiftKey: true }), 1); + assert.equal(fired("alt+x", { key: "x", code: "KeyX", altKey: true }), 1); + assert.equal(fired("ctrl+x", { key: "x", code: "KeyX", ctrlKey: true }), 1); + assert.equal(fired("meta+x", { key: "x", code: "KeyX", metaKey: true }), 1); + }); + + it("does not fire without the bound modifier", () => { + assert.equal(fired("shift+x", { key: "x", code: "KeyX" }), 0); + assert.equal(fired("ctrl+x", { key: "x", code: "KeyX", altKey: true }), 0); + }); + + it("combines modifiers regardless of bind order", () => { + const event = { key: "q", code: "KeyQ", ctrlKey: true, altKey: true, shiftKey: true }; + assert.equal(fired("ctrl+alt+shift+q", event), 1); + assert.equal(fired("shift+ctrl+alt+q", event), 1); + }); + + it("drops the phantom ctrl while AltGraph is active", () => { + const altgr = { + key: "]", + code: "BracketRight", + ctrlKey: true, + altKey: true, + getModifierState: (mod) => mod === "AltGraph", + }; + assert.equal(fired("ctrl+alt+]", altgr), 0); + assert.equal(fired("alt+]", altgr), 1); + }); + + it("keeps a real ctrl+alt without AltGraph", () => { + const event = { + key: "]", + code: "BracketRight", + ctrlKey: true, + altKey: true, + getModifierState: () => false, + }; + assert.equal(fired("ctrl+alt+]", event), 1); + }); + + it("works when the event has no getModifierState at all", () => { + assert.equal( + fired("ctrl+x", { key: "x", code: "KeyX", ctrlKey: true, getModifierState: undefined }), + 1, + ); + }); +}); diff --git a/frontend/packages/mousetrap/test/normalize.test.js b/frontend/packages/mousetrap/test/normalize.test.js new file mode 100644 index 0000000000..b91b5ff40d --- /dev/null +++ b/frontend/packages/mousetrap/test/normalize.test.js @@ -0,0 +1,109 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent } from "./helpers.js"; + +function fired(bindStr, eventOver) { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind(bindStr, () => { + calls++; + }); + fire(target, makeKeyEvent(eventOver)); + return calls; +} + +describe("canonical key normalization", () => { + // [native event.key, code, canonical bind] — every _KEY_NAMES entry. + const table = [ + [" ", "Space", "space"], + ["Spacebar", "Space", "space"], + ["ArrowUp", "ArrowUp", "up"], + ["Up", "ArrowUp", "up"], + ["ArrowDown", "ArrowDown", "down"], + ["Down", "ArrowDown", "down"], + ["ArrowLeft", "ArrowLeft", "left"], + ["Left", "ArrowLeft", "left"], + ["ArrowRight", "ArrowRight", "right"], + ["Right", "ArrowRight", "right"], + ["Escape", "Escape", "escape"], + ["Esc", "Escape", "escape"], + ["Enter", "Enter", "enter"], + ["Backspace", "Backspace", "backspace"], + ["Delete", "Delete", "del"], + ["Del", "Delete", "del"], + ["Insert", "Insert", "ins"], + ["Ins", "Insert", "ins"], + ["Tab", "Tab", "tab"], + ["Home", "Home", "home"], + ["End", "End", "end"], + ["PageUp", "PageUp", "pageup"], + ["PageDown", "PageDown", "pagedown"], + ["CapsLock", "CapsLock", "capslock"], + ]; + + for (const [nativeKey, code, canonical] of table) { + it(`maps ${nativeKey} to ${canonical}`, () => { + assert.equal(fired(canonical, { key: nativeKey, code }), 1); + }); + } + + it("lowercases single characters so Shift+Z matches shift+z", () => { + assert.equal(fired("shift+z", { key: "Z", code: "KeyZ", shiftKey: true }), 1); + assert.equal(fired("a", { key: "A", code: "KeyA" }), 1); + }); + + 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); + // AZERTY shifted digits already type the digit: unchanged. + assert.equal(fired("shift+0", { key: "0", code: "Digit0", shiftKey: true }), 1); + // Unshifted digits are untouched. + assert.equal(fired("0", { key: "0", code: "Digit0" }), 1); + assert.equal(fired("&", { key: "&", code: "Digit1" }), 1); + }); + + it("without a layout map, punctuation resolves 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 }), 0); + assert.equal(fired("+", { key: "+", code: "BracketRight" }), 1); + // US layout: Shift + BracketRight types "}". + 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); + }); + + it("lowercases function keys", () => { + assert.equal(fired("f1", { key: "F1", code: "F1" }), 1); + assert.equal(fired("f13", { key: "F13", code: "F13" }), 1); + }); + + it("ignores Dead keys and missing/empty keys without throwing", () => { + assert.equal(fired("dead", { key: "Dead", code: "Quote" }), 0); + assert.equal(fired("a", { key: undefined, code: "KeyA" }), 0); + assert.equal(fired("a", { key: "", code: "KeyA" }), 0); + }); + + it("keeps numpad digits positional as num0..num9", () => { + assert.equal(fired("num0", { key: "0", code: "Numpad0" }), 1); + assert.equal(fired("num9", { key: "9", code: "Numpad9" }), 1); + // NumLock off: the key says Insert but the position wins. + assert.equal(fired("num0", { key: "Insert", code: "Numpad0" }), 1); + }); + + it("falls back to the glyph for non-digit numpad codes", () => { + assert.equal(fired("+", { key: "+", code: "NumpadAdd" }), 1); + }); + + it("lowercases unknown long keys", () => { + assert.equal(fired("foo", { key: "Foo", code: "Foo" }), 1); + assert.equal(fired("ñ", { key: "Ñ", code: "Semicolon" }), 1); + }); +}); diff --git a/frontend/packages/mousetrap/test/sequences.test.js b/frontend/packages/mousetrap/test/sequences.test.js new file mode 100644 index 0000000000..69098258d9 --- /dev/null +++ b/frontend/packages/mousetrap/test/sequences.test.js @@ -0,0 +1,108 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent } from "./helpers.js"; + +function down(over) { + return makeKeyEvent({ type: "keydown", ...over }); +} + +function up(over) { + return makeKeyEvent({ type: "keyup", ...over }); +} + +describe("sequences", () => { + beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout"] }); + }); + + afterEach(() => { + mock.timers.reset(); + }); + + it("fires after the full sequence, not on the prefix alone", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("g v", () => { + calls++; + }); + fire(target, down({ key: "g", code: "KeyG" })); + assert.equal(calls, 0); + fire(target, down({ key: "v", code: "KeyV" })); + assert.equal(calls, 1); + }); + + it("advances every sequence sharing a prefix", () => { + const { mousetrap, target } = freshInstance(); + const seen = []; + mousetrap.bind("g i", () => { + seen.push("i"); + }); + mousetrap.bind("g t", () => { + seen.push("t"); + }); + fire(target, down({ key: "g", code: "KeyG" })); + fire(target, down({ key: "t", code: "KeyT" })); + assert.deepEqual(seen, ["t"]); + }); + + it("resets on a wrong key mid-sequence", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("g v", () => { + calls++; + }); + fire(target, down({ key: "g", code: "KeyG" })); + fire(target, down({ key: "e", code: "KeyE" })); + fire(target, down({ key: "v", code: "KeyV" })); + assert.equal(calls, 0); + // and the sequence works again afterwards + fire(target, down({ key: "g", code: "KeyG" })); + fire(target, down({ key: "v", code: "KeyV" })); + assert.equal(calls, 1); + }); + + it("expires after one second without the next key", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("g v", () => { + calls++; + }); + fire(target, down({ key: "g", code: "KeyG" })); + mock.timers.tick(1001); + fire(target, down({ key: "v", code: "KeyV" })); + assert.equal(calls, 0); + }); + + it("supports modifiers inside sequences", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("ctrl+k u", () => { + calls++; + }); + fire(target, down({ key: "k", code: "KeyK", ctrlKey: true })); + fire(target, down({ key: "u", code: "KeyU" })); + assert.equal(calls, 1); + }); + + it("supports keyup sequences and ignores the final keyup", () => { + const { mousetrap, target } = freshInstance(); + const seen = []; + mousetrap.bind( + "g v", + () => { + seen.push("seq"); + }, + "keyup", + ); + mousetrap.bind( + "v", + () => { + seen.push("keyup-v"); + }, + "keyup", + ); + fire(target, up({ key: "g", code: "KeyG" })); + fire(target, up({ key: "v", code: "KeyV" })); + assert.deepEqual(seen, ["seq"]); + }); +}); diff --git a/frontend/packages/mousetrap/test/smoke.test.js b/frontend/packages/mousetrap/test/smoke.test.js new file mode 100644 index 0000000000..ae3498f046 --- /dev/null +++ b/frontend/packages/mousetrap/test/smoke.test.js @@ -0,0 +1,31 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent } from "./helpers.js"; + +describe("smoke", () => { + it("fires a bound combo and ignores it without the modifier", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("ctrl+z", () => { + calls++; + }); + + fire(target, makeKeyEvent({ key: "z", code: "KeyZ", ctrlKey: true })); + fire(target, makeKeyEvent({ key: "z", code: "KeyZ" })); + + assert.equal(calls, 1); + }); + + it("reset() leaves the instance deaf", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("ctrl+z", () => { + calls++; + }); + mousetrap.reset(); + + fire(target, makeKeyEvent({ key: "z", code: "KeyZ", ctrlKey: true })); + + assert.equal(calls, 0); + }); +}); diff --git a/frontend/packages/mousetrap/test/stop-callback.test.js b/frontend/packages/mousetrap/test/stop-callback.test.js new file mode 100644 index 0000000000..b02303dcec --- /dev/null +++ b/frontend/packages/mousetrap/test/stop-callback.test.js @@ -0,0 +1,102 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Mousetrap } from "../index.js"; +import { + freshInstance, + fire, + makeKeyEvent, + makeElement, +} from "./helpers.js"; + +describe("stopCallback", () => { + function stopped(elementOver, eventOver = {}, combo = "ctrl+z") { + const { mousetrap } = freshInstance(); + const event = makeKeyEvent({ target: makeElement(elementOver), ...eventOver }); + return mousetrap.stopCallback(event, event.target, combo); + } + + it("stops inside form fields", () => { + for (const tagName of ["INPUT", "SELECT", "TEXTAREA"]) { + assert.equal(stopped({ tagName }), true, tagName); + } + }); + + it("stops buttons only for tab combos, tolerating a missing combo", () => { + assert.equal(stopped({ tagName: "BUTTON" }, {}, "shift+tab"), true); + assert.equal(stopped({ tagName: "BUTTON" }, {}, "ctrl+z"), false); + assert.equal( + stopped({ tagName: "BUTTON" }, {}, undefined), + false, + ); + }); + + it("stops editable content", () => { + assert.equal(stopped({ contentEditable: "true" }), true); + assert.equal(stopped({ contentEditable: "plaintext-only" }), true); + assert.equal(stopped({ contentEditable: "false" }), false); + assert.equal(stopped({ contentEditable: "inherit" }), false); + }); + + it("lets events through on mousetrap-marked elements", () => { + assert.equal(stopped({ className: "mousetrap" }), false); + assert.equal(stopped({ className: "foo mousetrap bar" }), false); + assert.equal(stopped({ dataset: { mousetrapDontStop: "" } }), false); + }); + + it("always lets keyup through", () => { + assert.equal(stopped({ tagName: "INPUT" }, { type: "keyup" }), false); + }); + + it("retargets through composedPath in open shadow trees", () => { + const inner = makeElement({ tagName: "DIV" }); + const event = makeKeyEvent({ + target: makeElement({ tagName: "INPUT" }), + composedPath: () => [inner], + }); + const { mousetrap } = freshInstance(); + assert.equal(mousetrap.stopCallback(event, event.target, "ctrl+z"), false); + }); + + it("gates dispatch end to end: typing in an input fires nothing", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("ctrl+z", () => { + calls++; + }); + fire( + target, + makeKeyEvent({ + key: "z", + code: "KeyZ", + ctrlKey: true, + target: makeElement({ tagName: "INPUT" }), + }), + ); + assert.equal(calls, 0); + }); +}); + +describe("exports", () => { + it("exposes the shared default instance", async () => { + const mod = await import("../index.js"); + assert.equal(typeof mod.default.bind, "function"); + assert.equal(typeof mod.default.reset, "function"); + }); + + it("constructs without a target in Node: no listeners, no throw", () => { + const mousetrap = new Mousetrap(); + assert.deepEqual(mousetrap._callbacks, {}); + }); + + it("wires keydown and keyup on the given target", async () => { + const { Mousetrap: Klass } = await import("../index.js"); + const seen = []; + const target = { + addEventListener(type) { + seen.push(type); + }, + }; + new Klass(target); + assert.deepEqual(seen, ["keydown", "keyup"]); + }); +}); diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt index 009f4c1c4e..ef5db91657 100644 --- a/frontend/scripts/check-translations/words.es.txt +++ b/frontend/scripts/check-translations/words.es.txt @@ -280,6 +280,7 @@ lectores letras manifiesto menos +navegadores nosotros otras pero diff --git a/frontend/scripts/test b/frontend/scripts/test index 6d822b9542..c61c28b543 100755 --- a/frontend/scripts/test +++ b/frontend/scripts/test @@ -10,3 +10,8 @@ popd pnpm run lint:scss; pnpm run test; + +# Mousetrap JS unit tests (node:test, zero deps) +pushd packages/mousetrap; +pnpm test; +popd diff --git a/frontend/src/app/main/data/dashboard/shortcuts.cljs b/frontend/src/app/main/data/dashboard/shortcuts.cljs index b225a50060..f406151f9b 100644 --- a/frontend/src/app/main/data/dashboard/shortcuts.cljs +++ b/frontend/src/app/main/data/dashboard/shortcuts.cljs @@ -50,7 +50,8 @@ (def shortcut-create-new-project {:create-new-project {:tooltip "+" :label (fn [] (tr "shortcuts.create-new-project")) - :command "+" + :command ["+" "="] + :show-command "+" :section [:dashboard] :subsections [:generic] :fn #(st/emit! (dd/create-element))}}) diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 6ed22e5fb1..7181f5ebb3 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -17,6 +17,37 @@ (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)))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/viewer/shortcuts.cljs b/frontend/src/app/main/data/viewer/shortcuts.cljs index 23f71e9b57..83878c9682 100644 --- a/frontend/src/app/main/data/viewer/shortcuts.cljs +++ b/frontend/src/app/main/data/viewer/shortcuts.cljs @@ -17,7 +17,8 @@ (def shortcuts {:increase-zoom {:tooltip "+" :label (fn [] (tr "shortcuts.increase-zoom")) - :command "+" + :command ["+" "="] + :show-command "+" :subsections [:zoom-viewer] :section [:viewer] :fn #(st/emit! dv/increase-zoom)} diff --git a/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs b/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs index 1e234dc62d..de5349fca3 100644 --- a/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs @@ -55,7 +55,8 @@ :increase-zoom {:tooltip "+" :label (fn [] (tr "shortcuts.increase-zoom")) - :command "+" + :command ["+" "="] + :show-command "+" :section [:workspace] :fn #(st/emit! (dw/increase-zoom nil))} diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index c1d2269383..bd924df5fe 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -59,7 +59,8 @@ :add-node {:tooltip (ds/shift "+") :label (fn [] (tr "shortcuts.add-node")) - :command "shift++" + :command ["shift++" "shift+="] + :show-command "shift++" :subsections [:path-editor] :section [:workspace] :fn #(st/emit! (drp/add-node))} @@ -117,8 +118,7 @@ :snap-nodes {:tooltip (ds/meta "'") :label (fn [] (tr "shortcuts.snap-nodes")) - ;;https://github.com/ccampbell/mousetrap/issues/85 - :command [(ds/c-mod "'") (ds/c-mod "219")] + :command (ds/c-mod "'") :subsections [:path-editor] :section [:workspace] :fn #(st/emit! (drp/toggle-snap))} @@ -207,7 +207,8 @@ :increase-zoom {:tooltip "+" :label (fn [] (tr "shortcuts.increase-zoom")) - :command "+" + :command ["+" "="] + :show-command "+" :section [:workspace] :fn #(st/emit! (dw/increase-zoom nil))} diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index f91e51a5ba..bdbd3cedaf 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -567,8 +567,7 @@ :toggle-guides {:tooltip (ds/meta "'") :label (fn [] (tr "shortcuts.toggle-guides")) - ;;https://github.com/ccampbell/mousetrap/issues/85 - :command [(ds/c-mod "'") (ds/c-mod "219")] + :command (ds/c-mod "'") :show-command (ds/c-mod "'") :subsections [:main-menu] :section [:workspace] @@ -619,8 +618,7 @@ :toggle-snap-guides {:tooltip (ds/meta-shift "'") :label (fn [] (tr "shortcuts.toggle-snap-guides")) - ;;https://github.com/ccampbell/mousetrap/issues/85 - :command [(ds/c-mod "shift+'") (ds/c-mod "shift+219")] + :command (ds/c-mod "shift+'") :show-command (ds/c-mod "shift+'") :subsections [:main-menu] :section [:workspace] @@ -679,7 +677,8 @@ :hide-ui {:tooltip "\\" :label (fn [] (tr "shortcuts.hide-ui")) - :command "\\" + :command ["\\" "alt+\\" "alt+º"] + :show-command "\\" :subsections [:panels :basics] :section [:workspace :basics] :fn #(st/emit! (toggle-layout-flag :hide-ui))} @@ -716,7 +715,8 @@ :zoom-selected {:tooltip (ds/shift "2") :label (fn [] (tr "shortcuts.zoom-selected")) - :command ["shift+2" "shift+num2" "@" "\""] + :command ["shift+2" "shift+num2" "shift+'" "shift+\""] + :show-command "shift+2" :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 7df63314d0..2b246339b2 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.cljs +++ b/frontend/src/app/main/ui/settings/shortcuts.cljs @@ -23,6 +23,7 @@ [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] @@ -477,6 +478,10 @@ [:> 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 522147f61e..c86ca879b5 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.scss +++ b/frontend/src/app/main/ui/settings/shortcuts.scss @@ -314,3 +314,7 @@ 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 74035a119f..1520f3258b 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -34,41 +34,40 @@ (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" "ArrowDown" "down" + "Down" "down" "ArrowLeft" "left" + "Left" "left" "ArrowRight" "right" + "Right" "right" "Escape" "escape" + "Esc" "escape" "Enter" "enter" "Backspace" "backspace" "Delete" "del" + "Del" "del" + "Insert" "ins" + "Ins" "ins" "Tab" "tab" + "Home" "home" + "End" "end" + "PageUp" "pageup" + "PageDown" "pagedown" + "CapsLock" "capslock" + "Spacebar" "space" + "Control" "ctrl" + "Shift" "shift" + "Alt" "alt" + "Meta" "meta" " " "space"}) -;; Mousetrap (the runtime shortcut matcher, vendored at -;; frontend/packages/mousetrap) resolves a keydown positionally - via keyCode -;; - ONLY for this punctuation set (its _KEYCODE_MAP); it falls back to -;; `event.key.toLowerCase()` (glyph-based) for letters and digits, which -;; aren't in that map. Recording punctuation from `code` therefore matches -;; what Mousetrap triggers regardless of layout (e.g. the key next to Right -;; Shift on a US layout is always "Period", whatever glyph it types on a -;; given ISO layout). With modifiers, letters and digits come from keyCode -;; like Mousetrap's keydown match (see `letter-or-digit`); otherwise from the -;; glyph, like its keypress match. -(def ^:private code-name-map - {"Minus" "-" - "Equal" "=" - "BracketLeft" "[" - "BracketRight" "]" - "Backslash" "\\" - "Semicolon" ";" - "Quote" "'" - "Backquote" "`" - "Comma" "," - "Period" "." - "Slash" "/"}) - (defn- event->native "Unwraps a goog.events.BrowserEvent to the native event, since the wrapper copies `key` but not `code`." @@ -77,42 +76,100 @@ (or (.getBrowserEvent event) event) event)) -(defn- letter-or-digit - "Letter or digit named by keyCode when a modifier is held, ignoring the glyph - it makes the key type (Option+Shift+5 types \"fi\" on macOS)." +(defn- alt-graph? [^js event] - (let [key-code (.-keyCode event)] - (when (and (or (.-ctrlKey event) (.-metaKey event) - (.-altKey event) (.-shiftKey event)) - (or (<= 48 key-code 57) - (<= 65 key-code 90))) - (.toLowerCase (js/String.fromCharCode key-code))))) + (boolean + (or (when (fn? (.-getModifierState event)) + (.getModifierState event "AltGraph")) + (let [native (event->native event)] + (when (and (not (identical? native event)) + (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." [^js event] - (let [key (.-key event)] - (when (and key (not (contains? modifier-keys key))) - (or (get key-name-map key) - (get code-name-map (.-code (event->native event))) - (letter-or-digit event) - (some-> key .toLowerCase))))) + (let [native (event->native event) + key (.-key ^js event) + code (.-code ^js native)] + (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)))))) (defn- event->modifier-parts [^js event] - (cond-> [] - (and (.-ctrlKey event) - (not (cf/check-platform? :macos))) - (conj "ctrl") + (let [alt-graph? (alt-graph? event)] + (cond-> [] + (and (.-ctrlKey event) + (not (cf/check-platform? :macos)) + (not alt-graph?)) + (conj "ctrl") - (and (.-metaKey event) - (cf/check-platform? :macos)) - (conj "command") + (and (.-metaKey event) + (cf/check-platform? :macos)) + (conj "command") - (.-altKey event) - (conj "alt") + (.-altKey event) + (conj "alt") - (.-shiftKey event) - (conj "shift"))) + (.-shiftKey event) + (conj "shift")))) (defn- keyboard-event->mousetrap [^js event] diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e33ae65f8f..3a3639433f 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -123,7 +123,6 @@ [frontend-tests.ui.settings-shortcuts-test] [frontend-tests.ui.shape-filters-test] [frontend-tests.ui.shortcuts-labels-test] - [frontend-tests.ui.shortcuts-recording-test] [frontend-tests.ui.sidebar-scroll-test] [frontend-tests.ui.stroke-menu-test] [frontend-tests.ui.workspace-libraries-test] @@ -270,7 +269,6 @@ 'frontend-tests.ui.settings-shortcuts-test 'frontend-tests.ui.shape-filters-test 'frontend-tests.ui.shortcuts-labels-test - 'frontend-tests.ui.shortcuts-recording-test 'frontend-tests.ui.sidebar-scroll-test 'frontend-tests.ui.stroke-menu-test 'frontend-tests.ui.workspace-libraries-test diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs index e9913c9b26..fd82bfe17d 100644 --- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs @@ -2,6 +2,7 @@ (:require [app.config :as cf] [app.main.data.profile :as du] + [app.main.data.shortcuts :as ds] [app.main.ui.settings.import-shortcuts-diff-modal :as diff-modal] [app.main.ui.settings.restore-shortcuts-modal :as restore-modal] [app.main.ui.settings.shortcuts :as sut] @@ -293,9 +294,8 @@ ;; --- keyboard-event->mousetrap across keyboard layouts ------------------- ;; `keyboard-event->mousetrap` (private, exercised via #') turns a recorded -;; keydown into a Mousetrap-style combo string, reading `event.code` -;; (layout-independent) rather than `event.key` (layout- and Shift-dependent) -;; so the recorded combo matches what Mousetrap resolves positionally. Real +;; keydown into a Mousetrap-style combo string from `event.key` (the typed +;; glyph), so recording and dispatch always use the same token. Real ;; keydown events are wrapped by goog.events in a goog.events.BrowserEvent, ;; which forwards `key` but not `code`; the fixtures below simulate that ;; wrapper via `getBrowserEvent`. @@ -326,35 +326,47 @@ :metaKey (boolean meta)}) (t/deftest mousetrap-shift-period-on-us-layout - (t/testing "US layout: Shift + Period key types \">\"" + (t/testing "US layout without a layout map: Shift + Period types \">\" and + records the glyph, the same token Mousetrap dispatches" (with-redefs [cf/check-platform? (constantly false)] - (t/is (= "shift+." + (t/is (= "shift+>" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key ">" :code "Period" :shift true}))))))) -(t/deftest mousetrap-shift-period-on-spanish-iso-layout - (t/testing "Spanish (ISO) layout: Shift + Period key types \":\" instead of - \">\", but the physical key (code \"Period\") is the same as on - a US layout, so the recorded combo must match" +(t/deftest mousetrap-shift-period-with-layout-map-records-primary + (t/testing "with a layout map, Shift + Period records the primary \".\" + even when the typed glyph differs" (with-redefs [cf/check-platform? (constantly false)] - (t/is (= "shift+." + (try + (reset! ds/layout-map #js {:get (fn [code] (when (= code "Period") "."))}) + (t/is (= "shift+." + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key ">" :code "Period" :shift true})))) + (t/is (= "shift+." + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key ":" :code "Period" :shift true})))) + (finally + (reset! ds/layout-map nil)))))) + +(t/deftest mousetrap-shift-period-on-spanish-iso-layout + (t/testing "Spanish (ISO) layout without a layout map: Shift + Period + types \":\" and records the glyph" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "shift+:" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key ":" :code "Period" :shift true}))))))) (t/deftest mousetrap-shift-period-on-arbitrary-layout - (t/testing "an arbitrary layout where Shift + Period produces a character - unrelated to \".\" or \":\" still normalizes to \"shift+.\", - since matching is driven by the physical key, not the glyph" + (t/testing "without a layout map the typed glyph wins over the physical + position: \"?\" on the Period position records \"shift+?\"" (with-redefs [cf/check-platform? (constantly false)] - (t/is (= "shift+." + (t/is (= "shift+?" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "?" :code "Period" :shift true}))))))) (t/deftest mousetrap-letter-key-on-azerty-layout (t/testing "French AZERTY: the key printed \"A\" sits where \"Q\" is on a US - layout (code \"KeyQ\") and types \"a\". Mousetrap doesn't - resolve letters positionally (absent from its - _MAP/_KEYCODE_MAP), so recording must stay glyph-based + layout (code \"KeyQ\") and types \"a\". Letters resolve by glyph (\"a\", not \"q\") to match what that physical key triggers" (with-redefs [cf/check-platform? (constantly false)] (t/is (= "a" @@ -364,19 +376,17 @@ (t/deftest mousetrap-digit-key-on-azerty-layout (t/testing "French AZERTY: the unshifted top-row key at the US \"1\" position (code \"Digit1\") types \"&\". Digits, like letters, - are absent from Mousetrap's _MAP/_KEYCODE_MAP, so recording - must stay glyph-based (\"&\") rather than resolve positionally - to \"1\"" + resolve by glyph (\"&\") rather than positionally to \"1\"" (with-redefs [cf/check-platform? (constantly false)] (t/is (= "&" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "&" :code "Digit1"}))))))) (t/deftest mousetrap-reads-code-from-unwrapped-native-event - (t/testing "falls back to reading `code` straight off the event when it is - not a goog.events.BrowserEvent wrapper (no getBrowserEvent)" + (t/testing "recording works on a plain native event too (glyph wins + without a layout map)" (with-redefs [cf/check-platform? (constantly false)] - (t/is (= "shift+." + (t/is (= "shift+:" (#'ui-shortcuts/keyboard-event->mousetrap (mock-native-keydown {:key ":" :code "Period" :shift true}))))))) @@ -417,3 +427,116 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "q" :code "KeyQ" :ctrl true :alt true :shift true}))))))) + +(t/deftest mousetrap-shifted-letter-lowercases + (t/testing "Ctrl+Shift+Z types \"Z\" but records \"ctrl+shift+z\" so the + bind and the event use the same token" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "ctrl+shift+z" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "Z" :code "KeyZ" + :ctrl true :shift true}))))))) + +(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)" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "shift+0" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key ")" :code "Digit0" :shift true})))) + (t/is (= "shift+1" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "!" :code "Digit1" :shift true})))) + (t/is (= "shift+2" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "@" :code "Digit2" :shift true})))) + (t/is (= "shift+8" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "*" :code "Digit8" :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}))))))) + +(t/deftest mousetrap-space-and-named-keys + (t/testing "\" \" records as space and named keys stay lowercase" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "space" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key " " :code "Space"})))) + (t/is (= "escape" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "Escape" :code "Escape"})))) + (t/is (= "shift+tab" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "Tab" :code "Tab" :shift true})))) + (t/is (= "del" + (#'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" + (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+]" + (#'ui-shortcuts/keyboard-event->mousetrap event))))))) + +(t/deftest mousetrap-real-ctrl-alt-keeps-both + (t/testing "a real Ctrl+Alt without AltGraph still records both modifiers" + (with-redefs [cf/check-platform? (constantly false)] + (let [event #js {:key "]" + :shiftKey false :ctrlKey true :altKey true :metaKey false + :getModifierState (fn [_] false) + :getBrowserEvent (fn [] nil)}] + (t/is (= "ctrl+alt+]" + (#'ui-shortcuts/keyboard-event->mousetrap event))))))) + +(t/deftest mousetrap-spanish-shift-plus-with-layout-map-records-primary + (t/testing "Spanish layout with a layout map: Shift + the physical \"+\" + key types \"*\" but records the primary \"shift++\", which is + 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") "+"))}) + (t/is (= "shift++" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "*" :code "BracketRight" :shift true})))) + (t/is (= "+" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "+" :code "BracketRight"})))) + (finally + (reset! ds/layout-map nil)))))) + +(t/deftest mousetrap-spanish-shift-plus-without-layout-map-records-glyph + (t/testing "Spanish layout without a layout map: Shift + the physical + \"+\" key records the typed glyph \"shift+*\", the same token + Mousetrap dispatches" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "shift+*" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "*" :code "BracketRight" :shift true}))))))) + +(t/deftest mousetrap-spanish-unshifted-plus-records-plus + (t/testing "Spanish layout: the unshifted physical \"+\" key records \"+\"" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "+" + (#'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)}}))))) diff --git a/frontend/test/frontend_tests/ui/shortcuts_recording_test.cljs b/frontend/test/frontend_tests/ui/shortcuts_recording_test.cljs deleted file mode 100644 index 9bb4479fbe..0000000000 --- a/frontend/test/frontend_tests/ui/shortcuts_recording_test.cljs +++ /dev/null @@ -1,60 +0,0 @@ -;; 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.shortcuts-recording-test - (:require - [app.main.ui.shortcuts :as ss] - [cljs.test :as t :include-macros true])) - -(def ^:private keyboard-event->mousetrap - @#'ss/keyboard-event->mousetrap) - -(defn- key-event - [props] - (clj->js (merge {:altKey false :shiftKey false :ctrlKey false :metaKey false} - props))) - -(t/deftest records-alt-digit-from-key-code - ;; macOS Option+Shift+5 types "fi"; Mousetrap matches it as "5" - (t/is (= "alt+shift+5" - (keyboard-event->mousetrap - (key-event {:key "fi" :code "Digit5" :keyCode 53 :altKey true :shiftKey true}))))) - -(t/deftest records-alt-letter-from-key-code - ;; macOS Option+A types "å"; Mousetrap matches it as "a" - (t/is (= "alt+a" - (keyboard-event->mousetrap - (key-event {:key "å" :code "KeyA" :keyCode 65 :altKey true}))))) - -(t/deftest records-shift-digit-from-key-code - ;; US Shift+0 types ")"; Mousetrap matches it as "0" - (t/is (= "shift+0" - (keyboard-event->mousetrap - (key-event {:key ")" :code "Digit0" :keyCode 48 :shiftKey true}))))) - -(t/deftest records-modified-letter-from-key-code - ;; Russian layout V types "м"; Mousetrap matches Alt+V as "v" - (t/is (= "alt+v" - (keyboard-event->mousetrap - (key-event {:key "м" :code "KeyV" :keyCode 86 :altKey true}))))) - -(t/deftest records-unmodified-key-from-glyph - ;; without modifiers Mousetrap matches the typed glyph on keypress - (t/is (= "&" - (keyboard-event->mousetrap - (key-event {:key "&" :code "Digit1" :keyCode 49})))) - (t/is (= "ñ" - (keyboard-event->mousetrap - (key-event {:key "ñ" :code "" :keyCode 0}))))) - -(t/deftest records-shift-punctuation-from-code - ;; Shift+. types ":" and Shift+= types "+" (US); the key is recorded - (t/is (= "shift+." - (keyboard-event->mousetrap - (key-event {:key ":" :code "Period" :keyCode 190 :shiftKey true})))) - (t/is (= "shift+=" - (keyboard-event->mousetrap - (key-event {:key "+" :code "Equal" :keyCode 187 :shiftKey true}))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2c7108dbe4..525bb3589d 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -5021,6 +5021,13 @@ 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 a069f620eb..82081caf82 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -5067,6 +5067,13 @@ 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" @@ -6344,10 +6351,6 @@ msgstr "Tipografías" msgid "workspace.assets.typography.add-typography" msgstr "Añadir tipografía" -#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:461 -msgid "workspace.assets.typography.multiple-texts-selected" -msgstr "Selecciona un solo texto para crear un estilo de tipografía" - #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:855 msgid "workspace.assets.typography.font-size" msgstr "Tamaño" @@ -6368,6 +6371,10 @@ msgstr "Interletrado" msgid "workspace.assets.typography.line-height" msgstr "Interlineado" +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:461 +msgid "workspace.assets.typography.multiple-texts-selected" +msgstr "Selecciona un solo texto para crear un estilo de tipografía" + #: src/app/main/ui/dashboard/grid.cljs:226, src/app/main/ui/workspace/libraries.cljs:1070, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:802, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:838, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:957, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:976 msgid "workspace.assets.typography.sample" msgstr "Ag" From 5abf66dbf0cf0f887cd6041b68a250222c8000a1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 14:00:25 +0000 Subject: [PATCH 082/167] :bug: Set a finite idle timeout on binfile transactions Binfile v1/v3 import and the v3 export get-file step hold a single transaction while the ZIP is decoded and the file data is realized. Disabling idle_in_transaction_session_timeout left a stuck connection holding locks (and a pool slot) forever. Raise it to 30 minutes instead. The longest import/export observed in production is under 8 minutes, so the limit still bounds a stuck session. statement_timeout stays at 300 s. Add set-transaction-idle-timeout and configure-import-transaction in app.binfile.common, use them from both paths, and cover the helper with a test. AI-assisted-by: deepseek-v4.1-flash --- .serena/memories/backend/subtleties.md | 3 ++- backend/src/app/binfile/common.clj | 22 +++++++++++++++++++-- backend/src/app/binfile/v1.clj | 2 +- backend/src/app/binfile/v3.clj | 5 +++-- backend/test/backend_tests/binfile_test.clj | 17 ++++++++++++++++ 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index cee764853a..b6db941801 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -81,4 +81,5 @@ - Tell a snapshot apart by `label IS NOT NULL` (with `created_by` / `deleted_at` as needed), never by `file_change.data IS NOT NULL`: the label holds with every file data backend, and the payload column stopped meaning "this is a snapshot" when the payload moved out. Queries that identify versions must use the label. - The version quota counts of `app.rpc.quotes` (`sql:get-snapshots-per-file`, `sql:get-snapshots-per-team`) are served by the partial index `file_change__user_snapshots__idx`, whose predicate mirrors their where clause; changing one without the other drops the index only scan and the count walks the whole table again. - Revisions live only in `file_change` and have no `file_data` counterpart. They are born already scheduled for deletion one hour later (`app.rpc.commands.files-update`) and the snapshot paths cannot reach one, because they resolve their row through a `label IS NOT NULL` filter. -- A deletion date is assigned once and never moved: `restore-files` (`app.rpc.commands.files`) unmarks only durable history, the `:file` method of `app.tasks.delete-object` dates only rows that have no date yet, and `objects-gc` collects only rows that carry one. Unmarking a revision leaves it invisible to `objects-gc` for ever, because nothing sets that date again. \ No newline at end of file +- A deletion date is assigned once and never moved: `restore-files` (`app.rpc.commands.files`) unmarks only durable history, the `:file` method of `app.tasks.delete-object` dates only rows that have no date yet, and `objects-gc` collects only rows that carry one. Unmarking a revision leaves it invisible to `objects-gc` for ever, because nothing sets that date again. +- Binfile v1/v3 import and the v3 export `get-file` step run inside one long transaction. They raise `idle_in_transaction_session_timeout` to 30 min (`app.binfile.common/transaction-idle-timeout-ms`) instead of disabling it, so a connection left idle while the ZIP is decoded or the file data is realized is bounded; `statement_timeout` stays at 300 s. diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index 7fe8e6a532..34e93ae686 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -660,10 +660,28 @@ data library-ids))) -(defn disable-database-timeouts! +(def transaction-idle-timeout-ms + "Idle-in-transaction guard for the long binfile transactions: 30 + minutes. The import and the export of a file run inside one + transaction that can go quiet while the ZIP is decoded and the file + data is realized. The longest operation observed in production is + under 8 minutes, so this leaves room for bigger files and still + aborts a connection that is left idle for half an hour." + 1800000) + +(defn set-transaction-idle-timeout + "Set `idle_in_transaction_session_timeout` for the current + transaction. Must be called inside a transaction: `SET LOCAL` is + scoped to it." [cfg] (let [conn (db/get-connection cfg)] - (db/exec-one! conn ["SET LOCAL idle_in_transaction_session_timeout = 0"]) + (db/exec-one! conn [(str "SET LOCAL idle_in_transaction_session_timeout = " + transaction-idle-timeout-ms)]))) + +(defn configure-import-transaction + [cfg] + (set-transaction-idle-timeout cfg) + (let [conn (db/get-connection cfg)] (db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"]))) (defn invalidate-thumbnails diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index fd340a487d..a8b1ea32c3 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -455,7 +455,7 @@ (defn- read-import-v1 [{:keys [::db/conn ::bfc/project-id ::bfc/profile-id ::bfc/input] :as cfg}] - (bfc/disable-database-timeouts! cfg) + (bfc/configure-import-transaction cfg) (pu/with-open [input (zstd-input-stream input) input (io/data-input-stream input)] diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index cdc3dd66bb..69c3e4781e 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -228,6 +228,7 @@ (let [detach? (and (not embed-assets) (not include-libraries))] (db/tx-run! cfg (fn [cfg] + (bfc/set-transaction-idle-timeout cfg) (cond-> (bfc/get-file cfg file-id {:realize? true :include-deleted? true @@ -934,7 +935,7 @@ (defn- import-files* [{:keys [::manifest] :as cfg}] - (bfc/disable-database-timeouts! cfg) + (bfc/configure-import-transaction cfg) (vswap! bfc/*state* update :index bfc/update-index (:files manifest) :id) @@ -962,7 +963,7 @@ :hint "unable to perform in-place update with binfile containing more than 1 file" :manifest manifest)) - (bfc/disable-database-timeouts! cfg) + (bfc/configure-import-transaction cfg) (let [ref-file (bfc/get-minimal-file cfg file-id ::db/for-update true) file (first (get manifest :files)) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 42edaaf4b5..61cf253c8e 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -390,3 +390,20 @@ d)))] (t/is (= :validation (:type out))) (t/is (= :max-file-size-reached (:code out)))))) + +(def ^:private sql:current-idle-timeout + "SELECT current_setting('idle_in_transaction_session_timeout')::interval + = interval '30 minutes' AS ok") + +(t/deftest binfile-transactions-use-a-finite-idle-timeout + (db/tx-run! th/*system* + (fn [{::db/keys [conn] :as cfg}] + (t/testing "export transaction" + (bfc/set-transaction-idle-timeout cfg) + (t/is (true? (-> (db/exec-one! conn [sql:current-idle-timeout]) + :ok)))) + + (t/testing "import transaction" + (bfc/configure-import-transaction cfg) + (t/is (true? (-> (db/exec-one! conn [sql:current-idle-timeout]) + :ok))))))) From cdb7dc2977620ef1500bc492d657a6f3e94bcdf5 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 17:05:22 +0200 Subject: [PATCH 083/167] :recycle: Use a DS button for edit grid in the grid cell options (#12385) * :recycle: Use a DS button for edit grid in the grid cell options Replace the raw "Edit grid" button of the grid cell options with the DS secondary button. Drop its alt attribute, which is not valid on a button; the label already names it. The column and row icons label the numeric inputs, so they move with the numeric inputs migration. Add a visual test that selects a cell in grid edit mode so the options show. AI-assisted-by: claude-opus-5-5 * :recycle: Move grid cell options styles to DS tokens The edit grid button is a DS button now, so its button placeholder and typography mixin go away; the class only stretches it and centers its label. Merge the two .grid-cell-menu-container rules, inline the flex mixins, and move spacing, borders and color aliases to DS tokens and logical properties. Use the DS body-small typography in the inputs; its line-height is 1.3 instead of 1.4. The refactor/ import stays only for %input-element and %button-icon, used by the inputs and their legacy icons, which move with the numeric inputs migration. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 36 +++++++++++ .../sidebar/options/menus/grid_cell.cljs | 8 +-- .../sidebar/options/menus/grid_cell.scss | 59 ++++++++++--------- 3 files changed, 71 insertions(+), 32 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 73c5e2a524..7d21687ba7 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -449,3 +449,39 @@ test.describe("Imported SVG attributes", () => { }); }); }); + +test.describe("Grid cell options", () => { + test("Shows the grid cell options when selecting a cell", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9042.json"); + await workspace.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-fragment-9042.json", + ); + await workspace.goToWorkspace({ + fileId: "af2494d0-39ba-8184-8005-230696f6df5c", + pageId: "af2494d0-39ba-8184-8005-230696f6df5d", + }); + + await workspace.clickLeafLayer("Board"); + await page + .getByTestId("inspect-layout") + .getByRole("button", { name: "Edit grid" }) + .click(); + + // Click the first cell of the grid editor. + const cell = page.locator("g.cell-editor > rect").first(); + const box = await cell.boundingBox(); + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + + await expect(workspace.rightSidebar.getByText("Grid cell")).toBeVisible(); + // The cell options are a small part of the sidebar: compare every pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs index 2434c66c07..b7ee6b4f6e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs @@ -19,6 +19,7 @@ [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as deprecated-icon] @@ -289,8 +290,7 @@ :set-alignment set-justify-self}]] [:div {:class (stl/css :row)} - [:button - {:class (stl/css :edit-grid-btn) - :alt (tr "workspace.layout-grid.editor.options.edit-grid") - :on-click toggle-edit-mode} + [:> button* {:variant "secondary" + :class (stl/css :edit-grid-btn) + :on-click toggle-edit-mode} (tr "workspace.layout-grid.editor.options.edit-grid")]]])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss index 8c245a491f..6b2092b133 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss @@ -4,14 +4,20 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for %input-element and %button-icon: the inputs and their icons move +// to the DS in their own plan. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; @use "../../../sidebar/common/sidebar.scss" as sidebar; .grid-cell-menu-container { - @include deprecated.flex-column; - - margin-top: deprecated.$s-8; - gap: deprecated.$s-16; + display: flex; + flex-direction: column; + gap: var(--sp-l); + grid-column: span 8; + margin-block-start: var(--sp-s); } .grid-cell-menu { @@ -20,55 +26,52 @@ .grid-cell-menu-title { grid-column: span 8; - font-size: deprecated.$fs-11; -} - -.grid-cell-menu-container { - grid-column: span 8; + font-size: px2rem(11); } .row { - @include deprecated.flex-row; + display: flex; + align-items: center; + gap: var(--sp-xs); } .cell-mode :global(label) { - padding: 0 deprecated.$s-12; + padding: 0 var(--sp-m); } .edit-grid-btn { - @extend %button-secondary; - @include deprecated.uppercase-title-typography; - - width: 100%; - padding: deprecated.$s-8; + justify-content: center; + inline-size: 100%; } .area-input { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - width: 100%; - padding: deprecated.$s-8; + inline-size: 100%; + padding: var(--sp-s); } .grid-coord-group { - @include deprecated.flex-row; - - border-radius: deprecated.$br-8; - padding-left: deprecated.$s-4; - background-color: var(--input-background-color); + display: flex; + align-items: center; + gap: var(--sp-xs); + border-radius: $br-8; + padding-inline-start: var(--sp-xs); + background-color: var(--color-background-tertiary); } +// The legacy icon is a raw svg with no class of its own. .icon svg { @extend %button-icon; - stroke: var(--icon-foreground); + stroke: var(--color-foreground-secondary); } .coord-input { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; - border-left: deprecated.$s-1 solid var(--panel-background-color); + border-radius: 0 $br-8 $br-8 0; + border-inline-start: $b-1 solid var(--color-background-primary); } From 7f53483a32332292c0ea91bead81bef1ce75aa25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Mon, 19 Oct 2026 17:17:34 +0200 Subject: [PATCH 084/167] :bug: Keep full quality rendering during the v3 text layout preview (#12360) --- frontend/src/app/main/data/workspace/modifiers.cljs | 11 +++++++---- frontend/src/app/main/data/workspace/texts.cljs | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 47d97b4249..e2cfc06963 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -706,8 +706,9 @@ #_:clj-kondo/ignore (defn set-wasm-modifiers [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis - subtree-ids-by-id selection-rect-cache] - :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil} + subtree-ids-by-id selection-rect-cache interactive?] + :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil + interactive? true} :as params}] (let [modif-tree (without-nil-ids modif-tree)] (ptk/reify ::set-wasm-modifiers @@ -726,8 +727,10 @@ ;; the renderer into fast + atlas-backdrop mode so the live ;; preview is cheap, tiles never appear sequentially and the main ;; thread is not blocked. The pair is closed in - ;; `clear-local-transform`. - (ensure-interactive-transform-start!) + ;; `clear-local-transform`. Previews outside a gesture pass + ;; `:interactive? false` to keep full quality rendering. + (when interactive? + (ensure-interactive-transform-start!)) (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index f737947e3a..e66b378f54 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -1485,7 +1485,8 @@ (not (and (mth/close? (:width size) (:width previous)) (mth/close? (:height size) (:height previous))))) (rx/of #(assoc-in % [::layout-preview id] size) - (dwm/set-wasm-modifiers (dwwt/resize-wasm-text-modifiers shape))) + (dwm/set-wasm-modifiers (dwwt/resize-wasm-text-modifiers shape) + :interactive? false)) (rx/empty)))))) (defn v3-stop-text-layout-preview From fd03ce2a639d879d033ed85699c07c88808366b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 19 Oct 2026 17:17:35 +0200 Subject: [PATCH 085/167] :bug: Show text typed on a new line during the v3 layout preview (#12384) A modified shape is a cached copy, and edits through get_mut did not reset it. After Enter the preview stops sending modifiers while the size holds, so the canvas kept painting the old text. AI-assisted-by: claude-opus-5-5 --- render-wasm/src/state/shapes_pool.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index f39825ebaf..60824bd42f 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -144,6 +144,10 @@ impl ShapesPoolImpl { pub fn get_mut(&mut self, id: &Uuid) -> Option<&mut Shape> { let idx = *self.uuid_to_idx.get(id)?; + // The caller may change the shape, so its modified copy goes stale. + if let Some(cell) = self.modified_shape_cache.get_mut(&idx) { + *cell = OnceCell::new(); + } Some(&mut self.shapes[idx]) } @@ -555,3 +559,22 @@ impl Clone for ShapesPoolImpl { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_mut_refreshes_the_modified_shape() { + let mut pool = ShapesPool::new(); + pool.initialize(1); + let id = Uuid::new_v4(); + pool.add_shape(id).set_opacity(1.0); + + pool.set_modifiers(HashMap::from([(id, skia::Matrix::translate((10.0, 0.0)))])); + assert_eq!(pool.get(&id).expect("shape").opacity, 1.0); + + pool.get_mut(&id).expect("shape").set_opacity(0.5); + assert_eq!(pool.get(&id).expect("shape").opacity, 0.5); + } +} From 61a66f31ab1be963e90970dd9ae8c5894aa7d5c7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 15:22:10 +0000 Subject: [PATCH 086/167] :ambulance: Disable media storage quota check on upload Comment out the media-storage-bytes-per-team check in upload-file-media-object. The SUM over storage_object takes 3-12s per upload on prod (3.5TB DB, ~34 slow runs/hour) and blocks uploads. Keep the SQL and quote definition for re-enable with a cheaper implementation. Update the upload quota test to document the disabled state until the new implementation lands. AI-assisted-by: muse-spark-1.3-contributor --- backend/src/app/rpc/commands/media.clj | 27 ++++++++++++------- .../test/backend_tests/rpc_quotes_test.clj | 11 ++++---- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 99d5daea5a..ce75f53994 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -40,11 +40,13 @@ (declare create-file-media-object) -(def ^:private sql:get-team-id-for-file - "SELECT p.team_id - FROM file AS f - JOIN project AS p ON (p.id = f.project_id) - WHERE f.id = ?") +;; TEMPORARILY DISABLED (2026-10-19): only used by the disabled +;; media-storage-bytes-per-team quote check below. Kept for re-enable. +;; (def ^:private sql:get-team-id-for-file +;; "SELECT p.team_id +;; FROM file AS f +;; JOIN project AS p ON (p.id = f.project_id) +;; WHERE f.id = ?") (def ^:private schema:upload-file-media-object [:map {:title "upload-file-media-object"} @@ -64,11 +66,16 @@ (media.v/validate-media-type! content) (media.v/validate-media-size! content) - (let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))] - (quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team - ::quotes/profile-id profile-id - ::quotes/team-id team-id - ::quotes/incr (:size content)})) + ;; TEMPORARILY DISABLED (2026-10-19): media-storage-bytes-per-team + ;; quote performs a full SUM over storage_object per upload (3-12s in + ;; prod on a 3.5TB DB, ~34 slow executions/hour). Disabled until we + ;; land a cheaper approximation (cached counter / rewritten query). + ;; TODO: re-enable with the new implementation. + ;; (let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))] + ;; (quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team + ;; ::quotes/profile-id profile-id + ;; ::quotes/team-id team-id + ;; ::quotes/incr (:size content)})) (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] ;; We get the minimal file for proper checking if diff --git a/backend/test/backend_tests/rpc_quotes_test.clj b/backend/test/backend_tests/rpc_quotes_test.clj index b0740f1b22..ab6b8ede9f 100644 --- a/backend/test/backend_tests/rpc_quotes_test.clj +++ b/backend/test/backend_tests/rpc_quotes_test.clj @@ -442,6 +442,7 @@ (t/is (true? true) "deduped storage counted once, under quota")))) (t/deftest media-upload-enforces-storage-quote + ;; TEMPORARILY DISABLED (2026-10-19), see body. (with-mocks [mock {:target 'app.config/get :return (th/config-get-mock {:quotes-media-storage-bytes-per-team 100})}] @@ -466,12 +467,10 @@ out (th/command! params)] - ;; 312043 bytes > 100 byte limit → should be rejected - (t/is (not (th/success? out))) - (let [error (:error out)] - (t/is (= :restriction (th/ex-type error))) - (t/is (= :max-quote-reached (th/ex-code error))) - (t/is (= "media-storage-bytes-per-team" (:target (ex-data error)))))))) + ;; TEMPORARILY DISABLED (2026-10-19): upload path no longer checks + ;; media-storage-bytes-per-team, so even over quota the upload + ;; succeeds. TODO: restore rejection with cheaper implementation. + (t/is (th/success? out))))) ;; --- SNAPSHOTS (VERSIONS) QUOTES From 29cd50621dd2c014da8be9af752cee0af2c8a78a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 19 Oct 2026 17:23:26 +0200 Subject: [PATCH 087/167] :bug: Exclude inner shadows from WebGL export bounds (#12427) Inner shadows were joined into extrect via the drop-shadow filter, so a Y offset grew the PNG/SVG export surface. Only drop shadows expand bounds, matching classic export. Closes #12386. --- ...ports_leaf_inner_shadow_as_svg_filter.snap | 4 +- render-wasm/src/shapes.rs | 49 ++++++++++++++----- render-wasm/src/shapes/shadows.rs | 4 ++ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap index feb3c531dd..1127c671f0 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap @@ -3,6 +3,6 @@ source: src/render/svg/tests.rs expression: svg --- - - + + diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index e766b723d3..aaf4e55a49 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1005,16 +1005,14 @@ impl Shape { let max_stroke = Stroke::max_bounds_width(self.strokes.iter(), self.is_open()); let mut rect = bounds.to_rect(); - for shadow in self.shadows_visible() { - if !shadow.hidden() { - if let Some(filter) = shadow.get_drop_shadow_filter() { - let mut source = rect; - if mitered && shadow.spread > 0.0 { - let tip = (MITER_LIMIT - 1.0) * (max_stroke + shadow.spread); - source.outset((tip, tip)); - } - rect.join(filter.compute_fast_bounds(source)); + for shadow in self.drop_shadows_visible() { + if let Some(filter) = shadow.get_drop_shadow_filter() { + let mut source = rect; + if mitered && shadow.spread > 0.0 { + let tip = (MITER_LIMIT - 1.0) * (max_stroke + shadow.spread); + source.outset((tip, tip)); } + rect.join(filter.compute_fast_bounds(source)); } } Bounds::from_rect(&rect) @@ -1514,10 +1512,6 @@ impl Shape { .filter(|shadow| shadow.style() == ShadowStyle::Inner && !shadow.hidden()) } - pub fn shadows_visible(&self) -> impl DoubleEndedIterator { - self.shadows.iter().rev().filter(|shadow| !shadow.hidden()) - } - pub fn to_path_transform(&self) -> Option { match self.shape_type { Type::Path(_) | Type::Bool(_) => { @@ -2571,6 +2565,35 @@ mod tests { assert!(extrect.bottom > 50.0 + 20.0); } + /// Inner shadows are drawn inside the shape; they must not inflate export + /// size (WebGL PNG surface uses `extrect`). Regression for #12386. + #[test] + fn leaf_extrect_ignores_inner_shadow_offset() { + let mut pool = ShapesPool::new(); + pool.initialize(1); + + let shape_id = Uuid::new_v4(); + { + let shape = pool.add_shape(shape_id); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(0.0, 0.0, 200.0, 200.0); + shape.add_shadow(inner_shadow(0.0, (0.0, 1.0))); + } + + let shape = pool.get(&shape_id).expect("shape should exist"); + let baseline = { + let mut no_shadow = shape.clone(); + no_shadow.clear_shadows(); + no_shadow.calculate_extrect(&pool, 1.0) + }; + let extrect = shape.calculate_extrect(&pool, 1.0); + + assert_eq!(extrect.height(), baseline.height()); + assert_eq!(extrect.width(), baseline.width()); + assert_eq!(extrect.height(), 200.0); + assert_eq!(extrect.width(), 200.0); + } + #[test] fn test_apply_transform() { let mut shape = Shape::new(Uuid::new_v4()); diff --git a/render-wasm/src/shapes/shadows.rs b/render-wasm/src/shapes/shadows.rs index 11a0e13295..b13c6d76bf 100644 --- a/render-wasm/src/shapes/shadows.rs +++ b/render-wasm/src/shapes/shadows.rs @@ -87,6 +87,10 @@ impl Shadow { } pub fn get_drop_shadow_filter(&self) -> Option { + if self.style == ShadowStyle::Inner { + return None; + } + let sigma = radius_to_sigma(self.blur); let mut filter = image_filters::drop_shadow_only( (self.offset.0, self.offset.1), From 2d335e9e83403f19d67a37e37853b85a7579f567 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 15:37:13 +0000 Subject: [PATCH 088/167] :wrench: Reset explicit EXTERNAL storage to PG defaults Drop the legacy TOAST STORAGE EXTERNAL overrides set when PostgreSQL ran on ZFS with transparent compression. Migration 0156 restores the type default (SET STORAGE DEFAULT) on 62 columns across 21 tables; existing TOAST data keeps its strategy until rewritten. Raise the documented PostgreSQL floor to >=16, required by the new SET STORAGE DEFAULT syntax. AI-assisted-by: muse-spark-1.3-contributor --- backend/src/app/migrations.clj | 5 +- .../sql/0156-reset-pg-storage-defaults.sql | 129 ++++++++++++++++++ docs/technical-guide/configuration.md | 2 +- 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 861629bb54..9ccedd96e1 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -508,7 +508,10 @@ :fn (mg/resource "app/migrations/sql/0154-del-legacy-file-object-thumbnail-table.sql")} {:name "0155-add-idx-to-file-change" - :fn (mg/resource "app/migrations/sql/0155-add-idx-to-file-change.sql")}]) + :fn (mg/resource "app/migrations/sql/0155-add-idx-to-file-change.sql")} + + {:name "0156-reset-pg-storage-defaults" + :fn (mg/resource "app/migrations/sql/0156-reset-pg-storage-defaults.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql b/backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql new file mode 100644 index 0000000000..54bd98ace5 --- /dev/null +++ b/backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql @@ -0,0 +1,129 @@ +-- Reset explicit `STORAGE EXTERNAL` settings back to the PostgreSQL defaults. +-- +-- Those were set when PostgreSQL ran on top of ZFS with transparent +-- compression (see 0041-mod-pg-storage-options.sql and the per-table +-- migrations that followed it): skipping the TOAST compression done by +-- PostgreSQL made sense there. On regular filesystems it only wastes +-- space and I/O bandwidth, so restore the default storage strategy of +-- each column type (EXTENDED for every column below: text, text[], +-- jsonb and bytea). +-- +-- This only touches the catalog, it does not rewrite the tables: +-- already stored TOAST values keep their strategy until the row is +-- rewritten, new writes use the default. +-- +-- NOTE: DEFAULT restores the storage mode of the column's data type +-- (EXTENDED for every column below: text, text[], jsonb and bytea). It +-- requires PostgreSQL 16 or newer, which is the minimum version the +-- project supports (see the database section of the technical guide). +-- RESET STORAGE is not valid syntax for a column. + +ALTER TABLE access_token + ALTER COLUMN name SET STORAGE DEFAULT, + ALTER COLUMN perms SET STORAGE DEFAULT, + ALTER COLUMN token SET STORAGE DEFAULT; + +ALTER TABLE audit_log + ALTER COLUMN context SET STORAGE DEFAULT, + ALTER COLUMN name SET STORAGE DEFAULT, + ALTER COLUMN props SET STORAGE DEFAULT, + ALTER COLUMN source SET STORAGE DEFAULT, + ALTER COLUMN type SET STORAGE DEFAULT; + +ALTER TABLE comment + ALTER COLUMN content SET STORAGE DEFAULT; + +ALTER TABLE comment_thread + ALTER COLUMN page_name SET STORAGE DEFAULT, + ALTER COLUMN participants SET STORAGE DEFAULT; + +ALTER TABLE file + ALTER COLUMN data SET STORAGE DEFAULT, + ALTER COLUMN data_backend SET STORAGE DEFAULT, + ALTER COLUMN features SET STORAGE DEFAULT, + ALTER COLUMN name SET STORAGE DEFAULT; + +ALTER TABLE file_change + ALTER COLUMN changes SET STORAGE DEFAULT, + ALTER COLUMN data SET STORAGE DEFAULT, + ALTER COLUMN features SET STORAGE DEFAULT, + ALTER COLUMN label SET STORAGE DEFAULT; + +ALTER TABLE file_data_fragment + ALTER COLUMN metadata SET STORAGE DEFAULT; + +ALTER TABLE file_media_object + ALTER COLUMN mtype SET STORAGE DEFAULT, + ALTER COLUMN name SET STORAGE DEFAULT; + +ALTER TABLE file_thumbnail + ALTER COLUMN data SET STORAGE DEFAULT, + ALTER COLUMN props SET STORAGE DEFAULT; + +ALTER TABLE global_complaint_report + ALTER COLUMN content SET STORAGE DEFAULT, + ALTER COLUMN type SET STORAGE DEFAULT; + +ALTER TABLE profile + ALTER COLUMN auth_backend SET STORAGE DEFAULT, + ALTER COLUMN email SET STORAGE DEFAULT, + ALTER COLUMN fullname SET STORAGE DEFAULT, + ALTER COLUMN lang SET STORAGE DEFAULT, + ALTER COLUMN password SET STORAGE DEFAULT, + ALTER COLUMN props SET STORAGE DEFAULT, + ALTER COLUMN theme SET STORAGE DEFAULT; + +ALTER TABLE profile_complaint_report + ALTER COLUMN content SET STORAGE DEFAULT, + ALTER COLUMN type SET STORAGE DEFAULT; + +ALTER TABLE project + ALTER COLUMN name SET STORAGE DEFAULT; + +ALTER TABLE scheduled_task + ALTER COLUMN cron_expr SET STORAGE DEFAULT, + ALTER COLUMN id SET STORAGE DEFAULT; + +ALTER TABLE server_error_report + ALTER COLUMN content SET STORAGE DEFAULT; + +ALTER TABLE server_prop + ALTER COLUMN content SET STORAGE DEFAULT, + ALTER COLUMN id SET STORAGE DEFAULT; + +ALTER TABLE storage_object + ALTER COLUMN backend SET STORAGE DEFAULT, + ALTER COLUMN metadata SET STORAGE DEFAULT; + +ALTER TABLE task + ALTER COLUMN error SET STORAGE DEFAULT, + ALTER COLUMN label SET STORAGE DEFAULT, + ALTER COLUMN name SET STORAGE DEFAULT, + ALTER COLUMN props SET STORAGE DEFAULT, + ALTER COLUMN queue SET STORAGE DEFAULT, + ALTER COLUMN status SET STORAGE DEFAULT; + +ALTER TABLE team + ALTER COLUMN name SET STORAGE DEFAULT; + +ALTER TABLE team_font_variant + ALTER COLUMN font_family SET STORAGE DEFAULT, + ALTER COLUMN font_style SET STORAGE DEFAULT; + +ALTER TABLE team_invitation + ALTER COLUMN email_to SET STORAGE DEFAULT, + ALTER COLUMN role SET STORAGE DEFAULT; + +ALTER TABLE usage_quote + ALTER COLUMN target SET STORAGE DEFAULT; + +ALTER TABLE webhook + ALTER COLUMN error_code SET STORAGE DEFAULT, + ALTER COLUMN mtype SET STORAGE DEFAULT, + ALTER COLUMN secret_key SET STORAGE DEFAULT, + ALTER COLUMN uri SET STORAGE DEFAULT; + +ALTER TABLE webhook_delivery + ALTER COLUMN error_code SET STORAGE DEFAULT, + ALTER COLUMN req_data SET STORAGE DEFAULT, + ALTER COLUMN rsp_data SET STORAGE DEFAULT; diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 294885c897..59939a522b 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -484,7 +484,7 @@ PENPOT_SECRET_KEY: my-super-secure-key ### Database -Penpot only supports PostgreSQL and we highly recommend >=13 version. If you are using official +Penpot only supports PostgreSQL and we highly recommend >=16 version. If you are using official docker images this is already solved for you. Essential database configuration: From 534d46c7b3e40816ec4f2d6c2744724022521cf3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 15:59:16 +0000 Subject: [PATCH 089/167] :books: Update changelog Add the missing 2.18.4 entries for the closed storage GC, version quota and file_change revision issues, with their fix PRs referenced inline. AI-assisted-by: muse-spark-1.3-contributor --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 3a2b8dab1c..d0ea0e6aee 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,9 @@ - Fix file restore leaving its storage objects marked for deletion [#12278](https://github.com/penpot/penpot/issues/12278) (PR: [#12269](https://github.com/penpot/penpot/pull/12269)) - Fix backend workers staying parked forever when nested calls take the same concurrency limits [#12279](https://github.com/penpot/penpot/issues/12279) (PR: [#12269](https://github.com/penpot/penpot/pull/12269)) - Fix one profile's activity blocking the audit events of every other profile [#12280](https://github.com/penpot/penpot/issues/12280) (PR: [#12269](https://github.com/penpot/penpot/pull/12269)) +- Fix storage garbage collection blocking database vacuum during long deletions [#12396](https://github.com/penpot/penpot/issues/12396) (PR: [#12397](https://github.com/penpot/penpot/pull/12397)) +- Fix saving a version taking seconds and the version quota never counting saved versions [#12404](https://github.com/penpot/penpot/issues/12404) (PR: [#12405](https://github.com/penpot/penpot/pull/12405)) +- Fix file_change accumulating revisions that the objects GC never collects [#12418](https://github.com/penpot/penpot/issues/12418) (PR: [#12419](https://github.com/penpot/penpot/pull/12419)) ### :sparkles: New features & Enhancements From 0801674af2b88f4520cd47e403a6331f67bd430c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Mon, 19 Oct 2026 18:21:02 +0200 Subject: [PATCH 090/167] :bug: Ignore export scale in render-wasm PDF export (#12447) --- exporter/src/app/wasm.cljs | 5 ++--- exporter/src/app/wasm/render.cljs | 2 +- frontend/src/app/main/data/exports/wasm.cljs | 4 ++-- frontend/src/app/render_wasm/api.cljs | 5 ++--- render-wasm/src/main.rs | 4 ++-- render-wasm/src/render/pdf.rs | 11 +++-------- render-wasm/src/render/vector.rs | 3 +-- render-wasm/src/state.rs | 4 ++-- 8 files changed, 15 insertions(+), 23 deletions(-) diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs index 0160a89a4a..88ae874009 100644 --- a/exporter/src/app/wasm.cljs +++ b/exporter/src/app/wasm.cljs @@ -287,11 +287,10 @@ (defn render-shape-pdf "Renders the shape subtree to PDF bytes (Uint8Array)." - [shape-id scale] + [shape-id] (let [buf (uuid/get-u32 shape-id)] (-> (h/call wasm/internal-module "_render_shape_pdf" - (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) - scale) + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)) (read-render-result LEN-HEADER-BYTES)))) (defn render-shape-svg diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs index 77e53a9e6b..7889a6e3c5 100644 --- a/exporter/src/app/wasm/render.cljs +++ b/exporter/src/app/wasm/render.cljs @@ -418,7 +418,7 @@ (defn- render-object-bytes [type id scale] (case type - :pdf (let [bytes (wasm/render-shape-pdf id scale)] + :pdf (let [bytes (wasm/render-shape-pdf id)] (l/dbg :hint "PDF generated via Skia (render-wasm headless)" :object-id (str id) :backend "skia-wasm" diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index ff56fd5796..696b8ed716 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -30,8 +30,8 @@ nil)) (defn export-pdf-uri - [{:keys [scale object-id]}] - (let [bytes (wasm.api/render-shape-pdf object-id (or scale 1)) + [{:keys [object-id]}] + (let [bytes (wasm.api/render-shape-pdf object-id) blob (wapi/create-blob bytes "application/pdf")] (wapi/create-uri blob))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 78623aa43e..2227b4155e 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -3010,7 +3010,7 @@ {:x x :y y :width w :height h}))))) (defn render-shape-pdf - [shape-id scale] + [shape-id] (when (initialized?) (let [buffer (uuid/get-u32 shape-id) @@ -3019,8 +3019,7 @@ (aget buffer 0) (aget buffer 1) (aget buffer 2) - (aget buffer 3) - scale) + (aget buffer 3)) heap (mem/get-heap-u8) heapu32 (mem/get-heap-u32) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 125f553a0b..11a8256842 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -818,11 +818,11 @@ pub fn free_gpu_resources() { #[no_mangle] #[wasm_error] -pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> { +pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32) -> Result<*mut u8> { let id = uuid_from_u32_quartet(a, b, c, d); with_state!(state, { - let data = state.render_shape_pdf(&id, scale)?; + let data = state.render_shape_pdf(&id)?; let len = data.len() as u32; let mut buf = Vec::with_capacity(4 + data.len()); diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index cf53731c30..b8e204dcb6 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -19,17 +19,13 @@ pub fn render_to_pdf( shared: &mut RenderResources, id: &Uuid, tree: ShapesPoolRef, - scale: f32, ) -> Result> { let shape = tree .get(id) .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?; // Boards crop overflowing children (classic board box) but keep their own // strokes/shadows/blur. Other shapes use full extrect. - let bounds = shape.export_root_bounds(tree, scale); - - let page_w = bounds.width() * scale; - let page_h = bounds.height() * scale; + let bounds = shape.export_root_bounds(tree, 1.0); let mut pdf_bytes: Vec = Vec::new(); @@ -42,13 +38,12 @@ pub fn render_to_pdf( let document = skia::pdf::new_document(&mut pdf_bytes, Some(&metadata)); - let mut on_page = document.begin_page((page_w, page_h), None); + let mut on_page = document.begin_page((bounds.width(), bounds.height()), None); { let page_canvas = on_page.canvas(); - page_canvas.scale((scale, scale)); page_canvas.translate((-bounds.left(), -bounds.top())); - vector::render_tree_pdf(shared, page_canvas, id, tree, scale, bounds)?; + vector::render_tree_pdf(shared, page_canvas, id, tree, bounds)?; } let document = on_page.end_page(); diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 7eddda2b8b..e9d873f353 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -474,10 +474,9 @@ pub(super) fn render_tree_pdf( canvas: &Canvas, id: &Uuid, tree: ShapesPoolRef, - scale: f32, page: skia::Rect, ) -> Result<()> { - render_tree_dispatch(shared, canvas, id, tree, scale, page, true) + render_tree_dispatch(shared, canvas, id, tree, 1.0, page, true) } #[allow(clippy::too_many_arguments)] diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 5b2684ecb4..9efce2b024 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -101,8 +101,8 @@ impl State { get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp, format) } - pub fn render_shape_pdf(&mut self, id: &Uuid, scale: f32) -> Result> { - crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale) + pub fn render_shape_pdf(&mut self, id: &Uuid) -> Result> { + crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes) } pub fn render_shape_svg(&mut self, id: &Uuid, scale: f32) -> Result> { From 4635eaa4679cbc89bb48edf93270c7d869890c5f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 22:33:10 +0200 Subject: [PATCH 091/167] :bug: Show request access modal on viewer and dashboard urls (#12456) The exception page compared the screen query param against literals that never occur ("view", "dashboard"), so viewer and dashboard urls of inaccessible files and teams fell through to the plain not-found page instead of the request access modal. Classify the param with a tested helper that recognizes "viewer" and every "dashboard-*" route. Closes #11224 AI-assisted-by: muse-spark-1.3-contributor --- frontend/src/app/main/ui/static.cljs | 30 +++++++++-- frontend/test/frontend_tests/runner.cljs | 2 + .../test/frontend_tests/ui/static_test.cljs | 54 +++++++++++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/static_test.cljs diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index ec5e20a72a..fe75e1f7b2 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -34,6 +34,7 @@ [app.util.timers :as tm] [app.util.webapi :as wapi] [beicon.v2.core :as rx] + [cuerdas.core :as str] [rumext.v2 :as mf])) ;; FIXME: this is a workaround until we export this class on beicon library @@ -608,16 +609,37 @@ [:> internal-error* props]))) +(defn screen-kind + "Classify the `screen` query param for the exception page. + + The param carries the route name (`?screen=¶ms`), so the + viewer is \"viewer\" and every team dashboard is \"dashboard-*\". Returns + `:workspace`, `:dashboard`, `:viewer`, or nil when the screen is not a + request-access candidate. Reads the param with `rt/get-query-param` + because a repeated param arrives as a vector." + [query-params] + (let [screen (rt/get-query-param query-params :screen)] + (cond + (= screen "workspace") + :workspace + + (and (string? screen) + (str/starts-with? screen "dashboard")) + :dashboard + + (= screen "viewer") + :viewer))) + (mf/defc exception-page* [{:keys [data route] :as props}] (let [type (:type data) params (:query-params route) - screen (get params :screen) - workspace? (= screen "workspace") - dashboard? (= screen "dashboard") - view? (= screen "view") + kind (screen-kind params) + workspace? (= kind :workspace) + dashboard? (= kind :dashboard) + view? (= kind :viewer) ;; We store the request access info int this state info* (mf/use-state nil) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index ad9b96d56f..8cea58e9b6 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -125,6 +125,7 @@ [frontend-tests.ui.shape-filters-test] [frontend-tests.ui.shortcuts-labels-test] [frontend-tests.ui.sidebar-scroll-test] + [frontend-tests.ui.static-test] [frontend-tests.ui.stroke-menu-test] [frontend-tests.ui.workspace-libraries-test] [frontend-tests.util-clipboard-test] @@ -272,6 +273,7 @@ 'frontend-tests.ui.shape-filters-test 'frontend-tests.ui.shortcuts-labels-test 'frontend-tests.ui.sidebar-scroll-test + 'frontend-tests.ui.static-test 'frontend-tests.ui.stroke-menu-test 'frontend-tests.ui.workspace-libraries-test 'frontend-tests.util-clipboard-test diff --git a/frontend/test/frontend_tests/ui/static_test.cljs b/frontend/test/frontend_tests/ui/static_test.cljs new file mode 100644 index 0000000000..acdc1b0b2c --- /dev/null +++ b/frontend/test/frontend_tests/ui/static_test.cljs @@ -0,0 +1,54 @@ +;; 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.static-test + (:require + [app.main.ui.static :as static] + [cljs.test :as t :include-macros true])) + +(t/deftest screen-kind-classifies-known-screens + ;; Regression test (penpot/penpot#11224): the exception page shows the + ;; REQUEST ACCESS modal only when it recognizes the screen the failing + ;; URL belongs to. The `screen` query param carries the route name, so + ;; the viewer is "viewer" and every team dashboard is "dashboard-*". + (t/testing "workspace" + (t/is (= :workspace (static/screen-kind {:screen "workspace"})))) + + (t/testing "bare dashboard prefix also classifies" + ;; No such route exists (routes are always "dashboard-*"), and the + ;; modal still needs loaded team/file info, so this is harmless and + ;; matches the prefix convention used in routes.cljs. + (t/is (= :dashboard (static/screen-kind {:screen "dashboard"})))) + + (t/testing "viewer" + (t/is (= :viewer (static/screen-kind {:screen "viewer"})))) + + (t/testing "dashboard variants" + (doseq [screen ["dashboard-recent" + "dashboard-members" + "dashboard-files" + "dashboard-settings"]] + (t/is (= :dashboard (static/screen-kind {:screen screen})) + (str "screen: " screen))))) + +(t/deftest screen-kind-ignores-unknown-screens + ;; Unknown, missing, or auth screens are not request-access candidates: + ;; the exception page must keep showing the plain not-found page. + (doseq [params [nil + {} + {:screen "auth-login"} + {:screen "settings-profile"} + ;; The "view" literal never was a real screen name; the + ;; exception page compared against it before the fix. + {:screen "view"}]] + (t/is (nil? (static/screen-kind params)) + (str "params: " (pr-str params))))) + +(t/deftest screen-kind-tolerates-duplicate-screen-params + ;; A repeated query param arrives as a vector; the classifier reads the + ;; scalar the same way the router does instead of crashing on it. + (t/is (= :dashboard + (static/screen-kind {:screen ["dashboard-recent" "dashboard-recent"]})))) From ccf99c7dcf35e8ddcbd3f609f1aec89b949be101 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 22:35:58 +0200 Subject: [PATCH 092/167] :recycle: Archive audit log from a pending queue with two jobs (#12373) * :recycle: Archive audit log from a pending queue with two jobs Replace the mark-and-sweep audit archive with a pending queue (audit_log_pending, migration 0157): every row is born pending for the archivers enabled at insert time, in the same transaction, so a rollback takes both. Two jobs share one namespace, one queue each: :audit-log-archive-nexus and :audit-log-archive-nitrate, each with its own closed params schema, deps and every-5m cron entry gated by its send flag. Each job drains its queue oldest-first in 128-row chunks (one transaction per chunk, same total order, no SKIP LOCKED so arrival stays chronological) and deletes its pendings on completion; whoever deletes the last pending deletes the row. Housekeeping lives in both jobs: reap of disabled pendings plus sweep of orphans, both idempotent. A refused chunk raises :archive-chunk-refused and aborts the job; the cron retries. No separate GC task. Metrics with bounded labels only: archived/failed/skipped per destination, discarded per reason (reaped pendings, swept rows: the silent R3 loss, now dashboard-visible). Timing comes from the jobs substrate; queue depth is read off the replica DB. No gauge, no custom histogram. Destination hosts log once at job-def init; per-run logs carry counts plus frozen elapsed (log props evaluate lazily in the logger thread). Pre-existing rows are swept unsent (no backfill, accepted loss); rows born with no archiver on share that fate (R3). Memory mem:audit-log documents the design. AI-assisted-by: muse-spark-1.3-contributor * :recycle: Address review comments on audit log archive jobs Rename the second archive destination from nitrate to admin-console (jobs, metric and log labels, error code), reusing the :admin-console flag. Unify the pending-queue insert into a single helper used by both producers. Stagger the archive crons (Nexus at second 0, admin console at second 30). Add per-call dbg logs to the admin-console send. Update the audit-log memory and the affected tests. Closes #12372 AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/audit-log.md | 46 +- backend/src/app/loggers/audit.clj | 14 +- .../src/app/loggers/audit/archive_task.clj | 536 +++++++++++++--- backend/src/app/loggers/audit/gc_task.clj | 46 -- backend/src/app/main.clj | 78 ++- backend/src/app/migrations.clj | 5 +- .../sql/0157-add-audit-log-pending-table.sql | 39 ++ backend/src/app/rpc/commands/audit.clj | 58 +- backend/src/app/tasks/telemetry.clj | 40 +- .../loggers_audit_archive_task_test.clj | 605 ++++++++++++++---- backend/test/backend_tests/rpc_audit_test.clj | 166 ++++- .../backend_tests/tasks_telemetry_test.clj | 43 ++ 12 files changed, 1288 insertions(+), 388 deletions(-) delete mode 100644 backend/src/app/loggers/audit/gc_task.clj create mode 100644 backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index 898e95e413..bc33a4a190 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -12,14 +12,15 @@ The system has: - **Three producers** (they create events): the Penpot backend (RPC layer), the Penpot frontend (browser app), and the Nitrate admin-console, which sends both server-side and client-side events. - **One central buffer**: the Postgres `audit_log` table in Penpot. It is temporary: it holds events for minutes/hours until they are archived, then they are deleted. -- **Four consumers** (they read events): webhooks, error reporters, telemetry, and the external archive in Nexus. -- **One long-term archive**: **Nexus**, a separate service that stores events for the long term. Archiving to **Nitrate** is in development and **does not exist on the current branch** (see section 11). +- **Four consumers** (they read events): webhooks, error reporters, telemetry, and the external archives in Nexus and in the admin console. +- **Two long-term archives**: **Nexus**, a separate service that stores events for the long term, and the **admin console** (Nitrate), which ingests its own allowlisted events through a second archive job (see section 12). ``` Penpot frontend ─ POST push-audit-events (public) ─────────▶┐ Nitrate admin-console (browser) ─ POST push-audit-events ──▶│ -Nitrate admin-console (server) ─ POST push-audit-events ──▶│ ─▶ audit_log table (buffer) ── cron every 5m ──▶ Nexus (archive) -Penpot backend (RPC wrap-audit) ─ submit ───────────────────┘ │ ├──▶ webhooks +Nitrate admin-console (server) ─ POST push-audit-events ──▶│ ─▶ audit_log table (buffer) ──┬─ cron 0s every 5m ──▶ Nexus (archive) +Penpot backend (RPC wrap-audit) ─ submit ───────────────────┘ │ └─ cron 30s every 5m ─▶ admin console (archive) + │ ├──▶ webhooks │ ├──▶ error reporters └──▶ telemetry (shadow rows) ``` @@ -44,7 +45,7 @@ Rule of thumb: if you need to know for sure "did this user do X?", look at `sour ## 3. The `audit_log` table: a buffer, not a store -The Penpot `audit_log` table **is not the final destination**. It is a waiting tray: events sit here until the archive cron sends them to Nexus and the cleanup cron (GC) deletes them. +The Penpot `audit_log` table **is not the final destination**. It is a waiting tray: events sit here until every active archiver (Nexus, admin console) confirms them, and whoever finishes last deletes the row. Main columns (migration `backend/src/app/migrations/sql/0054-add-audit-log-table.sql` and later ones): @@ -56,15 +57,16 @@ Main columns (migration `backend/src/app/migrations/sql/0054-add-audit-log-table - `source` text: `backend` / `frontend` for full rows, `telemetry:backend` / `telemetry:frontend` for anonymized copies (see section 10). - `ip_addr` inet: real IP (or `0.0.0.0` on telemetry rows). - `props` / `context` jsonb holding transit-encoded maps: action data and metadata (versions, browser, request-id...). -- `archived_at` timestamptz: NULL until Nexus confirms receipt; only then the row can be deleted. +- No `archived_at`: the `audit_log_pending` queue (migration `0157`, archiver `1` = Nexus, `2` = admin console, ids never reused) tracks what is left. Every record is born pending only for the archivers enabled at insert time (never a fixed both): cheaper on single-archiver hosts and true to the design doc. Consequence, accepted on purpose: rows born with no send flag on get no pendings, so enabling archiving later does not pick them up — the sweep deletes them unarchived. Indexes (each consumer has its own, so a slow consumer never blocks the others): -- Partial `created_at WHERE archived_at IS NULL`: used by the archive scan. -- Partial `archived_at WHERE archived_at IS NOT NULL`: used by the GC. +- `audit_log_pending` PK `(archiver, audit_log_id)`: each archiver owns its queue at constant cost, even with millions pending. +- `audit_log (created_at, id)` (migration `0157`): the chronological drain order (`ORDER BY created_at ASC, id ASC`; same total order for both archivers, so concurrent drains cannot deadlock). +- `audit_log_pending (audit_log_id)`: serves the "is anything still pending?" check of the final delete. - `(source, created_at)`: used by the telemetry scan. -Life of a row: `INSERT` → (every 5 min) archive sets `archived_at = now()` **only if Nexus answers 204**, in the same transaction → (every 5 min) GC runs `DELETE WHERE archived_at IS NOT NULL` **with no age filter**. So archive must run before GC, or data is deleted without being sent. +Life of a row: `INSERT` (row plus one pending per enabled archiver, same transaction; a rollback takes both) → (every 5 min, Nexus at second 0 and admin console at second 30) each archive job sends its own 128-row batches oldest-first and deletes its pendings in the same transaction → whoever deletes the last pending deletes the row. A batch the destination refuses rolls back and stays for the next tick. Pendings of archivers that are off are reaped at the start of each job run (covers later deactivations), and orphan rows are swept at the end of each run, so there is no separate GC task. The FK is plain `DEFERRABLE` (never `INITIALLY DEFERRED`): every deleter removes pendings before rows, including the telemetry task's own deletes. ## 4. Anatomy of an event @@ -271,17 +273,19 @@ Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and insert - **No personal data is expected.** Profile ids are random UUIDs with no PII, so the telemetry consumer needs no login guard; grouping is by `instance`, not by person (see posthog-telemetry below). - **The endpoint always answers 200.** Failures are swallowed and only counted (`nexus_telemetry_errors_total`), so a bad batch never breaks the sending instance; successes are counted (`nexus_telemetry_events_total`, `nexus_telemetry_reports_total`). A body the parser cannot read never reaches the handler: it answers 400 and counts in `nexus_http_malformed_requests_total`. Old instances may send a fressian+zstd base64 blob instead of a plain vector; Nexus decodes both (50 MiB cap against decompression bombs). -## 12. Archive to Nexus (today) and to Nitrate (future) +## 12. Archive to Nexus and to the admin console -### Nexus: how it works today +### How it works today -Nexus is the long-term store. Every 5 min the `:audit-log-archive` cron takes 128-row chunks of unarchived rows (`FOR UPDATE SKIP LOCKED`, ordered by `created_at`), POSTs them as transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri, and marks `archived_at = now()` **only on HTTP 204**, in the same transaction. Anything else is retried next run; flag on but no URI raises `:task-not-configured`. It loops with 100 ms pauses until empty. +Every 5 min each archive job drains its own queue (`:audit-log-archive-nexus` on flag `:nexus`, cron `0 */5 * * * ?`; `:audit-log-archive-admin-console` on flag `:admin-console`, cron `30 */5 * * * ?`, 30s offset so the two drains never start on the same second), in 128-row chunks taken oldest-first (`ORDER BY created_at ASC, id ASC`) with `FOR UPDATE OF audit_log` (`SET LOCAL lock_timeout = '30s'`) inside the same transaction that sends and completes them, with 100 ms pauses between chunks. Batches arrive at Nexus in chronological order (it numbers by arrival for order-sensitive consumers). Nexus receives transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri; flag on but no URI raises `:task-not-configured`. The admin console receives only its allowlisted event names (enriched with team names and emails); anything filtered out counts as done for the admin-console queue. A chunk whose destination refuses it (Nexus non-204 raises `:archive-chunk-refused`) or throws (transport failure, admin-console error) rolls back and stays for the next tick, and the job aborts as failed: it archives what it can, and the cron retries the rest. Committed chunks stay sent. The send fns return counts or throw carrying the attempted count as `:events`; `run-single-archiver` records the `failed` metric from it, outside every chunk transaction (a local DB failure carries no `:events` and stays uncounted — it is not a destination failure). No `warn` before the throw: the runner reports the aborted job. Each job touches only its own queue, so a fallen Nexus never starves the admin console (nor vice versa). A chunk is **one POST** and the task advances only on 204, so a single oversized event wedges the whole queue: a crash report whose message embeds a whole payload (a serialized file, for example) can push the request past the proxy body limit and the rejection blocks every later event until it is pruned by hand. Frontend crash reports are therefore size-bounded at the source (`mem:frontend/routing-app-shell-subtleties`): `app.common.exceptions/get-hint` and the CLJS `format-throwable` clamp exception messages (`max-message-length`). On the other side, Nexus checks `origin` against its allowlist (403 if not allowed), coerces the events, and inserts them into its own `audit_log` (partitioned by `received_at`, with an incrementing sequence number and `source`). Watch the rename: Penpot's `created-at` is stored as **`received-at`** in Nexus; Nexus's `created-at` is when it was created there. `tracked-at` keeps the origin time. Telemetry batches arriving at `telemetry.penpot.app` land in the same table through the telemetry endpoint above. -Concurrent runs are safe: rows are locked with `FOR UPDATE SKIP LOCKED` inside the same transaction that sends and marks them, so two archivers running at once each take different rows. Only rows Nexus acknowledges (HTTP 204) get marked; anything else is retried on the next run. +Concurrent jobs are safe: both lock rows in `created_at ASC, id ASC` order (one total order, deliberately blocking — no `SKIP LOCKED`, so archive arrival stays chronological), so the second waits for the first to commit and then sees whether it was last. Only acknowledged work deletes pendings; an admin-console failure never blocks the Nexus send of the same rows (each job owns its queue). + +Observability (`backend/src/app/loggers/audit/archive_task.clj`, definitions in `backend/src/app/main.clj:default-metrics`): one `dbg` summary per job run (`:archiver` label, sent/skipped/chunks, reaped disabled pendings, swept orphans, `:elapsed` as `"1.5s"` via `ct/format-duration`, frozen in a strict binding first because log props evaluate lazily in the logger thread), one `dbg` per admin-console send call (`ready` with totals, `sent`/`failed` with counts), plus `dbg` when the queue is empty or the job's send flag is off. Queue depth is Grafana's business: it queries the database directly, so the run performs no `GROUP BY` for the logs. No `warn` before a throw: the runner reports the aborted job (refusal details travel in the error data). Metrics, all recorded with bounded labels only: `penpot_audit_archive_events_total{ destination = nexus|admin-console, outcome = archived|failed|skipped }` (`skipped` = filtered by the admin-console allowlist) and `penpot_audit_archive_discarded_total{ reason = reaped|swept }` (entries discarded without archiving: `reaped` counts pendings dropped for disabled archivers, `swept` counts rows deleted while nobody waited for them — the silent R3 loss, now dashboard-visible instead of log-only). Timing needs no custom histogram: the jobs substrate already records execution time per job (`jobs-execution-timing`, split by job name), so each archive job is timed as itself. No queue-depth gauge: pending depth is read off the replica database directly. The bulk frontend ingest counts `penpot_audit_ingest_events_total{ kind = full|shadow, outcome = inserted|failed }` (both faces share one transaction, so a failure drops the batch on both). Archive success counts are recorded by `run-single-archiver` on the drain totals: a chunk that returns normally committed its transaction (a rollback always throws instead), so they cannot inflate. Logs never carry event data (`props`, `context`, `profile-id`, `ip-addr`, `team-name`, emails): only counts, statuses and the uri host. ### How Nexus consumes the data (and forwards to PostHog) @@ -291,20 +295,16 @@ Stored rows are not the end: a consumer pipeline reads them back in sequence ord 2. **`posthog`** (runs after `local`): forwards our own SaaS archived events to PostHog. It sends an allowlist of backend track events (teams, members, projects, files, comments, Nitrate/SSO, ...) plus frontend events minus a noise exclusion list, grouped by **team**, with person identify data from the local profile mirror and snake_case props. Both paths include the `initiator` property when the stored context has one, so PostHog can split Penpot vs admin-console traffic. 3. **`posthog-telemetry`**: forwards events with `source = telemetry:frontend|telemetry:backend` to a separate PostHog project. Because these rows are already anonymous (no IPs, truncated times, filtered props), grouping is by **instance** (the self-hosted server, from `context.instance-id`), with one `$groupidentify` event per instance per chunk so PostHog learns per-instance properties (version, URI, flags). Each consumer has its own PostHog credentials. -So the full journey is: Penpot buffer → Nexus `audit_log` (via archive cron or telemetry endpoint) → consumers → PostHog (SaaS events grouped by team, telemetry grouped by instance) plus local analytics views and materialized KPIs. +So the full journey is: Penpot buffer → Nexus `audit_log` (via either archive cron or the telemetry endpoint) → consumers → PostHog (SaaS events grouped by team, telemetry grouped by instance) plus local analytics views and materialized KPIs. -### Nitrate: what exists and what is planned +### Admin console: the second archiver -Today **only Nexus archives**. Archiving Penpot → Nitrate is in development and not on this branch. - -What already exists (do not confuse with archiving): +Penpot → admin-console archiving lives in its own job (`:audit-log-archive-admin-console`, archiver `2`, flag `:admin-console`, params closed empty): it drains its own pending queue and ingests allowlisted events through `nitrate/call :ingest-audit-log`, enriched as described above. What also exists (do not confuse with archiving): - **Nitrate → Penpot**: `POST /api/management/methods/push-audit-events` (`backend/src/app/rpc/management/nitrate.clj`) lets the trusted Nitrate backend inject events into the Penpot audit log (no RPC auth, `::audit/skip`, request context + `submit`). Section 7a is the main user. - **Admin-console browser → collector**: usage events from the admin-console UI traveling through the normal public `push-audit-events` (section 7b). - **Penpot frontend Nitrate bits**: membership/subscription events (`frontend/src/app/main/data/nitrate.cljs`, `nitrate-audit/*`) through the same public endpoint. -The future Penpot → Nitrate archive will follow the Nexus pattern (cron reading the buffer and POSTing batches), but there is no contract or flag on this branch yet. When it lands, document here: endpoint, auth, format, which `source` values travel, and how it shares the GC. - ## 13. Flags and config Flags (`common/src/app/common/flags.cljc`, varia; enabled with `PENPOT_FLAGS=enable-`): @@ -312,8 +312,8 @@ Flags (`common/src/app/common/flags.cljc`, varia; enabled with `PENPOT_FLAGS=ena | Flag | What it does | |---|---| | `:audit-log` | Stores full rows; turns on `wrap-audit` and frontend ingest | -| `:audit-log-archive` | Turns on the cron that sends to Nexus (needs `:audit-log-archive-uri`) | -| `:audit-log-gc` | Turns on the cron that deletes archived rows | +| `:nexus` | Send flag of the Nexus archive job `:audit-log-archive-nexus` (cron `0 */5 * * * ?`; needs `:audit-log-archive-uri`) | +| `:admin-console` | Send flag of the admin-console archive job `:audit-log-archive-admin-console` (cron `30 */5 * * * ?`, 30s after Nexus); also the module flag for the admin-console integration | | `:audit-log-logger` | Structured `app.audit` log per event | | `:error-reporting` | Starts the error reporters (off by default); database reporter on flag alone, Mattermost on flag plus webhook URL | | `:telemetry` | Stores shadow rows and turns on shipping (auto with `:telemetry-enabled`) | @@ -337,6 +337,6 @@ Config (`backend/src/app/config.clj`): `:audit-log-archive-uri`, `:telemetry-uri - Penpot frontend producer: `frontend/src/app/main/data/event.cljs` (collector, buffer, send), `frontend/src/app/main/repo.cljs` + `frontend/src/app/util/http.cljs` (headers), `frontend/src/app/main/data/nitrate.cljs` (Nitrate membership events). - Admin-console producer (private repo, no paths cited here): server-side organization/subscription events through the management `push-audit-events`, browser-side UI events through the public `push-audit-events`. - Consumers: `backend/src/app/loggers/webhooks.clj`, `backend/src/app/loggers/database.clj`, `backend/src/app/loggers/mattermost.clj`, `backend/src/app/tasks/telemetry.clj`. -- Archive/GC: `backend/src/app/loggers/audit/archive_task.clj`, `backend/src/app/loggers/audit/gc_task.clj`, wiring and crons in `backend/src/app/main.clj`. +- Archive (two jobs sharing one namespace + pending queue, no GC): `backend/src/app/loggers/audit/archive_task.clj` (`:audit-log-archive-nexus` / `:audit-log-archive-admin-console` job-defs, shared drain/reap/sweep helpers), producer hook in `backend/src/app/loggers/audit.clj` (`append-audit-entry`, single `queue-pendings` helper) plus the bulk ingest in `backend/src/app/rpc/commands/audit.clj` (`handle-events`), metrics/cron wiring in `backend/src/app/main.clj` (one cron entry per job, Nexus at second 0 and admin console at second 30, each gated by its own send flag), single migration `0157` (queue, drop of `archived_at`, covering `(created_at, id)` index for the chronological scan; no backfill — pre-existing rows are swept unsent, an accepted loss). - Nexus (private repo, no paths cited here): archive ingest, ordered consumers (local profile mirror, PostHog SaaS events, PostHog telemetry), telemetry endpoint, analytics views. - Tests: `backend_tests/rpc-audit-test.clj`, `rpc-team-test/accept-organization-invitation-audit-event`. diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 7c77a710d7..619c367a49 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -21,6 +21,7 @@ [app.http :as-alias http] [app.http.access-token :as-alias actoken] [app.jobs :as jobs] + [app.loggers.audit.archive-task :as archive] [app.loggers.audit.tasks :as-alias tasks] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] @@ -241,8 +242,13 @@ (update :props db/tjson) (update :context db/tjson) (update :ip-addr db/inet)) - params (select-keys params event-keys)] - (db/insert! cfg :audit-log params))) + params (select-keys params event-keys) + row (db/insert! cfg :audit-log params)] + ;; The archive queue follows the row in the same transaction: one + ;; pending per enabled archiver. A rollback takes both on its way + ;; out, so a row never outlives its queue. + (archive/queue-pendings cfg [(:id row)]) + row)) (def ^:private xf:filter-telemetry-props "Transducer that keeps only map entries whose values are UUIDs, @@ -582,6 +588,8 @@ logic." [cfg event] (when (contains? cf/flags :audit-log) + ;; Transactional (not a bare connection): the row and its + ;; pendings commit or vanish together (see append-audit-entry). (let [tnow (ct/now) event (-> event (assoc :created-at tnow) @@ -592,4 +600,4 @@ (update :source d/nilv "backend") (select-keys event-keys) (check-event))] - (db/run! cfg append-audit-entry event)))) + (db/tx-run! cfg append-audit-entry event)))) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 6c29c29d6b..a4eb14e5e6 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -9,20 +9,77 @@ [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.schema :as sm] + [app.common.time :as ct] [app.common.transit :as t] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.db.sql :as sql] [app.http.client :as http] [app.jobs :as jobs] + [app.metrics :as mtx] [app.nitrate :as nitrate] [app.setup :as-alias setup] [integrant.core :as ig] [promesa.exec :as px])) -;; This is a task responsible to send the accumulated events to -;; external service for archival. +;; This task drains the audit log buffer into the long-term archives. +;; +;; Two separate jobs share this namespace and the queue helpers, one +;; per destination: `:audit-log-archive-nexus` and +;; `:audit-log-archive-admin-console`. Each job drains only its own queue +;; and deletes its pendings on completion; whoever deletes the last +;; pending also deletes the record ("last one out turns off the +;; light"), so no separate GC task is needed. Housekeeping (reaping +;; disabled pendings, sweeping orphans) lives in both jobs on purpose: +;; only the enabled job runs, so it must clean up for the rest. + +;; Archiver ids are fixed constants, never reuse one: retired +;; pendings are reaped by id, so a reused id would inherit the +;; previous owner's queue. +(def archiver-nexus 1) +(def archiver-admin-console 2) + +(def ^:private archivers + [{:id archiver-nexus :flag :nexus :label "nexus"} + {:id archiver-admin-console :flag :admin-console :label "admin-console"}]) + +(def ^:private xf:enabled-archivers + "Static transducer: keep the archivers whose send flag is on. + Allocated once so hot paths (every audited RPC) do not rebuild + it per call; the flag lookup itself still reads the live flags." + (comp (filter #(contains? cf/flags (:flag %))) + (map :id))) + +(defn enabled-archivers + "Archivers allowed to work right now, from the send flags. Public: + producers queue every record for exactly these archivers." + [] + (into [] xf:enabled-archivers archivers)) + +(defn queue-pendings + "Queue records for every enabled archiver, nothing when there is + nothing to queue or no archiver is on. One multi-row statement for + all ids instead of one per archiver: this runs inside every audited + RPC and the bulk frontend ingest. Runs inside the row transaction: + a rollback takes both." + [cfg ids] + (let [archivers (enabled-archivers)] + (when (and (seq ids) (seq archivers)) + (db/insert-many! cfg :audit-log-pending + [:audit-log-id :archiver] + (for [id ids + archiver archivers] + [id archiver]) + {::sql/on-conflict-do-nothing true})))) + +(def ^:private archiver-labels + (into {} (map (juxt :id :label)) archivers)) + +(defn- archiver-label + [archiver] + (get archiver-labels archiver)) (defn- decode-row [{:keys [props ip-addr context] :as row}] @@ -51,29 +108,95 @@ [row] (select-keys row event-keys)) -(defn- send! +(defn- record-count + [metrics id labels amount] + (mtx/run! metrics :id id :labels labels :inc amount)) + +(defn- truncate-body + [body limit] + (when (some? body) + (let [s (str body)] + (if (> (count s) limit) + (subs s 0 limit) + s)))) + +;; NOTE: deliberately no SKIP LOCKED here. Batches must reach the +;; destinations in chronological order (Nexus numbers by arrival), +;; so every archiver takes the same total order and a second +;; overlapping run waits for the first to commit, then sees whether +;; it was last. The brief blocking is accepted; skipping rows could +;; reorder archive arrival. +(def ^:private sql:pending-chunk + "SELECT a.* + FROM audit_log_pending p + JOIN audit_log a ON a.id = p.audit_log_id + WHERE p.archiver = ?::smallint + ORDER BY a.created_at ASC, a.id ASC + LIMIT 128 + FOR UPDATE OF a") + +(def ^:private sql:delete-pendings + "DELETE FROM audit_log_pending + WHERE archiver = ?::smallint + AND audit_log_id = ANY(?)") + +(def ^:private sql:delete-drained + "DELETE FROM audit_log a + WHERE a.id = ANY(?) + AND NOT EXISTS ( + SELECT 1 FROM audit_log_pending p + WHERE p.audit_log_id = a.id + )") + +(defn- get-pending-rows + [{:keys [::db/conn] :as cfg} archiver] + (->> (db/exec! conn [sql:pending-chunk archiver]) + (not-empty))) + +(defn- complete-batch + "Delete our pendings for the batch, then the records nobody is + waiting for anymore. Two statements on purpose: inside a single + statement the NOT EXISTS would still see the pending just deleted + and never delete a record." + [{:keys [::db/conn]} archiver rows] + (let [ids (db/create-array conn "uuid" (map :id rows))] + (db/exec! conn [sql:delete-pendings archiver ids]) + (db/exec! conn [sql:delete-drained ids]))) + +(defn- send-to-nexus + "POST events to Nexus. Returns the number of events sent; throws on + transport failure or non-204 refusal, carrying the attempted count + as :events in the error data so the caller records the failure. + No metrics or logs here: the runner reports the aborted job." [{:keys [::uri] :as cfg} events] - (let [skey (-> cfg ::setup/shared-keys :nexus) - body (t/encode {:events events}) - headers {"content-type" "application/transit+json" - "origin" (str (cf/get :public-uri)) - "x-shared-key" (str "nexus " skey)} - params {:uri uri - :timeout 12000 - :method :post - :headers headers - :body body} - resp (http/req cfg params {:skip-ssrf-check? true})] - + (let [n (count events) + skey (-> cfg ::setup/shared-keys :nexus) + body (t/encode {:events events}) + params {:uri uri + :timeout 12000 + :method :post + :headers {"content-type" "application/transit+json" + "origin" (str (cf/get :public-uri)) + "x-shared-key" (str "nexus " skey)} + :body body} + resp (try + (http/req cfg params {:skip-ssrf-check? true}) + (catch Throwable cause + (throw (ex/raise :type :internal + :code :nexus-send-failed + :hint "unable to send events to nexus" + :cause cause + :events n))))] (if (= (:status resp) 204) - true - (do - (l/error :hint "unable to archive events" - :resp-status (:status resp) - :resp-body (:body resp)) - false)))) + n + (throw (ex/raise :type :internal + :code :archive-chunk-refused + :hint "nexus refused archive chunk" + :resp-status (:status resp) + :resp-body (truncate-body (:body resp) 500) + :events n))))) -(def ^:private event-names-for-nitrate +(def ^:private event-names-for-admin-console #{"accept-organization-invitation" "accept-team-invitation" "accept-team-invitation-from" @@ -121,7 +244,7 @@ "update-team-photo" "verify-token"}) -;; Props id key -> email prop filled from profile.email (nitrate path only). +;; Props id key -> email prop filled from profile.email (admin-console path only). ;; Some producers store ids as UUID strings (e.g. :user-who-send-invitation). (def ^:private id->email-prop {:profile-id :profile-email @@ -187,8 +310,8 @@ props id->email-prop)))) -(defn- enrich-events-for-nitrate - "Enrich allowlisted events before nitrate ingest: +(defn- enrich-events-for-admin-console + "Enrich allowlisted events before the admin-console ingest: 1. Fill missing :team-name from team id (including soft-deleted). 2. Fill missing emails from matching id keys (including soft-deleted profiles): :profile-email, :user-email, :member-email, @@ -204,103 +327,304 @@ (maybe-assoc-emails emails))) events))) -(defn- send-to-nitrate! +(defn- send-to-admin-console + "Ingest rows into the admin console. Returns {:sent :skipped} + (filtered-out rows count as processed for the queue); throws + carrying the attempted count as :events, like send-to-nexus. Enrich + failures propagate untouched: a local DB/decode failure is ours, not + the destination's, and must not count as a send failure." [cfg rows] - (when-let [events (->> rows - (filterv #(contains? event-names-for-nitrate (:name %))) - (not-empty))] - (nitrate/call cfg :ingest-audit-log - {:events (enrich-events-for-nitrate cfg events)}))) + (let [events (filterv #(contains? event-names-for-admin-console (:name %)) rows) + total (count rows) + skipped (- total (count events)) + uri-host (some-> (cf/get :admin-console-uri) u/uri u/get-domain)] + (l/dbg :hint "archive events chunk ready" + :archiver "admin-console" + :uri-host uri-host + :total total + :skipped skipped) + (if (empty? events) + {:sent 0 :skipped skipped} + ;; Enrich first, outside the try: a local DB/decode failure is + ;; ours, not the destination's, and must not count as + ;; admin-console/failed. + (let [enriched (enrich-events-for-admin-console cfg events) + n (count events)] + (try + (nitrate/call cfg :ingest-audit-log {:events enriched}) + (l/dbg :hint "archive events chunk sent" + :archiver "admin-console" + :uri-host uri-host + :sent n + :skipped skipped) + {:sent n :skipped skipped} + (catch Throwable cause + (l/dbg :hint "archive events chunk failed" + :archiver "admin-console" + :uri-host uri-host + :events n + :code :admin-console-send-failed) + (throw (ex/raise :type :internal + :code :admin-console-send-failed + :hint "unable to archive events to the admin console" + :cause cause + :events n)))))))) -(defn- mark-archived! - [{:keys [::db/conn]} rows] - (let [ids (db/create-array conn "uuid" (map :id rows))] - (db/exec-one! conn ["update audit_log set archived_at=now() where id = ANY(?)" ids]))) +(defn- archive-nexus-chunk + "Send one pending chunk to Nexus and complete it. Returns {:sent n} + or nil when the queue is empty; throws on refusal. Takes a + transactional cfg: the caller opens one transaction per chunk. No + metrics here: a normal return means the transaction committed, so + the caller records the success." + [cfg] + (db/exec! cfg ["SET LOCAL lock_timeout = '30s'"]) + (when-let [rows (get-pending-rows cfg archiver-nexus)] + (let [events (mapv row->event (mapv decode-row rows)) + ;; send-to-nexus returns the count or throws: a refusal + ;; aborts the job, the rows stay for the next tick. + n (send-to-nexus cfg events)] -(def ^:private sql:get-audit-log-chunk - "SELECT * - FROM audit_log - WHERE archived_at IS NULL - ORDER BY created_at ASC - LIMIT 128 - FOR UPDATE - SKIP LOCKED") + (l/trc :hint "archive events chunk" + :archiver "nexus" + :events n) -(defn- get-event-rows - [{:keys [::db/conn] :as cfg}] - (->> (db/exec! conn [sql:get-audit-log-chunk]) - (not-empty))) + (complete-batch cfg archiver-nexus rows) + {:sent n}))) -(defn- archive-events! - [{:keys [::uri] :as cfg}] - (db/tx-run! cfg (fn [cfg] - (when-let [rows (get-event-rows cfg)] - (let [decoded (mapv decode-row rows) - events (mapv row->event decoded)] - (l/trc :hint "archive events chunk" :uri uri :events (count events)) +(defn- archive-admin-console-chunk + "Send one pending chunk to the admin console and complete it. Returns + {:sent :skipped} or nil when the queue is empty; throws on failure. + Takes a transactional cfg, no metrics here: same contract as + archive-nexus-chunk." + [cfg] + (db/exec! cfg ["SET LOCAL lock_timeout = '30s'"]) + (when-let [rows (get-pending-rows cfg archiver-admin-console)] + (let [decoded (mapv decode-row rows) + _ (l/trc :hint "archive events chunk" + :archiver "admin-console" + :events (count decoded)) + admin-console-res (send-to-admin-console cfg decoded)] + ;; Filtered-out rows count as processed for the + ;; admin-console queue: their pending is done, the + ;; record stays until every archiver is done. + (complete-batch cfg archiver-admin-console rows) + admin-console-res))) - (when (contains? cf/flags :admin-console) - (send-to-nitrate! cfg decoded)) +(defn- drain-archiver + "Drain one archiver queue chunk by chunk, one transaction per chunk. + Returns totals; a chunk the destination refuses (or that throws) + aborts the drain with a throw, and the committed chunks so far + stay sent." + [cfg send-chunk] + (loop [sent 0 + skipped 0 + chunks 0] + ;; One transaction per chunk: fetch, send and complete commit (or + ;; roll back) together, and the next chunk sees a fresh snapshot. + (if-let [res (db/tx-run! cfg send-chunk)] + (do + (jobs/heartbeat cfg) + (px/sleep 100) + (recur (+ sent ^long (:sent res)) + (+ skipped ^long (:skipped res 0)) + (inc chunks))) + {:sent sent :skipped skipped :chunks chunks}))) - ;; When :nexus is off, treat Nexus as skipped - ;; success so nitrate-only still marks the chunk. - ;; REPL :enabled true with neither send flag also marks - ;; with no outbound send — always set at least one flag. - (let [nexus-ok? (if (contains? cf/flags :nexus) - (send! cfg events) - true)] - (when nexus-ok? - (mark-archived! cfg rows) - (count events)))))))) +(defn- sql:reap-pendings + [archiver] + (str "DELETE FROM audit_log_pending" + " WHERE archiver = " archiver + " AND audit_log_id IN (SELECT audit_log_id FROM audit_log_pending" + " WHERE archiver = " archiver + " ORDER BY audit_log_id LIMIT 5000)")) -(declare execute-audit-log-archive) +(def ^:private sql:sweep-orphans + "DELETE FROM audit_log a + WHERE a.id IN (SELECT a2.id FROM audit_log a2 + WHERE NOT EXISTS (SELECT 1 FROM audit_log_pending p + WHERE p.audit_log_id = a2.id) + ORDER BY a2.id LIMIT 5000)") -(def schema:audit-log-archive-params - "Optional overrides for the repl invocation defaults. +(defn- delete-batched + "Run a batched DELETE until it removes nothing. Returns the total." + [cfg sql] + (loop [total 0] + (let [n (db/get-update-count (db/exec-one! cfg [sql]))] + (if (pos? n) + (do + (px/sleep 100) + (recur (+ total ^long n))) + total)))) + +(defn- reap-disabled + "Drop the pendings of archivers that are off: a disabled destination + will never drain them, and without this they would pin their records + forever. This also absorbs any later deactivation, with no manual step. + Dropped pendings count as discarded: their rows may still be archived + by an enabled archiver, or swept unsent when nothing waits for them." + [cfg] + (let [total (->> archivers + (remove #(contains? cf/flags (:flag %))) + (map #(delete-batched cfg (sql:reap-pendings (:id %)))) + (reduce + 0))] + (when (pos? total) + (record-count (mtx/instance cfg) :audit-archive-discarded ["reaped"] total)) + total)) + +(defn- sweep-orphans + "Delete records nobody waits for anymore: rows born while no + archiver was on, rows left pending-less by the reap, and leftovers + archived by the old task. Batched and normally a no-op. Deleted rows + count as discarded: on a live system they were never archived." + [cfg] + (let [total (delete-batched cfg sql:sweep-orphans)] + (when (pos? total) + (record-count (mtx/instance cfg) :audit-archive-discarded ["swept"] total)) + total)) + +(declare execute-audit-log-archive-nexus) +(declare execute-audit-log-archive-admin-console) + +(def schema:audit-log-archive-nexus-params + "Overrides for the nexus job repl invocation defaults. Cron submits {}; system deps (pool, shared-keys, http) live on the - job-def cfg closed over by the handler, not in job params." - [:map - [:enabled {:optional true} :boolean] + job-def cfg closed over by the handler, not in job params. + Closed on purpose: a stale key (like the old :enabled) must fail + validation loudly instead of skipping silently." + [:map {:closed true} [:uri {:optional true} ::sm/uri]]) -(defmethod ig/init-key ::job-def +(def schema:audit-log-archive-admin-console-params + "Params of the admin-console job: none. Cron submits {}; system deps + (pool, admin-console client) live on the job-def cfg closed over by + the handler, not in job params. Closed on purpose, same as the nexus + schema." + [:map {:closed true}]) + +(defmethod ig/assert-key ::nexus-job-def [_ cfg] - {::jobs/name :audit-log-archive - ::jobs/schema schema:audit-log-archive-params + (assert (db/pool? (::db/pool cfg)) "expected a valid database pool") + (assert (http/client? (::http/client cfg)) "expected a valid http client") + (assert (mtx/metrics? (::mtx/metrics cfg)) "expected a valid metrics instance")) + +(defmethod ig/init-key ::nexus-job-def + [_ cfg] + ;; Effective destination, once at boot: per-run logs carry counts, + ;; the runner says the job started, this says where it sends. + (l/dbg :hint "audit log archive job initialized" + :archiver "nexus" + :uri-host (some-> (cf/get :audit-log-archive-uri) u/uri u/get-domain)) + {::jobs/name :audit-log-archive-nexus + ::jobs/schema schema:audit-log-archive-nexus-params ::jobs/handler (fn [_context params] - (execute-audit-log-archive cfg params)) - ::jobs/decoder (sm/decoder schema:audit-log-archive-params sm/json-transformer) - ::jobs/validator (sm/validator schema:audit-log-archive-params)}) + (execute-audit-log-archive-nexus cfg params)) + ::jobs/decoder (sm/decoder schema:audit-log-archive-nexus-params sm/json-transformer) + ::jobs/validator (sm/validator schema:audit-log-archive-nexus-params)}) -(defn execute-audit-log-archive - "Plain job handler: archive the accumulated audit events in chunks +(defmethod ig/assert-key ::admin-console-job-def + [_ cfg] + ;; No assert on the admin-console client: it inits to nil without the + ;; :admin-console flag, and the job skips on its flag anyway. + (assert (db/pool? (::db/pool cfg)) "expected a valid database pool") + (assert (mtx/metrics? (::mtx/metrics cfg)) "expected a valid metrics instance")) + +(defmethod ig/init-key ::admin-console-job-def + [_ cfg] + ;; Same as the nexus job-def: destination host once at boot. The + ;; ingest uri is resolved by nitrate/call at send time (it raises + ;; when missing); here it is display-only. + (l/dbg :hint "audit log archive job initialized" + :archiver "admin-console" + :uri-host (some-> (cf/get :admin-console-uri) u/uri u/get-domain)) + {::jobs/name :audit-log-archive-admin-console + ::jobs/schema schema:audit-log-archive-admin-console-params + ::jobs/handler + (fn [_context params] + (execute-audit-log-archive-admin-console cfg params)) + ::jobs/decoder (sm/decoder schema:audit-log-archive-admin-console-params sm/json-transformer) + ::jobs/validator (sm/validator schema:audit-log-archive-admin-console-params)}) + +(defn- run-single-archiver + "Drain one archiver queue, then housekeeping (reap disabled + pendings, sweep orphans) and the per-run summary. Housekeeping lives + in both jobs: only the enabled job runs, so it must reap the queues + of the archivers that are off; both halves are idempotent deletes. + A refused chunk (or a throw) aborts the job: the committed chunks + stay sent, the rest waits for the next tick, and the cron retries. + Every effect is rows and logs (drain metrics land here, outside + every chunk transaction)." + [cfg archiver send-chunk tpoint] + (let [label (archiver-label archiver) + metrics (mtx/instance cfg) + reaped (reap-disabled cfg) + ;; All drain metrics land here, outside every chunk + ;; transaction. Success is recorded on the totals: a normal + ;; chunk return means its transaction committed (a rollback + ;; always throws, taking the failure path instead). The send + ;; fns carry the attempted count as :events; anything else (a + ;; local DB failure) is not a destination failure and stays + ;; uncounted. The throw keeps rolling: a failed chunk aborts + ;; the job, the cron retries. + drained (try + (drain-archiver cfg send-chunk) + (catch Throwable cause + (let [n (:events (ex-data cause))] + (when (and (integer? n) (pos? n)) + (record-count metrics :audit-archive-events [label "failed"] n))) + (throw cause))) + _ (let [{:keys [sent skipped]} drained] + (when (pos? sent) + (record-count metrics :audit-archive-events [label "archived"] sent)) + (when (pos? skipped) + (record-count metrics :audit-archive-events [label "skipped"] skipped))) + swept (sweep-orphans cfg) + sent (:sent drained) + skipped (:skipped drained) + chunks (:chunks drained) + ;; Strict binding on purpose: log props evaluate lazily in + ;; the logger thread, so the elapsed must be frozen here. + elapsed (ct/format-duration (tpoint))] + (if (and (zero? sent) + (zero? skipped) + (zero? reaped) + (zero? swept)) + (l/dbg :hint "no pending audit events" + :archiver label) + (l/dbg :hint "events archived" + :archiver label + :elapsed elapsed + :sent sent + :skipped skipped + :chunks chunks + :reaped reaped + :swept swept)))) + +(defn execute-audit-log-archive-nexus + "Nexus job handler: drain the nexus queue in chunks, then clean up (heartbeat per iteration: the sent chunk batches can be long)." [cfg params] ;; NOTE: this let allows overwrite default configured values from - ;; the repl, when manually invoking the task. Prefer setting a send - ;; flag (:admin-console and/or :nexus); :enabled alone - ;; marks chunks without shipping. - (let [enabled (or (contains? cf/flags :nexus) - (contains? cf/flags :admin-console) - (:enabled params false)) - uri (cf/get :audit-log-archive-uri) - uri (or uri (:uri params)) - ;; Normalize to an uri object; params may carry a plain string - ;; on direct invocations (validation only applies on submit!). - uri (u/uri uri) - cfg (assoc cfg ::uri uri)] - (when (and (contains? cf/flags :nexus) - (not uri)) - (ex/raise :type :internal - :code :task-not-configured - :hint "archive task not configured, missing uri")) - (when enabled - (loop [total 0] - (if-let [n (archive-events! cfg)] - (do - (jobs/heartbeat cfg) - (px/sleep 100) - (recur (+ total ^long n))) - (when (pos? total) - (l/dbg :hint "events archived" :total total))))))) + ;; the repl, when manually invoking the task. + (if-not (contains? cf/flags :nexus) + (l/dbg :hint "audit log archive (nexus) skipped, flag off") + (let [uri (cf/get :audit-log-archive-uri) + uri (or uri (:uri params)) + ;; Normalize to an uri object; params may carry a plain string + ;; on direct invocations (validation only applies on submit!). + uri (u/uri uri) + cfg (assoc cfg ::uri uri)] + (when-not uri + (ex/raise :type :internal + :code :task-not-configured + :hint "archive task not configured, missing uri")) + (run-single-archiver cfg archiver-nexus archive-nexus-chunk (ct/tpoint))))) + +(defn execute-audit-log-archive-admin-console + "Admin-console job handler: drain the admin-console queue in chunks, + then clean up (heartbeat per iteration: the sent chunk batches can + be long)." + [cfg _params] + (if-not (contains? cf/flags :admin-console) + (l/dbg :hint "audit log archive (admin-console) skipped, flag off") + (run-single-archiver cfg archiver-admin-console archive-admin-console-chunk (ct/tpoint)))) diff --git a/backend/src/app/loggers/audit/gc_task.clj b/backend/src/app/loggers/audit/gc_task.clj deleted file mode 100644 index 65cd5bec7f..0000000000 --- a/backend/src/app/loggers/audit/gc_task.clj +++ /dev/null @@ -1,46 +0,0 @@ -;; 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.loggers.audit.gc-task - (:require - [app.common.logging :as l] - [app.common.schema :as sm] - [app.db :as db] - [app.jobs :as jobs] - [integrant.core :as ig])) - -(def ^:private sql:clean-archived - "DELETE FROM audit_log - WHERE archived_at IS NOT NULL") - -(defn- clean-archived! - [{:keys [::db/pool]}] - (let [result (db/exec-one! pool [sql:clean-archived]) - result (db/get-update-count result)] - (l/debug :hint "delete archived audit log entries" :deleted result) - result)) - -(declare execute-audit-log-gc) - -(def schema:audit-log-gc-params - "Params map (no params needed; config-derived only)." - [:map {:closed true}]) - -(defmethod ig/init-key ::job-def - [_ cfg] - {::jobs/name :audit-log-gc - ::jobs/schema schema:audit-log-gc-params - ::jobs/handler - (fn [_context params] - (execute-audit-log-gc cfg params)) - ::jobs/decoder (sm/decoder schema:audit-log-gc-params sm/json-transformer) - ::jobs/validator (sm/validator schema:audit-log-gc-params)}) - -(defn execute-audit-log-gc - "Plain job handler: delete the archived audit log entries." - ([cfg] (execute-audit-log-gc cfg nil)) - ([cfg _params] - (clean-archived! cfg))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 5ee2c7bbe2..5a387f14be 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -371,6 +371,24 @@ :http-malformed-requests-total {::mdef/name "penpot_http_malformed_requests_total" ::mdef/help "Total number of requests whose body the parser could not read." + ::mdef/type :counter} + + :audit-archive-events + {::mdef/name "penpot_audit_archive_events_total" + ::mdef/help "Audit log events sent by the archive task, by destination and outcome." + ::mdef/labels ["destination" "outcome"] + ::mdef/type :counter} + + :audit-archive-discarded + {::mdef/name "penpot_audit_archive_discarded_total" + ::mdef/help "Audit log entries discarded without archiving, by reason." + ::mdef/labels ["reason"] + ::mdef/type :counter} + + :audit-ingest-events + {::mdef/name "penpot_audit_ingest_events_total" + ::mdef/help "Audit log frontend events ingested, by kind and outcome." + ::mdef/labels ["kind" "outcome"] ::mdef/type :counter}}) (def system-config @@ -604,25 +622,25 @@ ::setup/shared-keys (ig/ref ::setup/shared-keys)} ::jobs/defs - {:sendmail (ig/ref ::email/job-def) - :delete-object (ig/ref :app.tasks.delete-object/job-def) - :demo-purge (ig/ref :app.tasks.demo-purge/job-def) - :run-webhook (ig/ref ::webhooks/run-webhook-job-def) - :process-webhook-event (ig/ref ::webhooks/process-webhook-event-job-def) - :file-gc (ig/ref :app.tasks.file-gc/job-def) - :offload-file-data (ig/ref :app.tasks.offload-file-data/job-def) - :objects-gc (ig/ref :app.tasks.objects-gc/job-def) - :export-binfile (ig/ref :app.tasks.export-binfile/job-def) - :import-binfile (ig/ref :app.tasks.import-binfile/job-def) - :storage-gc-deleted (ig/ref ::sto.gc-deleted/job-def) - :storage-gc-touched (ig/ref ::sto.gc-touched/job-def) - :storage-pending-gc (ig/ref ::sto.pending-gc/job-def) - :jobs-gc (ig/ref :app.jobs.gc/job-def) - :telemetry (ig/ref :app.tasks.telemetry/job-def) - :session-gc (ig/ref ::session/job-def) - :file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/job-def) - :audit-log-archive (ig/ref :app.loggers.audit.archive-task/job-def) - :audit-log-gc (ig/ref :app.loggers.audit.gc-task/job-def)} + {:sendmail (ig/ref ::email/job-def) + :delete-object (ig/ref :app.tasks.delete-object/job-def) + :demo-purge (ig/ref :app.tasks.demo-purge/job-def) + :run-webhook (ig/ref ::webhooks/run-webhook-job-def) + :process-webhook-event (ig/ref ::webhooks/process-webhook-event-job-def) + :file-gc (ig/ref :app.tasks.file-gc/job-def) + :offload-file-data (ig/ref :app.tasks.offload-file-data/job-def) + :objects-gc (ig/ref :app.tasks.objects-gc/job-def) + :export-binfile (ig/ref :app.tasks.export-binfile/job-def) + :import-binfile (ig/ref :app.tasks.import-binfile/job-def) + :storage-gc-deleted (ig/ref ::sto.gc-deleted/job-def) + :storage-gc-touched (ig/ref ::sto.gc-touched/job-def) + :storage-pending-gc (ig/ref ::sto.pending-gc/job-def) + :jobs-gc (ig/ref :app.jobs.gc/job-def) + :telemetry (ig/ref :app.tasks.telemetry/job-def) + :session-gc (ig/ref ::session/job-def) + :file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/job-def) + :audit-log-archive-nexus (ig/ref :app.loggers.audit.archive-task/nexus-job-def) + :audit-log-archive-admin-console (ig/ref :app.loggers.audit.archive-task/admin-console-job-def)} ::jobs/request-pool {::rds/client (ig/ref ::rds/client) @@ -711,16 +729,16 @@ {::db/pool (ig/ref ::db/pool) ::mtx/metrics (ig/ref ::mtx/metrics)} - :app.loggers.audit.archive-task/job-def + :app.loggers.audit.archive-task/nexus-job-def {::db/pool (ig/ref ::db/pool) ::http.client/client (ig/ref ::http.client/client) ::setup/shared-keys (ig/ref ::setup/shared-keys) - :app.nitrate/client (ig/ref :app.nitrate/client) ::mtx/metrics (ig/ref ::mtx/metrics)} - :app.loggers.audit.gc-task/job-def - {::db/pool (ig/ref ::db/pool) - ::mtx/metrics (ig/ref ::mtx/metrics)} + :app.loggers.audit.archive-task/admin-console-job-def + {::db/pool (ig/ref ::db/pool) + :app.nitrate/client (ig/ref :app.nitrate/client) + ::mtx/metrics (ig/ref ::mtx/metrics)} ::email/blacklist {} @@ -841,15 +859,13 @@ {:cron #penpot/cron "0 30 */3,23 * * ?" :job :telemetry} - (when (or (contains? cf/flags :nexus) - (contains? cf/flags :admin-console)) + (when (contains? cf/flags :nexus) {:cron #penpot/cron "0 */5 * * * ?" ;; every 5m - :job :audit-log-archive}) + :job :audit-log-archive-nexus}) - (when (or (contains? cf/flags :nexus) - (contains? cf/flags :admin-console)) - {:cron #penpot/cron "30 */5 * * * ?" ;; every 5m - :job :audit-log-gc})]} + (when (contains? cf/flags :admin-console) + {:cron #penpot/cron "30 */5 * * * ?" ;; every 5m, offset 30s from nexus + :job :audit-log-archive-admin-console})]} ::wrk/dispatcher {::rds/client (ig/ref ::rds/client) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 7b25493c8d..104222cc00 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -529,7 +529,10 @@ :fn (mg/resource "app/migrations/sql/0155-add-idx-to-file-change.sql")} {:name "0156-reset-pg-storage-defaults" - :fn (mg/resource "app/migrations/sql/0156-reset-pg-storage-defaults.sql")}]) + :fn (mg/resource "app/migrations/sql/0156-reset-pg-storage-defaults.sql")} + + {:name "0157-add-audit-log-pending-table" + :fn (mg/resource "app/migrations/sql/0157-add-audit-log-pending-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql b/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql new file mode 100644 index 0000000000..4c6d8d998d --- /dev/null +++ b/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql @@ -0,0 +1,39 @@ +-- Work queue for the audit log archivers: one row per (record, +-- archiver) pair still pending. Inserting a record creates its +-- pendings; each archiver deletes its own on completion, and whoever +-- deletes the last pending also deletes the record. Archiver ids are +-- fixed in code and never reused: 1 = nexus, 2 = nitrate. + +CREATE TABLE audit_log_pending ( + -- DEFERRABLE so the test-suite reset (which deletes table by table + -- with SET CONSTRAINTS ALL DEFERRED) keeps working; the guard still + -- fires when a record with pendings is deleted. Plain DEFERRABLE + -- (initially immediate) is enough: every deleter removes pendings + -- before rows, so the check passes at each statement. + audit_log_id uuid NOT NULL REFERENCES audit_log(id) DEFERRABLE, + archiver smallint NOT NULL CHECK (archiver BETWEEN 1 AND 20), + PRIMARY KEY (archiver, audit_log_id)); + +-- Lets the final delete ask "is anything still pending for this +-- record?" without scanning the whole queue. +CREATE INDEX ON audit_log_pending (audit_log_id); + +-- NOTE: no backfill on purpose. Rows predating this queue (sent or +-- not) get no pendings, so the orphan sweep deletes them unsent. +-- Losing the unarchived ones at upgrade time is accepted: this +-- table is a minutes/hours buffer, not a store. + +-- The pending queue replaces the archived_at mark: whoever deletes +-- the last pending deletes the record, so the flag column and the +-- indexes that served the old mark-and-sweep scans go away. +DROP INDEX IF EXISTS audit_log__created_at__idx; +DROP INDEX IF EXISTS audit_log__archived_at__idx; +DROP INDEX IF EXISTS audit_log_id_archived_at_idx; +ALTER TABLE audit_log DROP COLUMN IF EXISTS archived_at; + +-- Serves the archivers' chronological scan (ORDER BY created_at, id): +-- one total order for every archiver, so concurrent drains cannot +-- deadlock on row locks. Covers the whole ORDER BY (bulk ingest +-- shares request-at constantly, so ties are the norm). +CREATE INDEX IF NOT EXISTS audit_log__created_at__idx + ON audit_log (created_at ASC, id); diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 893160a23e..771b321606 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -16,8 +16,10 @@ [app.db :as db] [app.http :as-alias http] [app.loggers.audit :as audit] + [app.loggers.audit.archive-task :as archive] [app.loggers.database :as loggers.db] [app.loggers.mattermost :as loggers.mm] + [app.metrics :as mtx] [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.doc :as-alias doc] @@ -71,6 +73,9 @@ (def ^:private xf:map-event-row (comp (map adjust-timestamp) + ;; Each row mints its own id: the full row and its telemetry + ;; shadow are distinct records that must not share it. + (map #(assoc % :id (uuid/next))) (map event->row))) (defn- prepare-events @@ -80,8 +85,7 @@ initiator (audit/get-client-initiator request) xform (comp (map (fn [event] - {:id (uuid/next) - :type (:type event) + {:type (:type event) :name (:name event) :props (:props event) ;; The initiator always comes from the @@ -113,6 +117,19 @@ (assoc :source "telemetry:frontend")))) (map event->row))) +(defn- record-ingested + [metrics kind outcome amount] + (when (pos? amount) + (mtx/run! metrics :id :audit-ingest-events :labels [kind outcome] :inc amount))) + +(defn- insert-rows! + [tx rows] + (when (seq rows) + (db/insert-many! tx :audit-log event-columns rows) + ;; Same transaction as the rows: every ingested record is born + ;; pending for every enabled archiver (see archive-task). + (archive/queue-pendings tx (map first rows)))) + (defn- handle-events [{:keys [::db/pool] :as cfg} params] (let [events (prepare-events params)] @@ -124,19 +141,30 @@ (run! (partial loggers.db/emit cfg) events) (run! (partial loggers.mm/emit cfg) events)) - (when (contains? cf/flags :audit-log) - ;; Process and save full audit events when audit-log flag is active - (when-let [rows (-> (sequence xf:map-event-row events) - (not-empty))] - (db/insert-many! pool :audit-log event-columns rows))) - - (when (contains? cf/flags :telemetry) - ;; Store anonymized frontend events so the telemetry task can ship them - ;; in batches. Runs independently from the audit-log insert above so - ;; both modes can be active simultaneously. - (when-let [rows (-> (sequence xf:map-telemetry-event-row events) - (not-empty))] - (db/insert-many! pool :audit-log event-columns rows))))) + ;; Rows and their pendings commit together in one transaction: a + ;; row never outlives its queue. Both faces are best-effort, so a + ;; failure drops the whole batch (counted below as failed); the + ;; endpoint still answers without breaking the request. + (let [metrics (mtx/instance cfg) + full-rows (if (contains? cf/flags :audit-log) + (into [] xf:map-event-row events) + []) + shadow-rows (if (contains? cf/flags :telemetry) + (into [] xf:map-telemetry-event-row events) + [])] + (try + (db/tx-run! pool + (fn [tx] + (insert-rows! tx full-rows) + (insert-rows! tx shadow-rows))) + (record-ingested metrics "full" "inserted" (count full-rows)) + (record-ingested metrics "shadow" "inserted" (count shadow-rows)) + (catch Throwable cause + ;; Whatever never landed counts as failed per attempted + ;; kind; the endpoint logs the cause as before. + (record-ingested metrics "full" "failed" (count full-rows)) + (record-ingested metrics "shadow" "failed" (count shadow-rows)) + (throw cause)))))) (def ^:private valid-event-types #{"action" "identify" "trigger"}) diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 50029746e3..8088841380 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -218,19 +218,32 @@ ;; buffer stays bounded. (def ^:private batch-size 10000) +(def ^:private sql:aged-telemetry + "source LIKE 'telemetry:%' AND created_at < now() - interval '7 days'") + +(def ^:private sql:gc-pendings + (str "DELETE FROM audit_log_pending WHERE audit_log_id IN" + " (SELECT id FROM audit_log WHERE " sql:aged-telemetry ")")) + (def ^:private sql:gc-events - "DELETE FROM audit_log - WHERE source LIKE 'telemetry:%' - AND created_at < now() - interval '7 days'") + (str "DELETE FROM audit_log WHERE " sql:aged-telemetry)) (defn- gc-events "Delete telemetry-mode events older than `telemetry-retention-days` so that the buffer stays bounded." [{:keys [::db/conn]}] - (let [result (db/exec-one! conn [sql:gc-events])] - (when (pos? (:next.jdbc/update-count result)) - (l/warn :hint "purged stale telemetry events" - :count (:next.jdbc/update-count result))))) + ;; One transaction for both deletes: a crash between them would + ;; leave pending-less rows for the archive sweep to delete unsent. + ;; Pendings first: the queue holds a row per archiver for every + ;; record, and the FK would reject deleting a row that still has + ;; pendings. + (db/tx-run! conn + (fn [{:keys [::db/conn]}] + (db/exec-one! conn [sql:gc-pendings]) + (let [result (db/exec-one! conn [sql:gc-events])] + (when (pos? (:next.jdbc/update-count result)) + (l/warn :hint "purged stale telemetry events" + :count (:next.jdbc/update-count result))))))) (def ^:private sql:fetch-telemetry-events "SELECT id, name, type, source, tracked_at, profile_id, props, context @@ -274,10 +287,17 @@ false)))) (defn- delete-sent-events - "Delete rows by their ids after a successful send." + "Delete rows by their ids after a successful send. One transaction + for both deletes: a crash between them would leave pending-less + rows for the archive sweep to delete unsent." [conn ids] - (let [arr (db/create-array conn "uuid" ids)] - (db/exec-one! conn ["DELETE FROM audit_log WHERE id = ANY(?)" arr]))) + (db/tx-run! conn + (fn [tx] + (let [tx-conn (::db/conn tx) + arr (db/create-array tx-conn "uuid" ids)] + ;; Pendings first: same FK reason as in `gc-events`. + (db/exec-one! tx-conn ["DELETE FROM audit_log_pending WHERE audit_log_id = ANY(?)" arr]) + (db/exec-one! tx-conn ["DELETE FROM audit_log WHERE id = ANY(?)" arr]))))) (defn- collect-and-send-audit-events "Collect anonymous telemetry-mode audit events and ship them to the diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_archive_task_test.clj index a04c2c2fd1..c0e5a5a0e0 100644 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_archive_task_test.clj @@ -9,203 +9,244 @@ [app.common.json :as json] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.transit :as transit] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.http.client :as-alias http] [app.jobs :as jobs] + [app.loggers.audit :as audit] [app.loggers.audit.archive-task :as archive] + [app.main :as main] + [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] [app.setup :as-alias setup] [backend-tests.helpers :as th] [clojure.test :as t] [cuerdas.core :as str] [integrant.core :as ig] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]]) + (:import + io.prometheus.client.Counter + io.prometheus.client.Counter$Child)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) (def ^:private archive-uri "http://nexus.example/archive") +(defn- insert-pendings! + "Queue the row with the real producer helper: pendings for the + enabled archivers, like append-audit-entry does." + [row-id] + (archive/queue-pendings (:app.db/pool th/*system*) [row-id])) + +(defn- pending-count + [] + (:pending (first (th/db-exec! ["SELECT count(*) AS pending FROM audit_log_pending"])))) + (defn- insert-audit-row! ([profile-id name] (insert-audit-row! profile-id name {:team-id (uuid/next)})) ([profile-id name props] - (th/db-insert! :audit-log - {:id (uuid/next) - :name name - :type "action" - :source "backend" - :profile-id profile-id - :ip-addr (db/inet "127.0.0.1") - :props (db/tjson props) - :context (db/tjson {}) - :tracked-at (ct/now) - :created-at (ct/now)}))) + (let [row (th/db-insert! :audit-log + {:id (uuid/next) + :name name + :type "action" + :source "backend" + :profile-id profile-id + :ip-addr (db/inet "127.0.0.1") + :props (db/tjson props) + :context (db/tjson {}) + :tracked-at (ct/now) + :created-at (ct/now)})] + (insert-pendings! (:id row)) + row))) -(defn- nitrate-event-props - [nitrate-mock] - (let [[_ _ params] (:call-args @nitrate-mock)] +(defn- admin-console-event-props + [admin-console-mock] + (let [[_ _ params] (:call-args @admin-console-mock)] (:props (first (:events params))))) -(t/deftest archive-events-sends-to-nitrate-then-nexus - ;; Create profile before enabling :admin-console — system nitrate - ;; client is nil in tests, and team bootstrap would call it. - (let [prof (th/create-profile* 1 {:is-active true}) - order (atom [])] +(defn- run-both! + "Run both archive jobs, Nexus first (the historical single-run + order). The admin-console job takes no params (closed empty schema)." + [params] + (th/run-task! :audit-log-archive-nexus params) + (th/run-task! :audit-log-archive-admin-console {})) + +(t/deftest archive-jobs-drain-their-own-queues + ;; Two separate jobs, one queue each: the nexus job sends without + ;; touching the admin console, and only the admin-console job + ;; deletes the last pending and the row. Create profile before + ;; enabling :admin-console — the system admin-console client is nil + ;; in tests, and team bootstrap would call it. + (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call - :return (fn [& _] - (swap! order conj :nitrate) - nil)} - http-mock {:target 'app.http.client/req - :return (fn [& _] - (swap! order conj :nexus) - {:status 204})}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") (insert-audit-row! (:id prof) "create-file") - (th/run-task! :audit-log-archive {:uri archive-uri}) - (t/is (:called? @nitrate-mock)) + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (t/is (false? (:called? @admin-console-mock))) (t/is (:called? @http-mock)) - (t/is (= 1 (:call-count @nitrate-mock))) (t/is (= 1 (:call-count @http-mock))) - (t/is (= [:nitrate :nexus] @order)) - (let [[_ method params] (:call-args @nitrate-mock)] + ;; Nexus drained its own queue; both rows wait on the admin console. + (t/is (= 2 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 2 (pending-count))) + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (= 1 (:call-count @admin-console-mock))) + (let [[_ method params] (:call-args @admin-console-mock)] (t/is (= :ingest-audit-log method)) (t/is (= 2 (count (:events params)))) (t/is (= #{"create-project" "create-file"} (into #{} (map :name) (:events params)))) (t/is (every? uuid? (map :id (:events params))))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 2 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-filters-nitrate-event-names +(t/deftest archive-events-filters-admin-console-event-names (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") (insert-audit-row! (:id prof) "create-organization-attribute") (insert-audit-row! (:id prof) "unrelated-event") - (th/run-task! :audit-log-archive {:uri archive-uri}) - (t/is (:called? @nitrate-mock)) + (run-both! {:uri archive-uri}) + (t/is (:called? @admin-console-mock)) (t/is (:called? @http-mock)) - (let [[_ _ params] (:call-args @nitrate-mock)] + (let [[_ _ params] (:call-args @admin-console-mock)] (t/is (= #{"create-project" "create-organization-attribute"} (into #{} (map :name) (:events params))))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 3 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-skips-nitrate-when-no-matching-names +(t/deftest archive-events-skips-admin-console-when-no-matching-names (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "unrelated-event") - (th/run-task! :audit-log-archive {:uri archive-uri}) - (t/is (false? (:called? @nitrate-mock))) + (run-both! {:uri archive-uri}) + (t/is (false? (:called? @admin-console-mock))) (t/is (:called? @http-mock)) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-admin-console-only-marks-without-uri (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive {}) - (t/is (:called? @nitrate-mock)) + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (:called? @admin-console-mock)) (t/is (false? (:called? @http-mock))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-admin-console-only-marks-non-allowlisted (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "unrelated-event") - (th/run-task! :audit-log-archive {}) - (t/is (false? (:called? @nitrate-mock))) + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (false? (:called? @admin-console-mock))) (t/is (false? (:called? @http-mock))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-propagates-nitrate-error +(t/deftest archive-events-admin-console-error-still-drains-nexus + ;; Mirror of the nexus-throw test: an admin-console failure aborts + ;; only the admin-console job; Nexus already drained its own queue + ;; in its job. The row stays on the admin-console pending. (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call - :throw (ex-info "nitrate down" {})} + (with-mocks [admin-console-mock {:target 'app.nitrate/call + :throw (ex-info "nitrate down" {})} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) (t/is (thrown? clojure.lang.ExceptionInfo - (th/run-task! :audit-log-archive {:uri archive-uri}))) - (t/is (:called? @nitrate-mock)) - (t/is (false? (:called? @http-mock))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])] - (t/is (= 1 (count rows)))))))) + (th/run-task! :audit-log-archive-admin-console {}))) + ;; Nexus already drained its queue; the admin-console failure no + ;; longer blocks it, but the row stays on the admin-console pending. + (t/is (:called? @admin-console-mock)) + (t/is (:called? @http-mock)) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 1 (count rows)))) + (t/is (= 1 (pending-count))))))) (t/deftest archive-events-uses-nexus-without-admin-console (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} - nitrate-mock {:target 'app.nitrate/call :return nil}] + admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive {:uri archive-uri}) - (t/is (false? (:called? @nitrate-mock))) + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (t/is (false? (:called? @admin-console-mock))) (t/is (:called? @http-mock)) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-skips-mark-when-nexus-fails (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 500}}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive {:uri archive-uri}) + (t/is (thrown? clojure.lang.ExceptionInfo + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}))) (t/is (:called? @http-mock)) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])] + (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 1 (count rows)))))))) (t/deftest archive-events-both-flags-skips-mark-when-nexus-fails (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 500}}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive {:uri archive-uri}) - (t/is (:called? @nitrate-mock)) + (t/is (thrown? clojure.lang.ExceptionInfo + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}))) + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (:called? @admin-console-mock)) (t/is (:called? @http-mock)) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])] + (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 1 (count rows)))))))) -(t/deftest archive-events-encodes-db-rows-for-nitrate +(t/deftest archive-events-encodes-db-rows-for-admin-console (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console} cf/config (assoc cf/config :admin-console-uri "http://ac.example/admin-console/" :admin-console-shared-key "ac-key" :nexus-shared-key "nexus-key")] - (let [nitrate-bodies (atom []) + (let [admin-console-bodies (atom []) shared-keys {:admin-console "ac-key" :nexus "nexus-key"} client (ig/init-key :app.nitrate/client {::http/client (Object.) ::setup/shared-keys shared-keys}) - ;; The archive job is a job-def: the handler closes over the + ;; Each archive job is a job-def: the handler closes over the ;; cfg it was given at init and takes a nil context, because ;; an in-process invocation has no job row to describe. - job-def (ig/init-key :app.loggers.audit.archive-task/job-def - {::db/pool (:app.db/pool th/*system*) - ::setup/shared-keys shared-keys - ::http/client (:app.http.client/client th/*system*) - :app.nitrate/client client})] + nexus-job-def (ig/init-key :app.loggers.audit.archive-task/nexus-job-def + {::db/pool (:app.db/pool th/*system*) + ::setup/shared-keys shared-keys + ::http/client (:app.http.client/client th/*system*) + ::mtx/metrics (th/metrics-stub) + :app.nitrate/client client}) + admin-console-job-def (ig/init-key :app.loggers.audit.archive-task/admin-console-job-def + {::db/pool (:app.db/pool th/*system*) + ::mtx/metrics (th/metrics-stub) + :app.nitrate/client client})] (with-mocks [http-mock {:target 'app.http.client/req :return (fn [_ req & _] (when (str/includes? (str (:uri req)) "api/audit-log") - (swap! nitrate-bodies conj (:body req))) + (swap! admin-console-bodies conj (:body req))) {:status 204})}] (let [team-id (uuid/next) row (th/db-insert! :audit-log @@ -219,9 +260,13 @@ :context (db/tjson {}) :tracked-at (ct/now) :created-at (ct/now)})] - ((::jobs/handler job-def) nil {:uri archive-uri}) - (t/is (= 1 (count @nitrate-bodies))) - (let [body (json/decode (first @nitrate-bodies) :key-fn json/read-kebab-key) + (insert-pendings! (:id row)) + ;; Admin console first so its body is captured before Nexus + ;; deletes the last pending and the row. + ((::jobs/handler admin-console-job-def) nil {}) + ((::jobs/handler nexus-job-def) nil {:uri archive-uri}) + (t/is (= 1 (count @admin-console-bodies))) + (let [body (json/decode (first @admin-console-bodies) :key-fn json/read-kebab-key) event (first (:events body))] (t/is (= 1 (count (:events body)))) (t/is (= (str (:id row)) (str (:id event)))) @@ -230,14 +275,14 @@ (t/is (string? (:created-at event))) (t/is (string? (:tracked-at event))) (t/is (= (str team-id) (str (get-in event [:props :team-id]))))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))))) -(t/deftest archive-events-requires-uri-when-nexus-flag-set +(t/deftest archive-nexus-job-requires-uri-when-nexus-flag-set (with-redefs [cf/flags #{:nexus} cf/config (dissoc cf/config :audit-log-archive-uri)] (let [data (ex-data (try - (th/run-task! :audit-log-archive {}) + (th/run-task! :audit-log-archive-nexus {}) (catch Throwable cause cause)))] (t/is (= :task-not-configured (:code data)))))) @@ -247,7 +292,7 @@ member (th/create-profile* 2 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id actor) "create-project" {:team-id (:id team) :profile-id (:id actor) @@ -255,16 +300,16 @@ :member-id (:id member) ;; Producers store this as a string UUID. :user-who-send-invitation (str (:id actor))}) - (th/run-task! :audit-log-archive {}) - (t/is (:called? @nitrate-mock)) - (let [props (nitrate-event-props nitrate-mock)] + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (:called? @admin-console-mock)) + (let [props (admin-console-event-props admin-console-mock)] (t/is (= (:name team) (:team-name props))) (t/is (= (:email actor) (:profile-email props))) (t/is (= (:email member) (:user-email props))) (t/is (= (:email member) (:member-email props))) (t/is (= (:email actor) (:user-who-send-invitation-email props)))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-enriches-soft-deleted-team-and-profile (let [actor (th/create-profile* 1 {:is-active true}) @@ -273,22 +318,22 @@ (th/db-update! :team {:deleted-at (ct/now)} {:id (:id team)}) (th/db-update! :profile {:deleted-at (ct/now)} {:id (:id other)}) (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id actor) "create-project" {:team-id (:id team) :profile-id (:id other)}) - (th/run-task! :audit-log-archive {}) - (let [props (nitrate-event-props nitrate-mock)] + (th/run-task! :audit-log-archive-admin-console {}) + (let [props (admin-console-event-props admin-console-mock)] (t/is (= (:name team) (:team-name props))) (t/is (= (:email other) (:profile-email props)))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-does-not-overwrite-existing-enrich-props (let [actor (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id actor) "create-project" {:team-id (:id team) :team-name "already-set-name" @@ -300,8 +345,8 @@ :member-email "already-member@example.com" :user-who-send-invitation (str (:id actor)) :user-who-send-invitation-email "already-inviter@example.com"}) - (th/run-task! :audit-log-archive {}) - (let [props (nitrate-event-props nitrate-mock)] + (th/run-task! :audit-log-archive-admin-console {}) + (let [props (admin-console-event-props admin-console-mock)] (t/is (= "already-set-name" (:team-name props))) (t/is (= "already-set@example.com" (:profile-email props))) (t/is (= "already-user@example.com" (:user-email props))) @@ -312,29 +357,29 @@ (t/deftest archive-events-enrich-skips-missing-and-non-uuid-ids (let [actor (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id actor) "create-project" {:team-id (uuid/next) :profile-id (uuid/next) :user-id "not-a-uuid" :member-id 42}) - (th/run-task! :audit-log-archive {}) - (t/is (:called? @nitrate-mock)) - (let [props (nitrate-event-props nitrate-mock)] + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (:called? @admin-console-mock)) + (let [props (admin-console-event-props admin-console-mock)] (t/is (nil? (:team-name props))) (t/is (nil? (:profile-email props))) (t/is (nil? (:user-email props))) (t/is (nil? (:member-email props))) (t/is (= "not-a-uuid" (:user-id props))) (t/is (= 42 (:member-id props)))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-forwards-export-job-not-legacy-export (let [actor (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id actor) "create-export-binfile-job" {:team-id (:id team) :job-id (uuid/next) @@ -342,20 +387,336 @@ :export-type "detach-libraries"}) (insert-audit-row! (:id actor) "export-binfile" {:file-id (uuid/next)}) - (th/run-task! :audit-log-archive {}) - (let [[_ _ params] (:call-args @nitrate-mock) + (th/run-task! :audit-log-archive-admin-console {}) + (let [[_ _ params] (:call-args @admin-console-mock) events (:events params) props (:props (first events))] (t/is (= ["create-export-binfile-job"] (mapv :name events))) (t/is (= (:id team) (:team-id props))) (t/is (= (:name team) (:team-name props)))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 2 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-params-accept-empty-cron-props ;; Cron always submits (or props {}) — system deps live on the ;; job-def cfg, not in params. Requiring them here blocks every tick. - (let [valid? (sm/validator archive/schema:audit-log-archive-params)] - (t/is (true? (valid? {}))) - (t/is (true? (valid? {:enabled true}))) - (t/is (true? (valid? {:uri (u/uri archive-uri)}))))) + ;; Both schemas are closed: a stale manual invocation must fail + ;; validation loudly instead of skipping silently. + (let [nexus-valid? (sm/validator archive/schema:audit-log-archive-nexus-params) + admin-console-valid? (sm/validator archive/schema:audit-log-archive-admin-console-params)] + (t/is (true? (nexus-valid? {}))) + (t/is (false? (nexus-valid? {:enabled true}))) + (t/is (true? (nexus-valid? {:uri (u/uri archive-uri)}))) + (t/is (true? (admin-console-valid? {}))) + (t/is (false? (admin-console-valid? {:enabled true}))) + (t/is (false? (admin-console-valid? {:uri (u/uri archive-uri)}))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; METRICS SMOKE TEST +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; The job-def cfg of these tests carries an isolated metrics registry +;; (pattern from `jobs-metrics-test`), so assertions read exact values +;; instead of the shared test-system registry. `th/run-task!` would use +;; the latter and its counts would leak across tests. +(def ^:private archive-metric-ids + [:audit-archive-events :audit-archive-discarded]) + +(defn- make-archive-metrics + [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics archive-metric-ids)})) + +(defn- invoke-nexus! + "Invoke the nexus archive handler in-process with an isolated + metrics registry. The handler takes a nil context because there is + no job row to describe (same as `th/run-task!`)." + [metrics params] + (let [job-def (ig/init-key :app.loggers.audit.archive-task/nexus-job-def + {::db/pool (:app.db/pool th/*system*) + ::setup/shared-keys {:admin-console "ac-key" :nexus "nexus-key"} + ::http/client (:app.http.client/client th/*system*) + ::mtx/metrics metrics})] + ((::jobs/handler job-def) nil params))) + +(defn- invoke-admin-console! + "Invoke the admin-console archive handler in-process with an + isolated metrics registry. The admin-console client is the (nil in + tests) system one: tests mock `app.nitrate/call`, so it is never + dereferenced." + [metrics] + (let [job-def (ig/init-key :app.loggers.audit.archive-task/admin-console-job-def + {::db/pool (:app.db/pool th/*system*) + :app.nitrate/client (:app.nitrate/client th/*system*) + ::mtx/metrics metrics})] + ((::jobs/handler job-def) nil {}))) + +(defn- invoke-both! + "Invoke both archive handlers sharing one isolated metrics + registry, Nexus first (the historical single-run order)." + [metrics params] + (invoke-nexus! metrics params) + (invoke-admin-console! metrics)) + +(defn- counter-value + [metrics id labels] + (let [collector (mtx/get-collector metrics id) + instance (::mdef/instance collector) + child (.labels ^Counter instance (into-array String labels))] + (.get ^Counter$Child child))) + +(t/deftest archive-records-metrics-on-success + ;; Smoke test: the run records its domain metrics into the wired + ;; registry (isolated per test, pattern from `jobs-metrics-test`). + ;; Logging itself is not asserted: `dbg` summaries run below the + ;; test log level and failure `warn`s only repeat the outcome the + ;; metrics already prove. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (insert-audit-row! (:id prof) "create-project") + (insert-audit-row! (:id prof) "unrelated-event") + (invoke-both! metrics {:uri archive-uri}) + (t/is (= 2.0 (counter-value metrics :audit-archive-events ["nexus" "archived"]))) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "archived"]))) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "skipped"]))) + (t/is (= 0 (pending-count))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PENDING-QUEUE TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- full-event + "A complete event for submit*, which (unlike submit) assumes every + required field is filled." + [profile-id] + {:name "create-file" :type "action" :source "backend" + :profile-id profile-id :ip-addr "127.0.0.1" + :props {} :context {} + :created-at (ct/now) :tracked-at (ct/now)}) + +(t/deftest archive-submit-creates-pendings-for-enabled-archivers + ;; The producer hook: a row is born pending for the enabled + ;; archivers, in the same transaction as the row itself. Goes + ;; through submit* because the suite stubs submit itself (see + ;; helpers/init-config). + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:audit-log :nexus :admin-console}] + (audit/submit* {::db/pool (:app.db/pool th/*system*)} + (full-event (:id prof))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 2 (pending-count)))))) + +(t/deftest archive-submit-single-flag-creates-one-pending + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:audit-log :nexus}] + (audit/submit* {::db/pool (:app.db/pool th/*system*)} + (full-event (:id prof))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count)))))) + +(t/deftest archive-submit-rollback-keeps-no-pendings + ;; A rollback takes the row and its queue together: a row never + ;; outlives its pendings, and no phantom pending pins nothing. + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:audit-log :nexus :admin-console}] + (db/tx-run! {::db/pool (:app.db/pool th/*system*) + ::db/rollback true} + (fn [cfg] + (audit/submit* cfg (full-event (:id prof))) + (t/is (= 1 (count (db/exec! cfg ["select * from audit_log"])))) + (t/is (= 2 (count (db/exec! cfg ["select * from audit_log_pending"])))))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count)))))) + +(t/deftest archive-keeps-row-until-last-archiver-done + ;; Nexus refuses the chunk: the nexus job aborts (failed count + ;; recorded, isolated registry) while the admin-console job still + ;; drains its own queue. The row and the nexus pending stay; once + ;; nexus accepts, everything is gone. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus :admin-console}] + (insert-audit-row! (:id prof) "create-project") + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 500}}] + (let [err (try + (invoke-nexus! metrics {:uri archive-uri}) + nil + (catch clojure.lang.ExceptionInfo cause cause))] + (t/is (= :archive-chunk-refused (:code (ex-data err))))) + (invoke-admin-console! metrics) + (t/is (:called? @admin-console-mock)) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count)))) + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (run-both! {:uri archive-uri}) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count))))))) + +(t/deftest archive-sweeps-orphan-rows + ;; A row with no pendings (old archived leftover, or born while no + ;; archiver was on) is deleted without any send. + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + ;; Raw insert, no pendings on purpose. + (th/db-insert! :audit-log + {:id (uuid/next) + :name "create-project" + :type "action" + :source "backend" + :profile-id (:id prof) + :ip-addr (db/inet "127.0.0.1") + :props (db/tjson {}) + :context (db/tjson {}) + :tracked-at (ct/now) + :created-at (ct/now)}) + (run-both! {:uri archive-uri}) + (t/is (false? (:called? @admin-console-mock))) + (t/is (false? (:called? @http-mock))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))))))) + +(t/deftest archive-nexus-transport-failure-records-failed + ;; A transport exception (timeout, DNS, refused) counts the events + ;; as failed and aborts the nexus job: the chunk rolls back and + ;; the rows stay for the next tick. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus}] + (with-mocks [http-mock {:target 'app.http.client/req + :throw (ex-info "timeout" {})}] + (insert-audit-row! (:id prof) "create-project") + (t/is (thrown? clojure.lang.ExceptionInfo + (invoke-nexus! metrics {:uri archive-uri}))) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count))))))) + +(t/deftest archive-sends-chunks-in-chronological-order + ;; Nexus numbers by arrival for order-sensitive consumers, so + ;; multi-chunk drains must arrive oldest-first no matter how the + ;; queue is scanned. + (let [prof (th/create-profile* 1 {:is-active true}) + base (ct/now) + bodies (atom [])] + (with-redefs [cf/flags #{:nexus}] + (with-mocks [http-mock {:target 'app.http.client/req + :return (fn [_ params & _] + (swap! bodies conj (:body params)) + {:status 204})}] + (doseq [i (range 130)] + (let [row (insert-audit-row! (:id prof) (str "event-" i))] + (th/db-update! :audit-log + {:created-at (.plusSeconds ^java.time.Instant base (long i))} + {:id (:id row)}))) + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (let [received (mapcat #(:events (transit/decode %)) @bodies) + times (map :created-at received)] + (t/is (= 130 (count received))) + (t/is (= (sort times) times)) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"]))))))))) + +(t/deftest archive-reaps-disabled-archiver-pendings + ;; R3 reap by its real path: an admin-console pending born while + ;; admin-console was on is dropped once it is off; nexus still + ;; archives its queue and the row is gone with nothing stuck. The + ;; dropped pending counts as discarded (isolated registry). + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} + admin-console-mock {:target 'app.nitrate/call :return nil}] + (let [row (insert-audit-row! (:id prof) "create-project")] + ;; Admin console was on at insert time: both pendings on purpose. + (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, archiver) + VALUES (?::uuid, 2) + ON CONFLICT DO NOTHING" + (:id row)]) + (t/is (= 2 (pending-count))) + (invoke-nexus! metrics {:uri archive-uri}) + (t/is (:called? @http-mock)) + (t/is (false? (:called? @admin-console-mock))) + (t/is (= 1.0 (counter-value metrics :audit-archive-discarded ["reaped"]))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count)))))))) + +(t/deftest archive-sweeps-rows-born-with-no-archiver-on + ;; Accepted R3 consequence by its real path (not a hand-made + ;; orphan): ingested with no send flag on, the row is swept + ;; unarchived once the task runs, with zero sends. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:audit-log}] + (audit/submit* {::db/pool (:app.db/pool th/*system*)} + (full-event (:id prof))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count)))) + (with-redefs [cf/flags #{:audit-log :nexus}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}}] + (invoke-nexus! metrics {:uri archive-uri}) + (t/is (false? (:called? @http-mock))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count))) + (t/is (= 1.0 (counter-value metrics :audit-archive-discarded ["swept"]))) + (t/is (= 0.0 (counter-value metrics :audit-archive-events ["nexus" "archived"]))))))) + +(t/deftest archive-nexus-throw-still-drains-admin-console + ;; Consumer independence, now structural: a Nexus transport throw + ;; aborts only the nexus job, and the admin-console job still drains + ;; its own queue. Nexus keeps its pending (so the row stays); the + ;; admin console completes its own queue. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req + :throw (ex-info "timeout" {})}] + (insert-audit-row! (:id prof) "create-project") + (t/is (thrown? clojure.lang.ExceptionInfo + (invoke-nexus! metrics {:uri archive-uri}))) + (invoke-admin-console! metrics) + (t/is (:called? @admin-console-mock)) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count))))))) + +(t/deftest archive-reap-then-sweeps-without-sends + ;; End to end of the reap path: a row born with only an + ;; admin-console pending is reaped once :admin-console goes off, + ;; then swept with zero sends on either destination. + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:admin-console}] + (insert-audit-row! (:id prof) "create-project") + (t/is (= 1 (pending-count)))) + (with-redefs [cf/flags #{:nexus}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} + admin-console-mock {:target 'app.nitrate/call :return nil}] + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (t/is (false? (:called? @http-mock))) + (t/is (false? (:called? @admin-console-mock))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count))))))) + +(t/deftest archive-fully-filtered-admin-console-chunk-counts-skipped + ;; An admin-console chunk with every event outside the allowlist + ;; never calls ingest, but still counts its rows as skipped and + ;; completes its queue. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (insert-audit-row! (:id prof) "unrelated-event") + (invoke-both! metrics {:uri archive-uri}) + (t/is (false? (:called? @admin-console-mock))) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "skipped"]))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count))))))) diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index 7c99c0093c..6080c483cf 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -14,13 +14,24 @@ [app.db :as db] [app.jobs :as-alias jobs] [app.loggers.audit :as audit] + [app.main :as main] + [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] [app.rpc :as-alias rpc] [app.rpc.climit :as climit] + [app.rpc.commands.audit :as cmd-audit] [app.util.services :as sv] [backend-tests.helpers :as th] + [clojure.string :as str] [clojure.test :as t] + [integrant.core :as ig] [mockery.core :refer [with-mocks]] - [yetti.request])) + [yetti.request]) + (:import + io.prometheus.client.Collector$MetricFamilySamples + io.prometheus.client.CollectorRegistry + io.prometheus.client.Counter + io.prometheus.client.Counter$Child)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -97,7 +108,7 @@ (assoc base :app.http/auth-key-id :nexus))))))) (t/deftest push-events-1 - (with-redefs [app.config/flags #{:audit-log}] + (with-redefs [app.config/flags #{:audit-log :nexus}] (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) proj-id (:default-project-id prof) @@ -127,7 +138,9 @@ (t/is (= 1 (count rows))) (t/is (= (:id prof) (:profile-id row))) (t/is (= "navigate" (:name row))) - (t/is (= "frontend" (:source row))))))) + (t/is (= "frontend" (:source row))) + ;; The row is born pending for the enabled archiver. + (t/is (= 1 (count (th/db-exec! ["select * from audit_log_pending"])))))))) (t/deftest push-events-2 (with-redefs [app.config/flags #{:audit-log}] @@ -228,36 +241,41 @@ (t/deftest push-events-both-flags-creates-two-rows ;; When both :audit-log and :telemetry flags are active, two rows ;; should be stored: one full audit entry and one telemetry entry. - (with-redefs [cf/flags #{:audit-log :telemetry}] - (let [prof (th/create-profile* 1 {:is-active true}) - params {::th/type :push-audit-events - ::rpc/profile-id (:id prof) - :events [{:name "navigate" - :props {:route "dashboard"} - :context {:browser "Chrome" - :version "2.0.0" - :initiator "app"} - :timestamp (ct/now) - :type "action"}]} - params (with-meta params - {:app.http/request http-request}) - out (th/command! params)] + ;; The profile is created before enabling :admin-console: team + ;; bootstrap calls the real nitrate client, which is unconfigured + ;; in tests. + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:audit-log :telemetry :nexus :admin-console}] + (let [params {::th/type :push-audit-events + ::rpc/profile-id (:id prof) + :events [{:name "navigate" + :props {:route "dashboard"} + :context {:browser "Chrome" + :version "2.0.0" + :initiator "app"} + :timestamp (ct/now) + :type "action"}]} + params (with-meta params + {:app.http/request http-request}) + out (th/command! params)] - (t/is (nil? (:error out))) + (t/is (nil? (:error out))) - (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"]) - (mapv decode-row))] - (t/is (= 2 (count rows))) - ;; First row: full audit-log entry - (t/is (= "frontend" (:source row1))) - (t/is (contains? (:props row1) :route)) - (t/is (not= "0.0.0.0" (str (:ip-addr row1)))) - ;; Second row: telemetry entry - (t/is (= "telemetry:frontend" (:source row2))) - (t/is (= "0.0.0.0" (str (:ip-addr row2)))) - (let [day-now (ct/truncate (ct/now) :days)] - (t/is (= day-now (:created-at row2))) - (t/is (= day-now (:tracked-at row2)))))))) + (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"]) + (mapv decode-row))] + (t/is (= 2 (count rows))) + ;; First row: full audit-log entry + (t/is (= "frontend" (:source row1))) + (t/is (contains? (:props row1) :route)) + (t/is (not= "0.0.0.0" (str (:ip-addr row1)))) + ;; Second row: telemetry entry + (t/is (= "telemetry:frontend" (:source row2))) + (t/is (= "0.0.0.0" (str (:ip-addr row2)))) + (let [day-now (ct/truncate (ct/now) :days)] + (t/is (= day-now (:created-at row2))) + (t/is (= day-now (:tracked-at row2)))) + ;; Both rows are born pending for the enabled archivers. + (t/is (= 4 (count (th/db-exec! ["select * from audit_log_pending"]))))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; BACKEND PROCESS-EVENT PATH (RPC commands) @@ -857,3 +875,89 @@ (t/is (= (:id caller) (:profile-id (prepare-event (:id caller) result))) (str "override " (pr-str bad) " must fall back to the caller")))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; INGEST METRICS (bulk frontend path) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; handle-events resolves its metrics from the cfg (same pattern as +;; the jobs substrate), so these tests drive it directly with an +;; isolated registry instead of the shared test-system one. +(defn- make-ingest-metrics + [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics [:audit-ingest-events])})) + +(defn- ingest-counter + [metrics kind outcome] + (let [collector (mtx/get-collector metrics :audit-ingest-events) + instance (::mdef/instance collector) + child (.labels ^Counter instance (into-array String [kind outcome]))] + (.get ^Counter$Child child))) + +(defn- counter-label-sets + "Exact label sets present for the ingest counter, read off the + registry so phantom series cannot hide. Matches the family by + prefix (Prometheus strips the `_total` suffix from the family + name) and only counts `_total` samples, not `_created` ones." + [metrics] + (let [full (get main/default-metrics :audit-ingest-events) + base (str/replace (::mdef/name full) #"_total$" "")] + (->> (enumeration-seq + (.metricFamilySamples ^CollectorRegistry (mtx/get-registry metrics))) + (filter (fn [^Collector$MetricFamilySamples family] + (str/starts-with? (.-name family) base))) + (mapcat (fn [^Collector$MetricFamilySamples family] + (.samples family))) + (filter (fn [sample] (str/ends-with? (.-name sample) "_total"))) + (into #{} (map (fn [sample] (vec (.-labelValues sample)))))))) + +(defn- push-params + [profile-id] + (with-meta {::rpc/profile-id profile-id + ::rpc/request-at (ct/now) + :events [{:name "navigate" :type "action" + :props {:route "dashboard"} + :timestamp (ct/now)}]} + ;; A map request (like th/command! builds): the reify IRequest + ;; stub only serves the IP headers and blows up on x-client. + {:app.http/request (th/make-dummy-request)})) + +(t/deftest push-handle-events-records-inserted + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-ingest-metrics)] + (with-redefs [cf/flags #{:audit-log :telemetry :nexus :admin-console}] + (@#'cmd-audit/handle-events {::db/pool th/*pool* ::mtx/metrics metrics} + (push-params (:id prof))) + (t/is (= 1.0 (ingest-counter metrics "full" "inserted"))) + (t/is (= 1.0 (ingest-counter metrics "shadow" "inserted"))) + (t/is (= 2 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 4 (count (th/db-exec! ["select * from audit_log_pending"]))))))) + +(t/deftest push-handle-events-records-failed-on-error + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-ingest-metrics)] + (with-redefs [cf/flags #{:audit-log :telemetry :nexus :admin-console}] + (with-mocks [insert-mock {:target 'app.db/insert-many! + :throw (ex-info "boom" {})}] + (t/is (thrown? clojure.lang.ExceptionInfo + (@#'cmd-audit/handle-events {::db/pool th/*pool* ::mtx/metrics metrics} + (push-params (:id prof))))) + (t/is (= 1.0 (ingest-counter metrics "full" "failed"))) + (t/is (= 1.0 (ingest-counter metrics "shadow" "failed"))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log_pending"])))))))) + +(t/deftest push-handle-events-single-face-emits-no-empty-series + ;; With only :audit-log on, exactly one ingest series exists: the + ;; pos? guard in record-ingested emits no phantom shadow series. + ;; (No send flags either, so R3 leaves the row pending-less.) + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-ingest-metrics)] + (with-redefs [cf/flags #{:audit-log}] + (@#'cmd-audit/handle-events {::db/pool th/*pool* ::mtx/metrics metrics} + (push-params (:id prof))) + (t/is (= 1.0 (ingest-counter metrics "full" "inserted"))) + (t/is (= #{["full" "inserted"]} (counter-label-sets metrics))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log_pending"]))))))) diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index 79f29f335b..4585de8c98 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -11,6 +11,7 @@ [app.config :as cf] [app.db :as db] [app.loggers.audit :as audit] + [app.loggers.audit.archive-task :as at] [app.tasks.telemetry :as telemetry] [app.util.json :as json] [backend-tests.helpers :as th] @@ -999,3 +1000,45 @@ (th/run-task! :telemetry {:send? true :enabled? true}) (t/is (= 1 @send-count)) ;; still 1, not 2 (t/is (= 0 (count-telemetry-rows))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PENDING-QUEUE REGRESSION (telemetry deletes) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- insert-pending-pair! + "Queue the row for both archivers (from the archiver constants, not + literals): the delete paths must face pending-ful rows." + [row-id] + (doseq [archiver [at/archiver-nexus at/archiver-admin-console]] + (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, archiver) + VALUES (?::uuid, ?::smallint) + ON CONFLICT DO NOTHING" + row-id archiver]))) + +(defn- count-pendings [] + (-> (th/db-exec-one! ["SELECT count(*) AS cnt FROM audit_log_pending"]) + :cnt + long)) + +(t/deftest test-delete-sent-events-removes-pendings-first + ;; Regression: telemetry rows carry pendings for both archivers, so + ;; deleting the row must delete its pendings first, or the FK + ;; rejects the delete and the batch is sent again next tick. + (let [row (insert-telemetry-row! "navigate")] + (insert-pending-pair! (:id row)) + (t/is (= 2 (count-pendings))) + (db/tx-run! th/*pool* + (fn [{:keys [::db/conn]}] + (@#'telemetry/delete-sent-events conn [(:id row)]))) + (t/is (= 0 (count-telemetry-rows))) + (t/is (= 0 (count-pendings))))) + +(t/deftest test-gc-events-removes-pendings-first + ;; Same guarantee for the >7-days purge. + (let [past (ct/in-past {:days 8}) + row (insert-telemetry-row! "navigate" {:created-at past :tracked-at past})] + (insert-pending-pair! (:id row)) + (t/is (= 2 (count-pendings))) + (@#'telemetry/gc-events {::db/conn th/*pool*}) + (t/is (= 0 (count-telemetry-rows))) + (t/is (= 0 (count-pendings))))) From be2e117f670fdc3fe440e55bea541df28f1c76d9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 23:25:44 +0200 Subject: [PATCH 093/167] :recycle: Rename audit-log archivers to consumers and use base Nexus URI (#12459) * :recycle: Rename audit-log archivers to consumers The pending-queue owners are no longer only external archives: a local, non-archiving drain is about to join them, so "archiver" no longer fits. Rename the column, identifiers, jobs, metrics and docs to "consumer" in one release; migration 0157 never reached production, so a single RENAME COLUMN is enough, with no backfill. AI-assisted-by: muse-spark-1.3-contributor * :recycle: Use base Nexus URI with appended ingest path Mirror the admin-console setup: PENPOT_NEXUS_URI holds the base URI and the consumer appends api/audit itself, instead of a full endpoint URI. Keeps the explicit :uri job param as override and the :task-not-configured guard when no base is set. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/audit-log.md | 26 +-- .serena/memories/prod-infra/core.md | 2 +- backend/scripts/_env | 2 +- backend/src/app/config.clj | 2 +- backend/src/app/loggers/audit.clj | 8 +- .../{archive_task.clj => consumer_task.clj} | 217 +++++++++--------- backend/src/app/main.clj | 26 +-- .../sql/0157-add-audit-log-pending-table.sql | 18 +- backend/src/app/rpc/commands/audit.clj | 6 +- backend/src/app/tasks/telemetry.clj | 6 +- ...j => loggers_audit_consumer_task_test.clj} | 198 ++++++++-------- backend/test/backend_tests/rpc_audit_test.clj | 4 +- .../backend_tests/tasks_telemetry_test.clj | 12 +- 13 files changed, 276 insertions(+), 251 deletions(-) rename backend/src/app/loggers/audit/{archive_task.clj => consumer_task.clj} (77%) rename backend/test/backend_tests/{loggers_audit_archive_task_test.clj => loggers_audit_consumer_task_test.clj} (82%) diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index bc33a4a190..68d4bcc021 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -45,7 +45,7 @@ Rule of thumb: if you need to know for sure "did this user do X?", look at `sour ## 3. The `audit_log` table: a buffer, not a store -The Penpot `audit_log` table **is not the final destination**. It is a waiting tray: events sit here until every active archiver (Nexus, admin console) confirms them, and whoever finishes last deletes the row. +The Penpot `audit_log` table **is not the final destination**. It is a waiting tray: events sit here until every active consumer (Nexus, admin console) confirms them, and whoever finishes last deletes the row. Main columns (migration `backend/src/app/migrations/sql/0054-add-audit-log-table.sql` and later ones): @@ -57,16 +57,16 @@ Main columns (migration `backend/src/app/migrations/sql/0054-add-audit-log-table - `source` text: `backend` / `frontend` for full rows, `telemetry:backend` / `telemetry:frontend` for anonymized copies (see section 10). - `ip_addr` inet: real IP (or `0.0.0.0` on telemetry rows). - `props` / `context` jsonb holding transit-encoded maps: action data and metadata (versions, browser, request-id...). -- No `archived_at`: the `audit_log_pending` queue (migration `0157`, archiver `1` = Nexus, `2` = admin console, ids never reused) tracks what is left. Every record is born pending only for the archivers enabled at insert time (never a fixed both): cheaper on single-archiver hosts and true to the design doc. Consequence, accepted on purpose: rows born with no send flag on get no pendings, so enabling archiving later does not pick them up — the sweep deletes them unarchived. +- No `archived_at`: the `audit_log_pending` queue (migration `0157`, consumer `1` = Nexus, `2` = admin console, ids never reused) tracks what is left. Every record is born pending only for the consumers enabled at insert time (never a fixed both): cheaper on single-consumer hosts and true to the design doc. Consequence, accepted on purpose: rows born with no send flag on get no pendings, so enabling archiving later does not pick them up — the sweep deletes them unarchived. Indexes (each consumer has its own, so a slow consumer never blocks the others): -- `audit_log_pending` PK `(archiver, audit_log_id)`: each archiver owns its queue at constant cost, even with millions pending. -- `audit_log (created_at, id)` (migration `0157`): the chronological drain order (`ORDER BY created_at ASC, id ASC`; same total order for both archivers, so concurrent drains cannot deadlock). +- `audit_log_pending` PK `(consumer, audit_log_id)`: each consumer owns its queue at constant cost, even with millions pending. +- `audit_log (created_at, id)` (migration `0157`): the chronological drain order (`ORDER BY created_at ASC, id ASC`; same total order for both consumers, so concurrent drains cannot deadlock). - `audit_log_pending (audit_log_id)`: serves the "is anything still pending?" check of the final delete. - `(source, created_at)`: used by the telemetry scan. -Life of a row: `INSERT` (row plus one pending per enabled archiver, same transaction; a rollback takes both) → (every 5 min, Nexus at second 0 and admin console at second 30) each archive job sends its own 128-row batches oldest-first and deletes its pendings in the same transaction → whoever deletes the last pending deletes the row. A batch the destination refuses rolls back and stays for the next tick. Pendings of archivers that are off are reaped at the start of each job run (covers later deactivations), and orphan rows are swept at the end of each run, so there is no separate GC task. The FK is plain `DEFERRABLE` (never `INITIALLY DEFERRED`): every deleter removes pendings before rows, including the telemetry task's own deletes. +Life of a row: `INSERT` (row plus one pending per enabled consumer, same transaction; a rollback takes both) → (every 5 min, Nexus at second 0 and admin console at second 30) each archive job sends its own 128-row batches oldest-first and deletes its pendings in the same transaction → whoever deletes the last pending deletes the row. A batch the destination refuses rolls back and stays for the next tick. Pendings of consumers that are off are reaped at the start of each job run (covers later deactivations), and orphan rows are swept at the end of each run, so there is no separate GC task. The FK is plain `DEFERRABLE` (never `INITIALLY DEFERRED`): every deleter removes pendings before rows, including the telemetry task's own deletes. ## 4. Anatomy of an event @@ -277,7 +277,7 @@ Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and insert ### How it works today -Every 5 min each archive job drains its own queue (`:audit-log-archive-nexus` on flag `:nexus`, cron `0 */5 * * * ?`; `:audit-log-archive-admin-console` on flag `:admin-console`, cron `30 */5 * * * ?`, 30s offset so the two drains never start on the same second), in 128-row chunks taken oldest-first (`ORDER BY created_at ASC, id ASC`) with `FOR UPDATE OF audit_log` (`SET LOCAL lock_timeout = '30s'`) inside the same transaction that sends and completes them, with 100 ms pauses between chunks. Batches arrive at Nexus in chronological order (it numbers by arrival for order-sensitive consumers). Nexus receives transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri; flag on but no URI raises `:task-not-configured`. The admin console receives only its allowlisted event names (enriched with team names and emails); anything filtered out counts as done for the admin-console queue. A chunk whose destination refuses it (Nexus non-204 raises `:archive-chunk-refused`) or throws (transport failure, admin-console error) rolls back and stays for the next tick, and the job aborts as failed: it archives what it can, and the cron retries the rest. Committed chunks stay sent. The send fns return counts or throw carrying the attempted count as `:events`; `run-single-archiver` records the `failed` metric from it, outside every chunk transaction (a local DB failure carries no `:events` and stays uncounted — it is not a destination failure). No `warn` before the throw: the runner reports the aborted job. Each job touches only its own queue, so a fallen Nexus never starves the admin console (nor vice versa). +Every 5 min each archive job drains its own queue (`:audit-log-consumer-nexus` on flag `:nexus`, cron `0 */5 * * * ?`; `:audit-log-consumer-admin-console` on flag `:admin-console`, cron `30 */5 * * * ?`, 30s offset so the two drains never start on the same second), in 128-row chunks taken oldest-first (`ORDER BY created_at ASC, id ASC`) with `FOR UPDATE OF audit_log` (`SET LOCAL lock_timeout = '30s'`) inside the same transaction that sends and completes them, with 100 ms pauses between chunks. Batches arrive at Nexus in chronological order (it numbers by arrival for order-sensitive consumers). Nexus receives transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri; flag on but no URI raises `:task-not-configured`. The admin console receives only its allowlisted event names (enriched with team names and emails); anything filtered out counts as done for the admin-console queue. A chunk whose destination refuses it (Nexus non-204 raises `:consumer-chunk-refused`) or throws (transport failure, admin-console error) rolls back and stays for the next tick, and the job aborts as failed: it archives what it can, and the cron retries the rest. Committed chunks stay sent. The send fns return counts or throw carrying the attempted count as `:events`; `run-single-consumer` records the `failed` metric from it, outside every chunk transaction (a local DB failure carries no `:events` and stays uncounted — it is not a destination failure). No `warn` before the throw: the runner reports the aborted job. Each job touches only its own queue, so a fallen Nexus never starves the admin console (nor vice versa). A chunk is **one POST** and the task advances only on 204, so a single oversized event wedges the whole queue: a crash report whose message embeds a whole payload (a serialized file, for example) can push the request past the proxy body limit and the rejection blocks every later event until it is pruned by hand. Frontend crash reports are therefore size-bounded at the source (`mem:frontend/routing-app-shell-subtleties`): `app.common.exceptions/get-hint` and the CLJS `format-throwable` clamp exception messages (`max-message-length`). @@ -285,7 +285,7 @@ On the other side, Nexus checks `origin` against its allowlist (403 if not allow Concurrent jobs are safe: both lock rows in `created_at ASC, id ASC` order (one total order, deliberately blocking — no `SKIP LOCKED`, so archive arrival stays chronological), so the second waits for the first to commit and then sees whether it was last. Only acknowledged work deletes pendings; an admin-console failure never blocks the Nexus send of the same rows (each job owns its queue). -Observability (`backend/src/app/loggers/audit/archive_task.clj`, definitions in `backend/src/app/main.clj:default-metrics`): one `dbg` summary per job run (`:archiver` label, sent/skipped/chunks, reaped disabled pendings, swept orphans, `:elapsed` as `"1.5s"` via `ct/format-duration`, frozen in a strict binding first because log props evaluate lazily in the logger thread), one `dbg` per admin-console send call (`ready` with totals, `sent`/`failed` with counts), plus `dbg` when the queue is empty or the job's send flag is off. Queue depth is Grafana's business: it queries the database directly, so the run performs no `GROUP BY` for the logs. No `warn` before a throw: the runner reports the aborted job (refusal details travel in the error data). Metrics, all recorded with bounded labels only: `penpot_audit_archive_events_total{ destination = nexus|admin-console, outcome = archived|failed|skipped }` (`skipped` = filtered by the admin-console allowlist) and `penpot_audit_archive_discarded_total{ reason = reaped|swept }` (entries discarded without archiving: `reaped` counts pendings dropped for disabled archivers, `swept` counts rows deleted while nobody waited for them — the silent R3 loss, now dashboard-visible instead of log-only). Timing needs no custom histogram: the jobs substrate already records execution time per job (`jobs-execution-timing`, split by job name), so each archive job is timed as itself. No queue-depth gauge: pending depth is read off the replica database directly. The bulk frontend ingest counts `penpot_audit_ingest_events_total{ kind = full|shadow, outcome = inserted|failed }` (both faces share one transaction, so a failure drops the batch on both). Archive success counts are recorded by `run-single-archiver` on the drain totals: a chunk that returns normally committed its transaction (a rollback always throws instead), so they cannot inflate. Logs never carry event data (`props`, `context`, `profile-id`, `ip-addr`, `team-name`, emails): only counts, statuses and the uri host. +Observability (`backend/src/app/loggers/audit/consumer_task.clj`, definitions in `backend/src/app/main.clj:default-metrics`): one `dbg` summary per job run (`:consumer` label, sent/skipped/chunks, reaped disabled pendings, swept orphans, `:elapsed` as `"1.5s"` via `ct/format-duration`, frozen in a strict binding first because log props evaluate lazily in the logger thread), one `dbg` per admin-console send call (`ready` with totals, `sent`/`failed` with counts), plus `dbg` when the queue is empty or the job's send flag is off. Queue depth is Grafana's business: it queries the database directly, so the run performs no `GROUP BY` for the logs. No `warn` before a throw: the runner reports the aborted job (refusal details travel in the error data). Metrics, all recorded with bounded labels only: `penpot_audit_consumer_events_total{ destination = nexus|admin-console, outcome = archived|failed|skipped }` (`skipped` = filtered by the admin-console allowlist) and `penpot_audit_consumer_discarded_total{ reason = reaped|swept }` (entries discarded without archiving: `reaped` counts pendings dropped for disabled consumers, `swept` counts rows deleted while nobody waited for them — the silent R3 loss, now dashboard-visible instead of log-only). Timing needs no custom histogram: the jobs substrate already records execution time per job (`jobs-execution-timing`, split by job name), so each archive job is timed as itself. No queue-depth gauge: pending depth is read off the replica database directly. The bulk frontend ingest counts `penpot_audit_ingest_events_total{ kind = full|shadow, outcome = inserted|failed }` (both faces share one transaction, so a failure drops the batch on both). Archive success counts are recorded by `run-single-consumer` on the drain totals: a chunk that returns normally committed its transaction (a rollback always throws instead), so they cannot inflate. Logs never carry event data (`props`, `context`, `profile-id`, `ip-addr`, `team-name`, emails): only counts, statuses and the uri host. ### How Nexus consumes the data (and forwards to PostHog) @@ -297,9 +297,9 @@ Stored rows are not the end: a consumer pipeline reads them back in sequence ord So the full journey is: Penpot buffer → Nexus `audit_log` (via either archive cron or the telemetry endpoint) → consumers → PostHog (SaaS events grouped by team, telemetry grouped by instance) plus local analytics views and materialized KPIs. -### Admin console: the second archiver +### Admin console: the second consumer -Penpot → admin-console archiving lives in its own job (`:audit-log-archive-admin-console`, archiver `2`, flag `:admin-console`, params closed empty): it drains its own pending queue and ingests allowlisted events through `nitrate/call :ingest-audit-log`, enriched as described above. What also exists (do not confuse with archiving): +Penpot → admin-console archiving lives in its own job (`:audit-log-consumer-admin-console`, consumer `2`, flag `:admin-console`, params closed empty): it drains its own pending queue and ingests allowlisted events through `nitrate/call :ingest-audit-log`, enriched as described above. What also exists (do not confuse with archiving): - **Nitrate → Penpot**: `POST /api/management/methods/push-audit-events` (`backend/src/app/rpc/management/nitrate.clj`) lets the trusted Nitrate backend inject events into the Penpot audit log (no RPC auth, `::audit/skip`, request context + `submit`). Section 7a is the main user. - **Admin-console browser → collector**: usage events from the admin-console UI traveling through the normal public `push-audit-events` (section 7b). @@ -312,13 +312,13 @@ Flags (`common/src/app/common/flags.cljc`, varia; enabled with `PENPOT_FLAGS=ena | Flag | What it does | |---|---| | `:audit-log` | Stores full rows; turns on `wrap-audit` and frontend ingest | -| `:nexus` | Send flag of the Nexus archive job `:audit-log-archive-nexus` (cron `0 */5 * * * ?`; needs `:audit-log-archive-uri`) | -| `:admin-console` | Send flag of the admin-console archive job `:audit-log-archive-admin-console` (cron `30 */5 * * * ?`, 30s after Nexus); also the module flag for the admin-console integration | +| `:nexus` | Send flag of the Nexus archive job `:audit-log-consumer-nexus` (cron `0 */5 * * * ?`; needs `:nexus-uri` (ingest path `api/audit` appended by the job)) | +| `:admin-console` | Send flag of the admin-console archive job `:audit-log-consumer-admin-console` (cron `30 */5 * * * ?`, 30s after Nexus); also the module flag for the admin-console integration | | `:audit-log-logger` | Structured `app.audit` log per event | | `:error-reporting` | Starts the error reporters (off by default); database reporter on flag alone, Mattermost on flag plus webhook URL | | `:telemetry` | Stores shadow rows and turns on shipping (auto with `:telemetry-enabled`) | -Config (`backend/src/app/config.clj`): `:audit-log-archive-uri`, `:telemetry-uri` (default `https://telemetry.penpot.app/`), `:nexus-shared-key` / instance secret, `:public-uri` (sent as `origin` to Nexus). +Config (`backend/src/app/config.clj`): `:nexus-uri`, `:telemetry-uri` (default `https://telemetry.penpot.app/`), `:nexus-shared-key` / instance secret, `:public-uri` (sent as `origin` to Nexus). ## 14. Practical guide: add or change events @@ -337,6 +337,6 @@ Config (`backend/src/app/config.clj`): `:audit-log-archive-uri`, `:telemetry-uri - Penpot frontend producer: `frontend/src/app/main/data/event.cljs` (collector, buffer, send), `frontend/src/app/main/repo.cljs` + `frontend/src/app/util/http.cljs` (headers), `frontend/src/app/main/data/nitrate.cljs` (Nitrate membership events). - Admin-console producer (private repo, no paths cited here): server-side organization/subscription events through the management `push-audit-events`, browser-side UI events through the public `push-audit-events`. - Consumers: `backend/src/app/loggers/webhooks.clj`, `backend/src/app/loggers/database.clj`, `backend/src/app/loggers/mattermost.clj`, `backend/src/app/tasks/telemetry.clj`. -- Archive (two jobs sharing one namespace + pending queue, no GC): `backend/src/app/loggers/audit/archive_task.clj` (`:audit-log-archive-nexus` / `:audit-log-archive-admin-console` job-defs, shared drain/reap/sweep helpers), producer hook in `backend/src/app/loggers/audit.clj` (`append-audit-entry`, single `queue-pendings` helper) plus the bulk ingest in `backend/src/app/rpc/commands/audit.clj` (`handle-events`), metrics/cron wiring in `backend/src/app/main.clj` (one cron entry per job, Nexus at second 0 and admin console at second 30, each gated by its own send flag), single migration `0157` (queue, drop of `archived_at`, covering `(created_at, id)` index for the chronological scan; no backfill — pre-existing rows are swept unsent, an accepted loss). +- Archive (two jobs sharing one namespace + pending queue, no GC): `backend/src/app/loggers/audit/consumer_task.clj` (`:audit-log-consumer-nexus` / `:audit-log-consumer-admin-console` job-defs, shared drain/reap/sweep helpers), producer hook in `backend/src/app/loggers/audit.clj` (`append-audit-entry`, single `queue-pendings` helper) plus the bulk ingest in `backend/src/app/rpc/commands/audit.clj` (`handle-events`), metrics/cron wiring in `backend/src/app/main.clj` (one cron entry per job, Nexus at second 0 and admin console at second 30, each gated by its own send flag), single migration `0157` (queue, drop of `archived_at`, covering `(created_at, id)` index for the chronological scan; no backfill — pre-existing rows are swept unsent, an accepted loss). - Nexus (private repo, no paths cited here): archive ingest, ordered consumers (local profile mirror, PostHog SaaS events, PostHog telemetry), telemetry endpoint, analytics views. - Tests: `backend_tests/rpc-audit-test.clj`, `rpc-team-test/accept-organization-invitation-audit-event`. diff --git a/.serena/memories/prod-infra/core.md b/.serena/memories/prod-infra/core.md index 2b3509a33f..5cd8bb707d 100644 --- a/.serena/memories/prod-infra/core.md +++ b/.serena/memories/prod-infra/core.md @@ -22,7 +22,7 @@ Postgres row locking is the only correctness primitive: `job` claims via `FOR UP Two known race patterns survive multi-backend operation: -- **Cron dedup is best-effort.** The lock on `scheduled_task` is released when the task body finishes. If two backends' cron timers fire for the same scheduled instant with a gap larger than the task body's runtime, both execute it. Penpot's cron entries are idempotent (`session-gc`, `objects-gc`, `storage-gc-*`, `storage-pending-gc`, `jobs-gc`, `file-gc-scheduler`); the exceptions are `:telemetry` (would double-report) and `:audit-log-archive` (depends on archive target idempotency). +- **Cron dedup is best-effort.** The lock on `scheduled_task` is released when the task body finishes. If two backends' cron timers fire for the same scheduled instant with a gap larger than the task body's runtime, both execute it. Penpot's cron entries are idempotent (`session-gc`, `objects-gc`, `storage-gc-*`, `storage-pending-gc`, `jobs-gc`, `file-gc-scheduler`); the exceptions are `:telemetry` (would double-report) and `:audit-log-consumer-nexus` / `:audit-log-consumer-admin-console` (depend on archive target idempotency). - **`jobs/submit ::dedupe true`** does a non-atomic `DELETE` then `INSERT`. Concurrent cross-backend submits can both bypass the `DELETE` (each sees the other's uncommitted insert as absent) and end up with duplicate `'new'` rows. Each row claims and runs once independently, so the underlying work is fine; the "at most one pending" guarantee weakens. Penpot in production lives with both: horizontal-scale deployments accept "exactly-once" as "essentially-once for idempotent operations." Devenv parallel instances handle it by running workers only on ws0 (see `mem:devenv/core`). diff --git a/backend/scripts/_env b/backend/scripts/_env index 4f7c5655bf..f777d16a8a 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -68,7 +68,7 @@ export PENPOT_FLAGS="\ # Uncomment for nexus integration testing # export PENPOT_FLAGS="$PENPOT_FLAGS enable-nexus"; -# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"; +# export PENPOT_NEXUS_URI="http://localhost:6070/"; # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 297d3de65f..33fde1fd5f 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -211,7 +211,7 @@ [:rpc-rlimit-config {:optional true} ::fs/path] [:rpc-climit-config {:optional true} ::fs/path] - [:audit-log-archive-uri {:optional true} :string] + [:nexus-uri {:optional true} ::sm/uri] [:audit-log-http-handler-concurrency {:optional true} ::sm/int] [:default-executor-parallelism {:optional true} ::sm/int] ;; REVIEW diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 619c367a49..430d369343 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -21,7 +21,7 @@ [app.http :as-alias http] [app.http.access-token :as-alias actoken] [app.jobs :as jobs] - [app.loggers.audit.archive-task :as archive] + [app.loggers.audit.consumer-task :as consumer] [app.loggers.audit.tasks :as-alias tasks] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] @@ -244,10 +244,10 @@ (update :ip-addr db/inet)) params (select-keys params event-keys) row (db/insert! cfg :audit-log params)] - ;; The archive queue follows the row in the same transaction: one - ;; pending per enabled archiver. A rollback takes both on its way + ;; The consumer queue follows the row in the same transaction: one + ;; pending per enabled consumer. A rollback takes both on its way ;; out, so a row never outlives its queue. - (archive/queue-pendings cfg [(:id row)]) + (consumer/queue-pendings cfg [(:id row)]) row)) (def ^:private xf:filter-telemetry-props diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/consumer_task.clj similarity index 77% rename from backend/src/app/loggers/audit/archive_task.clj rename to backend/src/app/loggers/audit/consumer_task.clj index a4eb14e5e6..6c4986eed5 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.loggers.audit.archive-task +(ns app.loggers.audit.consumer-task (:require [app.common.exceptions :as ex] [app.common.logging :as l] @@ -27,59 +27,59 @@ ;; This task drains the audit log buffer into the long-term archives. ;; ;; Two separate jobs share this namespace and the queue helpers, one -;; per destination: `:audit-log-archive-nexus` and -;; `:audit-log-archive-admin-console`. Each job drains only its own queue +;; per destination: `:audit-log-consumer-nexus` and +;; `:audit-log-consumer-admin-console`. Each job drains only its own queue ;; and deletes its pendings on completion; whoever deletes the last ;; pending also deletes the record ("last one out turns off the ;; light"), so no separate GC task is needed. Housekeeping (reaping ;; disabled pendings, sweeping orphans) lives in both jobs on purpose: ;; only the enabled job runs, so it must clean up for the rest. -;; Archiver ids are fixed constants, never reuse one: retired +;; Consumer ids are fixed constants, never reuse one: retired ;; pendings are reaped by id, so a reused id would inherit the ;; previous owner's queue. -(def archiver-nexus 1) -(def archiver-admin-console 2) +(def consumer-nexus 1) +(def consumer-admin-console 2) -(def ^:private archivers - [{:id archiver-nexus :flag :nexus :label "nexus"} - {:id archiver-admin-console :flag :admin-console :label "admin-console"}]) +(def ^:private consumers + [{:id consumer-nexus :flag :nexus :label "nexus"} + {:id consumer-admin-console :flag :admin-console :label "admin-console"}]) -(def ^:private xf:enabled-archivers - "Static transducer: keep the archivers whose send flag is on. +(def ^:private xf:enabled-consumers + "Static transducer: keep the consumers whose send flag is on. Allocated once so hot paths (every audited RPC) do not rebuild it per call; the flag lookup itself still reads the live flags." (comp (filter #(contains? cf/flags (:flag %))) (map :id))) -(defn enabled-archivers - "Archivers allowed to work right now, from the send flags. Public: - producers queue every record for exactly these archivers." +(defn enabled-consumers + "Consumers allowed to work right now, from the send flags. Public: + producers queue every record for exactly these consumers." [] - (into [] xf:enabled-archivers archivers)) + (into [] xf:enabled-consumers consumers)) (defn queue-pendings - "Queue records for every enabled archiver, nothing when there is - nothing to queue or no archiver is on. One multi-row statement for - all ids instead of one per archiver: this runs inside every audited + "Queue records for every enabled consumer, nothing when there is + nothing to queue or no consumer is on. One multi-row statement for + all ids instead of one per consumer: this runs inside every audited RPC and the bulk frontend ingest. Runs inside the row transaction: a rollback takes both." [cfg ids] - (let [archivers (enabled-archivers)] - (when (and (seq ids) (seq archivers)) + (let [consumers (enabled-consumers)] + (when (and (seq ids) (seq consumers)) (db/insert-many! cfg :audit-log-pending - [:audit-log-id :archiver] + [:audit-log-id :consumer] (for [id ids - archiver archivers] - [id archiver]) + consumer consumers] + [id consumer]) {::sql/on-conflict-do-nothing true})))) -(def ^:private archiver-labels - (into {} (map (juxt :id :label)) archivers)) +(def ^:private consumer-labels + (into {} (map (juxt :id :label)) consumers)) -(defn- archiver-label - [archiver] - (get archiver-labels archiver)) +(defn- consumer-label + [consumer] + (get consumer-labels consumer)) (defn- decode-row [{:keys [props ip-addr context] :as row}] @@ -122,7 +122,7 @@ ;; NOTE: deliberately no SKIP LOCKED here. Batches must reach the ;; destinations in chronological order (Nexus numbers by arrival), -;; so every archiver takes the same total order and a second +;; so every consumer takes the same total order and a second ;; overlapping run waits for the first to commit, then sees whether ;; it was last. The brief blocking is accepted; skipping rows could ;; reorder archive arrival. @@ -130,14 +130,14 @@ "SELECT a.* FROM audit_log_pending p JOIN audit_log a ON a.id = p.audit_log_id - WHERE p.archiver = ?::smallint + WHERE p.consumer = ?::smallint ORDER BY a.created_at ASC, a.id ASC LIMIT 128 FOR UPDATE OF a") (def ^:private sql:delete-pendings "DELETE FROM audit_log_pending - WHERE archiver = ?::smallint + WHERE consumer = ?::smallint AND audit_log_id = ANY(?)") (def ^:private sql:delete-drained @@ -149,8 +149,8 @@ )") (defn- get-pending-rows - [{:keys [::db/conn] :as cfg} archiver] - (->> (db/exec! conn [sql:pending-chunk archiver]) + [{:keys [::db/conn] :as cfg} consumer] + (->> (db/exec! conn [sql:pending-chunk consumer]) (not-empty))) (defn- complete-batch @@ -158,11 +158,21 @@ waiting for anymore. Two statements on purpose: inside a single statement the NOT EXISTS would still see the pending just deleted and never delete a record." - [{:keys [::db/conn]} archiver rows] + [{:keys [::db/conn]} consumer rows] (let [ids (db/create-array conn "uuid" (map :id rows))] - (db/exec! conn [sql:delete-pendings archiver ids]) + (db/exec! conn [sql:delete-pendings consumer ids]) (db/exec! conn [sql:delete-drained ids]))) +(defn- generate-nexus-uri + "Joins relative path segments to the Nexus backend URI, same shape + as the admin-console helper in `app.nitrate` (there the ingest path + is `api/audit-log`; here it is `api/audit`). + Segments must not start with `/`. Returns nil when no base URI is + configured, so the caller keeps the `:task-not-configured` guard." + [& segments] + (when-let [base (cf/get :nexus-uri)] + (apply cf/join-uri base segments))) + (defn- send-to-nexus "POST events to Nexus. Returns the number of events sent; throws on transport failure or non-204 refusal, carrying the attempted count @@ -190,7 +200,7 @@ (if (= (:status resp) 204) n (throw (ex/raise :type :internal - :code :archive-chunk-refused + :code :consumer-chunk-refused :hint "nexus refused archive chunk" :resp-status (:status resp) :resp-body (truncate-body (:body resp) 500) @@ -338,8 +348,8 @@ total (count rows) skipped (- total (count events)) uri-host (some-> (cf/get :admin-console-uri) u/uri u/get-domain)] - (l/dbg :hint "archive events chunk ready" - :archiver "admin-console" + (l/dbg :hint "consumer events chunk ready" + :consumer "admin-console" :uri-host uri-host :total total :skipped skipped) @@ -352,15 +362,15 @@ n (count events)] (try (nitrate/call cfg :ingest-audit-log {:events enriched}) - (l/dbg :hint "archive events chunk sent" - :archiver "admin-console" + (l/dbg :hint "consumer events chunk sent" + :consumer "admin-console" :uri-host uri-host :sent n :skipped skipped) {:sent n :skipped skipped} (catch Throwable cause - (l/dbg :hint "archive events chunk failed" - :archiver "admin-console" + (l/dbg :hint "consumer events chunk failed" + :consumer "admin-console" :uri-host uri-host :events n :code :admin-console-send-failed) @@ -370,7 +380,7 @@ :cause cause :events n)))))))) -(defn- archive-nexus-chunk +(defn- consumer-nexus-chunk "Send one pending chunk to Nexus and complete it. Returns {:sent n} or nil when the queue is empty; throws on refusal. Takes a transactional cfg: the caller opens one transaction per chunk. No @@ -378,40 +388,40 @@ the caller records the success." [cfg] (db/exec! cfg ["SET LOCAL lock_timeout = '30s'"]) - (when-let [rows (get-pending-rows cfg archiver-nexus)] + (when-let [rows (get-pending-rows cfg consumer-nexus)] (let [events (mapv row->event (mapv decode-row rows)) ;; send-to-nexus returns the count or throws: a refusal ;; aborts the job, the rows stay for the next tick. n (send-to-nexus cfg events)] - (l/trc :hint "archive events chunk" - :archiver "nexus" + (l/trc :hint "consumer events chunk" + :consumer "nexus" :events n) - (complete-batch cfg archiver-nexus rows) + (complete-batch cfg consumer-nexus rows) {:sent n}))) -(defn- archive-admin-console-chunk +(defn- consumer-admin-console-chunk "Send one pending chunk to the admin console and complete it. Returns {:sent :skipped} or nil when the queue is empty; throws on failure. Takes a transactional cfg, no metrics here: same contract as - archive-nexus-chunk." + consumer-nexus-chunk." [cfg] (db/exec! cfg ["SET LOCAL lock_timeout = '30s'"]) - (when-let [rows (get-pending-rows cfg archiver-admin-console)] + (when-let [rows (get-pending-rows cfg consumer-admin-console)] (let [decoded (mapv decode-row rows) - _ (l/trc :hint "archive events chunk" - :archiver "admin-console" + _ (l/trc :hint "consumer events chunk" + :consumer "admin-console" :events (count decoded)) admin-console-res (send-to-admin-console cfg decoded)] ;; Filtered-out rows count as processed for the ;; admin-console queue: their pending is done, the - ;; record stays until every archiver is done. - (complete-batch cfg archiver-admin-console rows) + ;; record stays until every consumer is done. + (complete-batch cfg consumer-admin-console rows) admin-console-res))) -(defn- drain-archiver - "Drain one archiver queue chunk by chunk, one transaction per chunk. +(defn- drain-consumer + "Drain one consumer queue chunk by chunk, one transaction per chunk. Returns totals; a chunk the destination refuses (or that throws) aborts the drain with a throw, and the committed chunks so far stay sent." @@ -431,11 +441,11 @@ {:sent sent :skipped skipped :chunks chunks}))) (defn- sql:reap-pendings - [archiver] + [consumer] (str "DELETE FROM audit_log_pending" - " WHERE archiver = " archiver + " WHERE consumer = " consumer " AND audit_log_id IN (SELECT audit_log_id FROM audit_log_pending" - " WHERE archiver = " archiver + " WHERE consumer = " consumer " ORDER BY audit_log_id LIMIT 5000)")) (def ^:private sql:sweep-orphans @@ -457,35 +467,35 @@ total)))) (defn- reap-disabled - "Drop the pendings of archivers that are off: a disabled destination + "Drop the pendings of consumers that are off: a disabled destination will never drain them, and without this they would pin their records forever. This also absorbs any later deactivation, with no manual step. Dropped pendings count as discarded: their rows may still be archived - by an enabled archiver, or swept unsent when nothing waits for them." + by an enabled consumer, or swept unsent when nothing waits for them." [cfg] - (let [total (->> archivers + (let [total (->> consumers (remove #(contains? cf/flags (:flag %))) (map #(delete-batched cfg (sql:reap-pendings (:id %)))) (reduce + 0))] (when (pos? total) - (record-count (mtx/instance cfg) :audit-archive-discarded ["reaped"] total)) + (record-count (mtx/instance cfg) :audit-consumer-discarded ["reaped"] total)) total)) (defn- sweep-orphans "Delete records nobody waits for anymore: rows born while no - archiver was on, rows left pending-less by the reap, and leftovers + consumer was on, rows left pending-less by the reap, and leftovers archived by the old task. Batched and normally a no-op. Deleted rows count as discarded: on a live system they were never archived." [cfg] (let [total (delete-batched cfg sql:sweep-orphans)] (when (pos? total) - (record-count (mtx/instance cfg) :audit-archive-discarded ["swept"] total)) + (record-count (mtx/instance cfg) :audit-consumer-discarded ["swept"] total)) total)) -(declare execute-audit-log-archive-nexus) -(declare execute-audit-log-archive-admin-console) +(declare execute-audit-log-consumer-nexus) +(declare execute-audit-log-consumer-admin-console) -(def schema:audit-log-archive-nexus-params +(def schema:audit-log-consumer-nexus-params "Overrides for the nexus job repl invocation defaults. Cron submits {}; system deps (pool, shared-keys, http) live on the job-def cfg closed over by the handler, not in job params. @@ -494,7 +504,7 @@ [:map {:closed true} [:uri {:optional true} ::sm/uri]]) -(def schema:audit-log-archive-admin-console-params +(def schema:audit-log-consumer-admin-console-params "Params of the admin-console job: none. Cron submits {}; system deps (pool, admin-console client) live on the job-def cfg closed over by the handler, not in job params. Closed on purpose, same as the nexus @@ -511,16 +521,16 @@ [_ cfg] ;; Effective destination, once at boot: per-run logs carry counts, ;; the runner says the job started, this says where it sends. - (l/dbg :hint "audit log archive job initialized" - :archiver "nexus" - :uri-host (some-> (cf/get :audit-log-archive-uri) u/uri u/get-domain)) - {::jobs/name :audit-log-archive-nexus - ::jobs/schema schema:audit-log-archive-nexus-params + (l/dbg :hint "audit log consumer job initialized" + :consumer "nexus" + :uri-host (some-> (cf/get :nexus-uri) u/uri u/get-domain)) + {::jobs/name :audit-log-consumer-nexus + ::jobs/schema schema:audit-log-consumer-nexus-params ::jobs/handler (fn [_context params] - (execute-audit-log-archive-nexus cfg params)) - ::jobs/decoder (sm/decoder schema:audit-log-archive-nexus-params sm/json-transformer) - ::jobs/validator (sm/validator schema:audit-log-archive-nexus-params)}) + (execute-audit-log-consumer-nexus cfg params)) + ::jobs/decoder (sm/decoder schema:audit-log-consumer-nexus-params sm/json-transformer) + ::jobs/validator (sm/validator schema:audit-log-consumer-nexus-params)}) (defmethod ig/assert-key ::admin-console-job-def [_ cfg] @@ -534,28 +544,28 @@ ;; Same as the nexus job-def: destination host once at boot. The ;; ingest uri is resolved by nitrate/call at send time (it raises ;; when missing); here it is display-only. - (l/dbg :hint "audit log archive job initialized" - :archiver "admin-console" + (l/dbg :hint "audit log consumer job initialized" + :consumer "admin-console" :uri-host (some-> (cf/get :admin-console-uri) u/uri u/get-domain)) - {::jobs/name :audit-log-archive-admin-console - ::jobs/schema schema:audit-log-archive-admin-console-params + {::jobs/name :audit-log-consumer-admin-console + ::jobs/schema schema:audit-log-consumer-admin-console-params ::jobs/handler (fn [_context params] - (execute-audit-log-archive-admin-console cfg params)) - ::jobs/decoder (sm/decoder schema:audit-log-archive-admin-console-params sm/json-transformer) - ::jobs/validator (sm/validator schema:audit-log-archive-admin-console-params)}) + (execute-audit-log-consumer-admin-console cfg params)) + ::jobs/decoder (sm/decoder schema:audit-log-consumer-admin-console-params sm/json-transformer) + ::jobs/validator (sm/validator schema:audit-log-consumer-admin-console-params)}) -(defn- run-single-archiver - "Drain one archiver queue, then housekeeping (reap disabled +(defn- run-single-consumer + "Drain one consumer queue, then housekeeping (reap disabled pendings, sweep orphans) and the per-run summary. Housekeeping lives in both jobs: only the enabled job runs, so it must reap the queues - of the archivers that are off; both halves are idempotent deletes. + of the consumers that are off; both halves are idempotent deletes. A refused chunk (or a throw) aborts the job: the committed chunks stay sent, the rest waits for the next tick, and the cron retries. Every effect is rows and logs (drain metrics land here, outside every chunk transaction)." - [cfg archiver send-chunk tpoint] - (let [label (archiver-label archiver) + [cfg consumer send-chunk tpoint] + (let [label (consumer-label consumer) metrics (mtx/instance cfg) reaped (reap-disabled cfg) ;; All drain metrics land here, outside every chunk @@ -567,17 +577,17 @@ ;; uncounted. The throw keeps rolling: a failed chunk aborts ;; the job, the cron retries. drained (try - (drain-archiver cfg send-chunk) + (drain-consumer cfg send-chunk) (catch Throwable cause (let [n (:events (ex-data cause))] (when (and (integer? n) (pos? n)) - (record-count metrics :audit-archive-events [label "failed"] n))) + (record-count metrics :audit-consumer-events [label "failed"] n))) (throw cause))) _ (let [{:keys [sent skipped]} drained] (when (pos? sent) - (record-count metrics :audit-archive-events [label "archived"] sent)) + (record-count metrics :audit-consumer-events [label "archived"] sent)) (when (pos? skipped) - (record-count metrics :audit-archive-events [label "skipped"] skipped))) + (record-count metrics :audit-consumer-events [label "skipped"] skipped))) swept (sweep-orphans cfg) sent (:sent drained) skipped (:skipped drained) @@ -590,9 +600,9 @@ (zero? reaped) (zero? swept)) (l/dbg :hint "no pending audit events" - :archiver label) + :consumer label) (l/dbg :hint "events archived" - :archiver label + :consumer label :elapsed elapsed :sent sent :skipped skipped @@ -600,16 +610,15 @@ :reaped reaped :swept swept)))) -(defn execute-audit-log-archive-nexus +(defn execute-audit-log-consumer-nexus "Nexus job handler: drain the nexus queue in chunks, then clean up (heartbeat per iteration: the sent chunk batches can be long)." [cfg params] ;; NOTE: this let allows overwrite default configured values from ;; the repl, when manually invoking the task. (if-not (contains? cf/flags :nexus) - (l/dbg :hint "audit log archive (nexus) skipped, flag off") - (let [uri (cf/get :audit-log-archive-uri) - uri (or uri (:uri params)) + (l/dbg :hint "audit log consumer (nexus) skipped, flag off") + (let [uri (or (:uri params) (generate-nexus-uri "api/audit")) ;; Normalize to an uri object; params may carry a plain string ;; on direct invocations (validation only applies on submit!). uri (u/uri uri) @@ -617,14 +626,14 @@ (when-not uri (ex/raise :type :internal :code :task-not-configured - :hint "archive task not configured, missing uri")) - (run-single-archiver cfg archiver-nexus archive-nexus-chunk (ct/tpoint))))) + :hint "consumer task not configured, missing uri")) + (run-single-consumer cfg consumer-nexus consumer-nexus-chunk (ct/tpoint))))) -(defn execute-audit-log-archive-admin-console +(defn execute-audit-log-consumer-admin-console "Admin-console job handler: drain the admin-console queue in chunks, then clean up (heartbeat per iteration: the sent chunk batches can be long)." [cfg _params] (if-not (contains? cf/flags :admin-console) - (l/dbg :hint "audit log archive (admin-console) skipped, flag off") - (run-single-archiver cfg archiver-admin-console archive-admin-console-chunk (ct/tpoint)))) + (l/dbg :hint "audit log consumer (admin-console) skipped, flag off") + (run-single-consumer cfg consumer-admin-console consumer-admin-console-chunk (ct/tpoint)))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 5a387f14be..7476f9bfb2 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -373,15 +373,15 @@ ::mdef/help "Total number of requests whose body the parser could not read." ::mdef/type :counter} - :audit-archive-events - {::mdef/name "penpot_audit_archive_events_total" - ::mdef/help "Audit log events sent by the archive task, by destination and outcome." + :audit-consumer-events + {::mdef/name "penpot_audit_consumer_events_total" + ::mdef/help "Audit log events drained by the consumer task, by destination and outcome." ::mdef/labels ["destination" "outcome"] ::mdef/type :counter} - :audit-archive-discarded - {::mdef/name "penpot_audit_archive_discarded_total" - ::mdef/help "Audit log entries discarded without archiving, by reason." + :audit-consumer-discarded + {::mdef/name "penpot_audit_consumer_discarded_total" + ::mdef/help "Audit log entries discarded without being archived, by reason." ::mdef/labels ["reason"] ::mdef/type :counter} @@ -593,7 +593,7 @@ :app.nitrate/client {::http.client/client (ig/ref ::http.client/client) - ;; No ::jobs/defs: nitrate never submits jobs, and archive-task's + ;; No ::jobs/defs: nitrate never submits jobs, and consumer-task's ;; job-def needs this client — that would cycle through jobs/defs. ::setup/shared-keys (ig/ref ::setup/shared-keys)} @@ -639,8 +639,8 @@ :telemetry (ig/ref :app.tasks.telemetry/job-def) :session-gc (ig/ref ::session/job-def) :file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/job-def) - :audit-log-archive-nexus (ig/ref :app.loggers.audit.archive-task/nexus-job-def) - :audit-log-archive-admin-console (ig/ref :app.loggers.audit.archive-task/admin-console-job-def)} + :audit-log-consumer-nexus (ig/ref :app.loggers.audit.consumer-task/nexus-job-def) + :audit-log-consumer-admin-console (ig/ref :app.loggers.audit.consumer-task/admin-console-job-def)} ::jobs/request-pool {::rds/client (ig/ref ::rds/client) @@ -729,13 +729,13 @@ {::db/pool (ig/ref ::db/pool) ::mtx/metrics (ig/ref ::mtx/metrics)} - :app.loggers.audit.archive-task/nexus-job-def + :app.loggers.audit.consumer-task/nexus-job-def {::db/pool (ig/ref ::db/pool) ::http.client/client (ig/ref ::http.client/client) ::setup/shared-keys (ig/ref ::setup/shared-keys) ::mtx/metrics (ig/ref ::mtx/metrics)} - :app.loggers.audit.archive-task/admin-console-job-def + :app.loggers.audit.consumer-task/admin-console-job-def {::db/pool (ig/ref ::db/pool) :app.nitrate/client (ig/ref :app.nitrate/client) ::mtx/metrics (ig/ref ::mtx/metrics)} @@ -861,11 +861,11 @@ (when (contains? cf/flags :nexus) {:cron #penpot/cron "0 */5 * * * ?" ;; every 5m - :job :audit-log-archive-nexus}) + :job :audit-log-consumer-nexus}) (when (contains? cf/flags :admin-console) {:cron #penpot/cron "30 */5 * * * ?" ;; every 5m, offset 30s from nexus - :job :audit-log-archive-admin-console})]} + :job :audit-log-consumer-admin-console})]} ::wrk/dispatcher {::rds/client (ig/ref ::rds/client) diff --git a/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql b/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql index 4c6d8d998d..ce797dd12c 100644 --- a/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql +++ b/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql @@ -1,8 +1,8 @@ --- Work queue for the audit log archivers: one row per (record, --- archiver) pair still pending. Inserting a record creates its --- pendings; each archiver deletes its own on completion, and whoever --- deletes the last pending also deletes the record. Archiver ids are --- fixed in code and never reused: 1 = nexus, 2 = nitrate. +-- Work queue for the audit log consumers: one row per (record, +-- consumer) pair still pending. Inserting a record creates its +-- pendings; each consumer deletes its own on completion, and whoever +-- deletes the last pending also deletes the record. Consumer ids are +-- fixed in code and never reused: 1 = nexus, 2 = admin console. CREATE TABLE audit_log_pending ( -- DEFERRABLE so the test-suite reset (which deletes table by table @@ -11,8 +11,8 @@ CREATE TABLE audit_log_pending ( -- (initially immediate) is enough: every deleter removes pendings -- before rows, so the check passes at each statement. audit_log_id uuid NOT NULL REFERENCES audit_log(id) DEFERRABLE, - archiver smallint NOT NULL CHECK (archiver BETWEEN 1 AND 20), - PRIMARY KEY (archiver, audit_log_id)); + consumer smallint NOT NULL CHECK (consumer BETWEEN 1 AND 20), + PRIMARY KEY (consumer, audit_log_id)); -- Lets the final delete ask "is anything still pending for this -- record?" without scanning the whole queue. @@ -31,8 +31,8 @@ DROP INDEX IF EXISTS audit_log__archived_at__idx; DROP INDEX IF EXISTS audit_log_id_archived_at_idx; ALTER TABLE audit_log DROP COLUMN IF EXISTS archived_at; --- Serves the archivers' chronological scan (ORDER BY created_at, id): --- one total order for every archiver, so concurrent drains cannot +-- Serves the consumers' chronological scan (ORDER BY created_at, id): +-- one total order for every consumer, so concurrent drains cannot -- deadlock on row locks. Covers the whole ORDER BY (bulk ingest -- shares request-at constantly, so ties are the norm). CREATE INDEX IF NOT EXISTS audit_log__created_at__idx diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 771b321606..4d5f26a402 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -16,7 +16,7 @@ [app.db :as db] [app.http :as-alias http] [app.loggers.audit :as audit] - [app.loggers.audit.archive-task :as archive] + [app.loggers.audit.consumer-task :as consumer] [app.loggers.database :as loggers.db] [app.loggers.mattermost :as loggers.mm] [app.metrics :as mtx] @@ -127,8 +127,8 @@ (when (seq rows) (db/insert-many! tx :audit-log event-columns rows) ;; Same transaction as the rows: every ingested record is born - ;; pending for every enabled archiver (see archive-task). - (archive/queue-pendings tx (map first rows)))) + ;; pending for every enabled consumer (see consumer-task). + (consumer/queue-pendings tx (map first rows)))) (defn- handle-events [{:keys [::db/pool] :as cfg} params] diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 8088841380..05cd056d92 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -233,8 +233,8 @@ so that the buffer stays bounded." [{:keys [::db/conn]}] ;; One transaction for both deletes: a crash between them would - ;; leave pending-less rows for the archive sweep to delete unsent. - ;; Pendings first: the queue holds a row per archiver for every + ;; leave pending-less rows for the consumer sweep to delete unsent. + ;; Pendings first: the queue holds a row per consumer for every ;; record, and the FK would reject deleting a row that still has ;; pendings. (db/tx-run! conn @@ -289,7 +289,7 @@ (defn- delete-sent-events "Delete rows by their ids after a successful send. One transaction for both deletes: a crash between them would leave pending-less - rows for the archive sweep to delete unsent." + rows for the consumer sweep to delete unsent." [conn ids] (db/tx-run! conn (fn [tx] diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj similarity index 82% rename from backend/test/backend_tests/loggers_audit_archive_task_test.clj rename to backend/test/backend_tests/loggers_audit_consumer_task_test.clj index c0e5a5a0e0..146f0da10b 100644 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns backend-tests.loggers-audit-archive-task-test +(ns backend-tests.loggers-audit-consumer-task-test (:require [app.common.json :as json] [app.common.schema :as sm] @@ -17,7 +17,7 @@ [app.http.client :as-alias http] [app.jobs :as jobs] [app.loggers.audit :as audit] - [app.loggers.audit.archive-task :as archive] + [app.loggers.audit.consumer-task :as consumer] [app.main :as main] [app.metrics :as mtx] [app.metrics.definition :as-alias mdef] @@ -38,9 +38,9 @@ (defn- insert-pendings! "Queue the row with the real producer helper: pendings for the - enabled archivers, like append-audit-entry does." + enabled consumers, like append-audit-entry does." [row-id] - (archive/queue-pendings (:app.db/pool th/*system*) [row-id])) + (consumer/queue-pendings (:app.db/pool th/*system*) [row-id])) (defn- pending-count [] @@ -70,13 +70,13 @@ (:props (first (:events params))))) (defn- run-both! - "Run both archive jobs, Nexus first (the historical single-run + "Run both consumer jobs, Nexus first (the historical single-run order). The admin-console job takes no params (closed empty schema)." [params] - (th/run-task! :audit-log-archive-nexus params) - (th/run-task! :audit-log-archive-admin-console {})) + (th/run-task! :audit-log-consumer-nexus params) + (th/run-task! :audit-log-consumer-admin-console {})) -(t/deftest archive-jobs-drain-their-own-queues +(t/deftest consumer-jobs-drain-their-own-queues ;; Two separate jobs, one queue each: the nexus job sends without ;; touching the admin console, and only the admin-console job ;; deletes the last pending and the row. Create profile before @@ -88,14 +88,14 @@ http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") (insert-audit-row! (:id prof) "create-file") - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}) (t/is (false? (:called? @admin-console-mock))) (t/is (:called? @http-mock)) (t/is (= 1 (:call-count @http-mock))) ;; Nexus drained its own queue; both rows wait on the admin console. (t/is (= 2 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 2 (pending-count))) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (= 1 (:call-count @admin-console-mock))) (let [[_ method params] (:call-args @admin-console-mock)] (t/is (= :ingest-audit-log method)) @@ -106,7 +106,7 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-filters-admin-console-event-names +(t/deftest consumer-events-filters-admin-console-event-names (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} @@ -123,7 +123,7 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-skips-admin-console-when-no-matching-names +(t/deftest consumer-events-skips-admin-console-when-no-matching-names (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} @@ -135,31 +135,31 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-admin-console-only-marks-without-uri +(t/deftest consumer-events-admin-console-only-marks-without-uri (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (:called? @admin-console-mock)) (t/is (false? (:called? @http-mock))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-admin-console-only-marks-non-allowlisted +(t/deftest consumer-events-admin-console-only-marks-non-allowlisted (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "unrelated-event") - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (false? (:called? @admin-console-mock))) (t/is (false? (:called? @http-mock))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-admin-console-error-still-drains-nexus +(t/deftest consumer-events-admin-console-error-still-drains-nexus ;; Mirror of the nexus-throw test: an admin-console failure aborts ;; only the admin-console job; Nexus already drained its own queue ;; in its job. The row stays on the admin-console pending. @@ -169,9 +169,9 @@ :throw (ex-info "nitrate down" {})} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}) (t/is (thrown? clojure.lang.ExceptionInfo - (th/run-task! :audit-log-archive-admin-console {}))) + (th/run-task! :audit-log-consumer-admin-console {}))) ;; Nexus already drained its queue; the admin-console failure no ;; longer blocks it, but the row stays on the admin-console pending. (t/is (:called? @admin-console-mock)) @@ -180,44 +180,44 @@ (t/is (= 1 (count rows)))) (t/is (= 1 (pending-count))))))) -(t/deftest archive-events-uses-nexus-without-admin-console +(t/deftest consumer-events-uses-nexus-without-admin-console (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}) (t/is (false? (:called? @admin-console-mock))) (t/is (:called? @http-mock)) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-skips-mark-when-nexus-fails +(t/deftest consumer-events-skips-mark-when-nexus-fails (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 500}}] (insert-audit-row! (:id prof) "create-project") (t/is (thrown? clojure.lang.ExceptionInfo - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}))) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}))) (t/is (:called? @http-mock)) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 1 (count rows)))))))) -(t/deftest archive-events-both-flags-skips-mark-when-nexus-fails +(t/deftest consumer-events-both-flags-skips-mark-when-nexus-fails (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 500}}] (insert-audit-row! (:id prof) "create-project") (t/is (thrown? clojure.lang.ExceptionInfo - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}))) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}))) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (:called? @admin-console-mock)) (t/is (:called? @http-mock)) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 1 (count rows)))))))) -(t/deftest archive-events-encodes-db-rows-for-admin-console +(t/deftest consumer-events-encodes-db-rows-for-admin-console (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console} cf/config (assoc cf/config @@ -229,16 +229,16 @@ client (ig/init-key :app.nitrate/client {::http/client (Object.) ::setup/shared-keys shared-keys}) - ;; Each archive job is a job-def: the handler closes over the + ;; Each consumer job is a job-def: the handler closes over the ;; cfg it was given at init and takes a nil context, because ;; an in-process invocation has no job row to describe. - nexus-job-def (ig/init-key :app.loggers.audit.archive-task/nexus-job-def + nexus-job-def (ig/init-key :app.loggers.audit.consumer-task/nexus-job-def {::db/pool (:app.db/pool th/*system*) ::setup/shared-keys shared-keys ::http/client (:app.http.client/client th/*system*) ::mtx/metrics (th/metrics-stub) :app.nitrate/client client}) - admin-console-job-def (ig/init-key :app.loggers.audit.archive-task/admin-console-job-def + admin-console-job-def (ig/init-key :app.loggers.audit.consumer-task/admin-console-job-def {::db/pool (:app.db/pool th/*system*) ::mtx/metrics (th/metrics-stub) :app.nitrate/client client})] @@ -278,16 +278,32 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))))) -(t/deftest archive-nexus-job-requires-uri-when-nexus-flag-set +(t/deftest consumer-nexus-default-uri-appends-api-audit + ;; Without an explicit :uri param, the job posts to the Nexus base + ;; URI plus the ingest path, mirroring generate-nitrate-uri. + (let [prof (th/create-profile* 1 {:is-active true}) + uris (atom [])] + (with-redefs [cf/flags #{:nexus} + cf/config (assoc cf/config :nexus-uri (u/uri "http://nexus.example"))] + (with-mocks [http-mock {:target 'app.http.client/req + :return (fn [_ params & _] + (swap! uris conj (str (:uri params))) + {:status 204})}] + (insert-audit-row! (:id prof) "create-project") + (th/run-task! :audit-log-consumer-nexus {}) + (t/is (= ["http://nexus.example/api/audit"] @uris)) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))))))) + +(t/deftest consumer-nexus-job-requires-uri-when-nexus-flag-set (with-redefs [cf/flags #{:nexus} - cf/config (dissoc cf/config :audit-log-archive-uri)] + cf/config (dissoc cf/config :nexus-uri)] (let [data (ex-data (try - (th/run-task! :audit-log-archive-nexus {}) + (th/run-task! :audit-log-consumer-nexus {}) (catch Throwable cause cause)))] (t/is (= :task-not-configured (:code data)))))) -(t/deftest archive-events-enriches-team-name-and-emails +(t/deftest consumer-events-enriches-team-name-and-emails (let [actor (th/create-profile* 1 {:is-active true}) member (th/create-profile* 2 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] @@ -300,7 +316,7 @@ :member-id (:id member) ;; Producers store this as a string UUID. :user-who-send-invitation (str (:id actor))}) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (:called? @admin-console-mock)) (let [props (admin-console-event-props admin-console-mock)] (t/is (= (:name team) (:team-name props))) @@ -311,7 +327,7 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-enriches-soft-deleted-team-and-profile +(t/deftest consumer-events-enriches-soft-deleted-team-and-profile (let [actor (th/create-profile* 1 {:is-active true}) other (th/create-profile* 2 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] @@ -322,14 +338,14 @@ (insert-audit-row! (:id actor) "create-project" {:team-id (:id team) :profile-id (:id other)}) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (let [props (admin-console-event-props admin-console-mock)] (t/is (= (:name team) (:team-name props))) (t/is (= (:email other) (:profile-email props)))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-does-not-overwrite-existing-enrich-props +(t/deftest consumer-events-does-not-overwrite-existing-enrich-props (let [actor (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] (with-redefs [cf/flags #{:admin-console}] @@ -345,7 +361,7 @@ :member-email "already-member@example.com" :user-who-send-invitation (str (:id actor)) :user-who-send-invitation-email "already-inviter@example.com"}) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (let [props (admin-console-event-props admin-console-mock)] (t/is (= "already-set-name" (:team-name props))) (t/is (= "already-set@example.com" (:profile-email props))) @@ -354,7 +370,7 @@ (t/is (= "already-inviter@example.com" (:user-who-send-invitation-email props)))))))) -(t/deftest archive-events-enrich-skips-missing-and-non-uuid-ids +(t/deftest consumer-events-enrich-skips-missing-and-non-uuid-ids (let [actor (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] @@ -363,7 +379,7 @@ :profile-id (uuid/next) :user-id "not-a-uuid" :member-id 42}) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (:called? @admin-console-mock)) (let [props (admin-console-event-props admin-console-mock)] (t/is (nil? (:team-name props))) @@ -375,7 +391,7 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-forwards-export-job-not-legacy-export +(t/deftest consumer-events-forwards-export-job-not-legacy-export (let [actor (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] (with-redefs [cf/flags #{:admin-console}] @@ -387,7 +403,7 @@ :export-type "detach-libraries"}) (insert-audit-row! (:id actor) "export-binfile" {:file-id (uuid/next)}) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (let [[_ _ params] (:call-args @admin-console-mock) events (:events params) props (:props (first events))] @@ -397,13 +413,13 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-params-accept-empty-cron-props +(t/deftest consumer-params-accept-empty-cron-props ;; Cron always submits (or props {}) — system deps live on the ;; job-def cfg, not in params. Requiring them here blocks every tick. ;; Both schemas are closed: a stale manual invocation must fail ;; validation loudly instead of skipping silently. - (let [nexus-valid? (sm/validator archive/schema:audit-log-archive-nexus-params) - admin-console-valid? (sm/validator archive/schema:audit-log-archive-admin-console-params)] + (let [nexus-valid? (sm/validator consumer/schema:audit-log-consumer-nexus-params) + admin-console-valid? (sm/validator consumer/schema:audit-log-consumer-admin-console-params)] (t/is (true? (nexus-valid? {}))) (t/is (false? (nexus-valid? {:enabled true}))) (t/is (true? (nexus-valid? {:uri (u/uri archive-uri)}))) @@ -419,20 +435,20 @@ ;; (pattern from `jobs-metrics-test`), so assertions read exact values ;; instead of the shared test-system registry. `th/run-task!` would use ;; the latter and its counts would leak across tests. -(def ^:private archive-metric-ids - [:audit-archive-events :audit-archive-discarded]) +(def ^:private consumer-metric-ids + [:audit-consumer-events :audit-consumer-discarded]) -(defn- make-archive-metrics +(defn- make-consumer-metrics [] (ig/init-key :app.metrics/metrics - {:default (select-keys main/default-metrics archive-metric-ids)})) + {:default (select-keys main/default-metrics consumer-metric-ids)})) (defn- invoke-nexus! - "Invoke the nexus archive handler in-process with an isolated + "Invoke the nexus consumer handler in-process with an isolated metrics registry. The handler takes a nil context because there is no job row to describe (same as `th/run-task!`)." [metrics params] - (let [job-def (ig/init-key :app.loggers.audit.archive-task/nexus-job-def + (let [job-def (ig/init-key :app.loggers.audit.consumer-task/nexus-job-def {::db/pool (:app.db/pool th/*system*) ::setup/shared-keys {:admin-console "ac-key" :nexus "nexus-key"} ::http/client (:app.http.client/client th/*system*) @@ -440,19 +456,19 @@ ((::jobs/handler job-def) nil params))) (defn- invoke-admin-console! - "Invoke the admin-console archive handler in-process with an + "Invoke the admin-console consumer handler in-process with an isolated metrics registry. The admin-console client is the (nil in tests) system one: tests mock `app.nitrate/call`, so it is never dereferenced." [metrics] - (let [job-def (ig/init-key :app.loggers.audit.archive-task/admin-console-job-def + (let [job-def (ig/init-key :app.loggers.audit.consumer-task/admin-console-job-def {::db/pool (:app.db/pool th/*system*) :app.nitrate/client (:app.nitrate/client th/*system*) ::mtx/metrics metrics})] ((::jobs/handler job-def) nil {}))) (defn- invoke-both! - "Invoke both archive handlers sharing one isolated metrics + "Invoke both consumer handlers sharing one isolated metrics registry, Nexus first (the historical single-run order)." [metrics params] (invoke-nexus! metrics params) @@ -465,23 +481,23 @@ child (.labels ^Counter instance (into-array String labels))] (.get ^Counter$Child child))) -(t/deftest archive-records-metrics-on-success +(t/deftest consumer-records-metrics-on-success ;; Smoke test: the run records its domain metrics into the wired ;; registry (isolated per test, pattern from `jobs-metrics-test`). ;; Logging itself is not asserted: `dbg` summaries run below the ;; test log level and failure `warn`s only repeat the outcome the ;; metrics already prove. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") (insert-audit-row! (:id prof) "unrelated-event") (invoke-both! metrics {:uri archive-uri}) - (t/is (= 2.0 (counter-value metrics :audit-archive-events ["nexus" "archived"]))) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "archived"]))) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "skipped"]))) + (t/is (= 2.0 (counter-value metrics :audit-consumer-events ["nexus" "archived"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["admin-console" "archived"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["admin-console" "skipped"]))) (t/is (= 0 (pending-count))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) @@ -499,9 +515,9 @@ :props {} :context {} :created-at (ct/now) :tracked-at (ct/now)}) -(t/deftest archive-submit-creates-pendings-for-enabled-archivers +(t/deftest consumer-submit-creates-pendings-for-enabled-consumers ;; The producer hook: a row is born pending for the enabled - ;; archivers, in the same transaction as the row itself. Goes + ;; consumers, in the same transaction as the row itself. Goes ;; through submit* because the suite stubs submit itself (see ;; helpers/init-config). (let [prof (th/create-profile* 1 {:is-active true})] @@ -511,7 +527,7 @@ (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 2 (pending-count)))))) -(t/deftest archive-submit-single-flag-creates-one-pending +(t/deftest consumer-submit-single-flag-creates-one-pending (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:audit-log :nexus}] (audit/submit* {::db/pool (:app.db/pool th/*system*)} @@ -519,7 +535,7 @@ (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 1 (pending-count)))))) -(t/deftest archive-submit-rollback-keeps-no-pendings +(t/deftest consumer-submit-rollback-keeps-no-pendings ;; A rollback takes the row and its queue together: a row never ;; outlives its pendings, and no phantom pending pins nothing. (let [prof (th/create-profile* 1 {:is-active true})] @@ -533,13 +549,13 @@ (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count)))))) -(t/deftest archive-keeps-row-until-last-archiver-done +(t/deftest consumer-keeps-row-until-last-consumer-done ;; Nexus refuses the chunk: the nexus job aborts (failed count ;; recorded, isolated registry) while the admin-console job still ;; drains its own queue. The row and the nexus pending stay; once ;; nexus accepts, everything is gone. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus :admin-console}] (insert-audit-row! (:id prof) "create-project") (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} @@ -548,10 +564,10 @@ (invoke-nexus! metrics {:uri archive-uri}) nil (catch clojure.lang.ExceptionInfo cause cause))] - (t/is (= :archive-chunk-refused (:code (ex-data err))))) + (t/is (= :consumer-chunk-refused (:code (ex-data err))))) (invoke-admin-console! metrics) (t/is (:called? @admin-console-mock)) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["nexus" "failed"]))) (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 1 (pending-count)))) (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} @@ -560,9 +576,9 @@ (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count))))))) -(t/deftest archive-sweeps-orphan-rows +(t/deftest consumer-sweeps-orphan-rows ;; A row with no pendings (old archived leftover, or born while no - ;; archiver was on) is deleted without any send. + ;; consumer was on) is deleted without any send. (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} @@ -584,23 +600,23 @@ (t/is (false? (:called? @http-mock))) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))))))) -(t/deftest archive-nexus-transport-failure-records-failed +(t/deftest consumer-nexus-transport-failure-records-failed ;; A transport exception (timeout, DNS, refused) counts the events ;; as failed and aborts the nexus job: the chunk rolls back and ;; the rows stay for the next tick. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :throw (ex-info "timeout" {})}] (insert-audit-row! (:id prof) "create-project") (t/is (thrown? clojure.lang.ExceptionInfo (invoke-nexus! metrics {:uri archive-uri}))) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["nexus" "failed"]))) (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 1 (pending-count))))))) -(t/deftest archive-sends-chunks-in-chronological-order +(t/deftest consumer-sends-chunks-in-chronological-order ;; Nexus numbers by arrival for order-sensitive consumers, so ;; multi-chunk drains must arrive oldest-first no matter how the ;; queue is scanned. @@ -617,26 +633,26 @@ (th/db-update! :audit-log {:created-at (.plusSeconds ^java.time.Instant base (long i))} {:id (:id row)}))) - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}) (let [received (mapcat #(:events (transit/decode %)) @bodies) times (map :created-at received)] (t/is (= 130 (count received))) (t/is (= (sort times) times)) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"]))))))))) -(t/deftest archive-reaps-disabled-archiver-pendings +(t/deftest consumer-reaps-disabled-consumer-pendings ;; R3 reap by its real path: an admin-console pending born while ;; admin-console was on is dropped once it is off; nexus still ;; archives its queue and the row is gone with nothing stuck. The ;; dropped pending counts as discarded (isolated registry). (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} admin-console-mock {:target 'app.nitrate/call :return nil}] (let [row (insert-audit-row! (:id prof) "create-project")] ;; Admin console was on at insert time: both pendings on purpose. - (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, archiver) + (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, consumer) VALUES (?::uuid, 2) ON CONFLICT DO NOTHING" (:id row)]) @@ -644,16 +660,16 @@ (invoke-nexus! metrics {:uri archive-uri}) (t/is (:called? @http-mock)) (t/is (false? (:called? @admin-console-mock))) - (t/is (= 1.0 (counter-value metrics :audit-archive-discarded ["reaped"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-discarded ["reaped"]))) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count)))))))) -(t/deftest archive-sweeps-rows-born-with-no-archiver-on +(t/deftest consumer-sweeps-rows-born-with-no-consumer-on ;; Accepted R3 consequence by its real path (not a hand-made ;; orphan): ingested with no send flag on, the row is swept ;; unarchived once the task runs, with zero sends. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:audit-log}] (audit/submit* {::db/pool (:app.db/pool th/*system*)} (full-event (:id prof))) @@ -665,16 +681,16 @@ (t/is (false? (:called? @http-mock))) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count))) - (t/is (= 1.0 (counter-value metrics :audit-archive-discarded ["swept"]))) - (t/is (= 0.0 (counter-value metrics :audit-archive-events ["nexus" "archived"]))))))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-discarded ["swept"]))) + (t/is (= 0.0 (counter-value metrics :audit-consumer-events ["nexus" "archived"]))))))) -(t/deftest archive-nexus-throw-still-drains-admin-console +(t/deftest consumer-nexus-throw-still-drains-admin-console ;; Consumer independence, now structural: a Nexus transport throw ;; aborts only the nexus job, and the admin-console job still drains ;; its own queue. Nexus keeps its pending (so the row stays); the ;; admin console completes its own queue. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req @@ -684,11 +700,11 @@ (invoke-nexus! metrics {:uri archive-uri}))) (invoke-admin-console! metrics) (t/is (:called? @admin-console-mock)) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["nexus" "failed"]))) (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 1 (pending-count))))))) -(t/deftest archive-reap-then-sweeps-without-sends +(t/deftest consumer-reap-then-sweeps-without-sends ;; End to end of the reap path: a row born with only an ;; admin-console pending is reaped once :admin-console goes off, ;; then swept with zero sends on either destination. @@ -699,24 +715,24 @@ (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} admin-console-mock {:target 'app.nitrate/call :return nil}] - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}) (t/is (false? (:called? @http-mock))) (t/is (false? (:called? @admin-console-mock))) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count))))))) -(t/deftest archive-fully-filtered-admin-console-chunk-counts-skipped +(t/deftest consumer-fully-filtered-admin-console-chunk-counts-skipped ;; An admin-console chunk with every event outside the allowlist ;; never calls ingest, but still counts its rows as skipped and ;; completes its queue. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "unrelated-event") (invoke-both! metrics {:uri archive-uri}) (t/is (false? (:called? @admin-console-mock))) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "skipped"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["admin-console" "skipped"]))) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count))))))) diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index 6080c483cf..8ae01500ad 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -139,7 +139,7 @@ (t/is (= (:id prof) (:profile-id row))) (t/is (= "navigate" (:name row))) (t/is (= "frontend" (:source row))) - ;; The row is born pending for the enabled archiver. + ;; The row is born pending for the enabled consumer. (t/is (= 1 (count (th/db-exec! ["select * from audit_log_pending"])))))))) (t/deftest push-events-2 @@ -274,7 +274,7 @@ (let [day-now (ct/truncate (ct/now) :days)] (t/is (= day-now (:created-at row2))) (t/is (= day-now (:tracked-at row2)))) - ;; Both rows are born pending for the enabled archivers. + ;; Both rows are born pending for the enabled consumers. (t/is (= 4 (count (th/db-exec! ["select * from audit_log_pending"]))))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index 4585de8c98..2bbdd7c371 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -11,7 +11,7 @@ [app.config :as cf] [app.db :as db] [app.loggers.audit :as audit] - [app.loggers.audit.archive-task :as at] + [app.loggers.audit.consumer-task :as at] [app.tasks.telemetry :as telemetry] [app.util.json :as json] [backend-tests.helpers :as th] @@ -1006,14 +1006,14 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- insert-pending-pair! - "Queue the row for both archivers (from the archiver constants, not + "Queue the row for both consumers (from the consumer constants, not literals): the delete paths must face pending-ful rows." [row-id] - (doseq [archiver [at/archiver-nexus at/archiver-admin-console]] - (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, archiver) + (doseq [consumer [at/consumer-nexus at/consumer-admin-console]] + (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, consumer) VALUES (?::uuid, ?::smallint) ON CONFLICT DO NOTHING" - row-id archiver]))) + row-id consumer]))) (defn- count-pendings [] (-> (th/db-exec-one! ["SELECT count(*) AS cnt FROM audit_log_pending"]) @@ -1021,7 +1021,7 @@ long)) (t/deftest test-delete-sent-events-removes-pendings-first - ;; Regression: telemetry rows carry pendings for both archivers, so + ;; Regression: telemetry rows carry pendings for both consumers, so ;; deleting the row must delete its pendings first, or the FK ;; rejects the delete and the batch is sent again next tick. (let [row (insert-telemetry-row! "navigate")] From 7e5e5897443b10d1af9c86cb25fd2b61176163f1 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 20 Oct 2026 09:18:24 +0200 Subject: [PATCH 094/167] :recycle: Use a DS icon button for the guide defaults menu (#12391) * :recycle: Use a DS icon button for the guide defaults menu Replace the raw three dots button that opens the use default / set as default menu of a guide with the DS ghost icon button. It had no label, so it gets a new "Guide defaults" one, shown in the DS tooltip, and aria-pressed while the menu is open instead of the selected class. The add, show/hide and remove guide buttons, and the new one, sit at the right edge of the sidebar: open their tooltips above and to the left. The advanced options toggle stays a raw button: it is one segment of the guide type control and an icon button would break that shape. Add a visual test that adds a guide to a board and opens its advanced options. AI-assisted-by: claude-opus-5-5 * :recycle: Remove guide defaults button styles The three dots guide defaults button is a DS icon button now: delete the .show-more-options rule, its state variables and its svg rule. The rest of frame_grid.scss already uses DS tokens. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 30 ++++++++++ .../sidebar/options/menus/frame_grid.cljs | 18 +++--- .../sidebar/options/menus/frame_grid.scss | 59 ------------------- frontend/translations/en.po | 4 ++ frontend/translations/es.po | 4 ++ 5 files changed, 48 insertions(+), 67 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 7d21687ba7..632dea1f56 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -320,6 +320,36 @@ test.describe("Assets tab", () => { }); }); +test.describe("Guides", () => { + test("Shows the advanced options of a square guide", async ({ page }) => { + // A taller window keeps the whole Design tab visible: a scrolled + // sidebar can land on a fractional offset and change the antialiasing. + await page.setViewportSize({ width: 1280, height: 900 }); + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + await workspace.boardButton.click(); + await workspace.clickWithDragViewportAt(128, 128, 200, 200); + await workspace.rightSidebar + .getByRole("button", { name: "Add guide" }) + .click(); + + // The advanced options toggle has no accessible name yet. + await workspace.rightSidebar + .locator("button[class*='frame_grid__show-options']") + .click(); + await expect(workspace.rightSidebar.getByText("Color")).toBeVisible(); + // The guide options are a small part of the sidebar: compare every + // pixel. The color rows antialias their rounded corners one color step + // differently between runs, so allow that much and nothing more. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0.02, + }); + }); +}); + test.describe("Left header", () => { test("Shows the shared badge and the save status", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs index ea4857a40a..1304fd3c1d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs @@ -40,14 +40,13 @@ params. Shared by the square, column and row grid-type layouts, each of which places it at a different point in their own layout." [{:keys [show disabled on-toggle]}] - [:button {:class (stl/css-case :show-more-options true - :selected show) - :on-click on-toggle - :disabled disabled} - [:> icon* {:icon-id i/menu - :size "m" - :aria-hidden true - :class (stl/css :show-options-icon)}]]) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.grid.params.guide-defaults") + :aria-pressed show + :on-click on-toggle + :disabled disabled + :tooltip-placement "top-left" + :icon i/menu}]) (mf/defc default-options-dropdown* "Dropdown menu with the reset/save-as-default options for a grid's params. @@ -230,10 +229,12 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.guides.toggle-guide") :on-click handle-toggle-visibility + :tooltip-placement "top-left" :icon (if display "shown" "hide")}] [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.guides.remove-guide") :on-click on-remove + :tooltip-placement "top-left" :icon i/remove}]]] (when (:display grid) @@ -365,6 +366,7 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.guides.add-guide") :on-click handle-create-grid + :tooltip-placement "top-left" :icon i/add}]]] (when (and open? (seq grids)) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss index ba80378e2a..aaf09cc4ee 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss @@ -272,65 +272,6 @@ inline-size: px2rem(156); } -.show-more-options { - --show-more-options-background-color: transparent; - --show-more-options-border-color: transparent; - --show-more-options-color: var(--color-foreground-secondary); - - background: none; - cursor: pointer; - display: grid; - place-content: center; - block-size: $sz-32; - inline-size: $sz-32; - border-radius: $br-8; - background-color: var(--show-more-options-background-color); - border: $b-2 solid var(--show-more-options-border-color); - color: var(--show-more-options-color); - - &:focus-visible { - outline: none; - border: $b-1 solid var(--color-accent-primary); - - --show-more-options-background-color: var(--color-background-tertiary); - --show-more-options-color: var(--color-foreground-primary); - } - - &:hover { - --show-more-options-background-color: var(--color-background-quaternary); - --show-more-options-border-color: var(--color-background-quaternary); - --show-more-options-color: var(--color-accent-primary); - } - - &:active { - outline: none; - - --show-more-options-background-color: var(--color-background-secondary); - --show-more-options-border-color: transparent; - --show-more-options-color: var(--color-accent-primary); - } - - &[disabled], - &:disabled { - --show-more-options-color: var(--color-foreground-disabled); - - cursor: unset; - pointer-events: none; - } - - &.selected { - outline: none; - - --show-more-options-background-color: var(--color-background-quaternary); - --show-more-options-border-color: var(--color-background-quaternary); - --show-more-options-color: var(--color-accent-primary); - } -} - -.show-more-options svg { - stroke: var(--show-more-options-color); -} - .height, .gutter, .margin { diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 421f69a678..41a29850d8 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7361,6 +7361,10 @@ msgstr "Columns" msgid "workspace.options.grid.params.color" msgstr "Color" +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:44 +msgid "workspace.options.grid.params.guide-defaults" +msgstr "Guide defaults" + #: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:302 msgid "workspace.options.grid.params.gutter" msgstr "Gutter" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index b102fe8821..68a7372cb7 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -7448,6 +7448,10 @@ msgstr "Columnas" msgid "workspace.options.grid.params.color" msgstr "Color" +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:44 +msgid "workspace.options.grid.params.guide-defaults" +msgstr "Valores por defecto de la guía" + #: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:302 msgid "workspace.options.grid.params.gutter" msgstr "Espaciado" From 9904e50889b4b9a072b073f2fe06d36d295cc133 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 20 Oct 2026 09:18:49 +0200 Subject: [PATCH 095/167] :recycle: Use DS icon buttons in the component options (#12394) * :recycle: Use a DS icon button for the component pill menu Replace the raw three dots button of the component pill with the DS secondary icon button. It had no label, so it reuses the "Options" one, shown in the DS tooltip above and to the left (it sits at the right edge of the sidebar), and aria-pressed while the menu is open instead of the selected class. Its class drops the button placeholders and only keeps the pill segment shape: full height and square corners on the left. The tooltip wrapper fills the segment too. Add a visual test that selects a component copy so the pill shows. AI-assisted-by: claude-opus-5-5 * :recycle: Use DS icon buttons for the component annotation actions The save/create, discard, edit and delete actions of a component annotation were divs with a click handler: no keyboard focus and no button role. Replace them with DS ghost icon buttons. Their title moves to the label, shown in the DS tooltip above and to the left, and save is disabled while the text is blank, which it already ignored. The class that hides them until the title is hovered, or while editing or creating, now goes on the tooltip wrapper. The green and red hover colors and the fixed size go away with the DS look. While editing or creating, the actions get a small padding so their hover background does not touch the annotation border; the title row grows from 32 to 36px there. Add a visual test that creates an annotation on a main component and shows both sets of actions. AI-assisted-by: claude-opus-5-5 * :lipstick: Open component options tooltips above and to the left The variants help, create variant, add property and remove property icon buttons sit at the right edge of the sidebar: open their tooltips above and to the left, like the other actions of the component options. AI-assisted-by: claude-opus-5-5 * :recycle: Move component options styles off the deprecated styles Inline the dropdown placeholders of the component pill menu with DS tokens and a local variable for the item colors that change on hover, and give the item label its own class instead of styling the span. Resolve the annotation title border alias to its DS token and delete the unused .component-title-actions rule. The item label uses the DS body-small typography; its line-height is 1.3 instead of 1.4, with no visible change since the item centers it. component.scss no longer imports refactor/. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 66 +++++++++++ .../sidebar/options/menus/component.cljs | 73 +++++++----- .../sidebar/options/menus/component.scss | 104 +++++++++++------- 3 files changed, 172 insertions(+), 71 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 632dea1f56..1c382e7c25 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -264,6 +264,72 @@ test.describe("Design tab", () => { }); }); +test.describe("Component options", () => { + test("Shows the component pill of a copy", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickToggableLayer("Board"); + await workspace.clickToggableLayer("Group"); + await workspace.layers.getByText("A", { exact: true }).click(); + + await expect( + workspace.rightSidebar.getByTestId("component-pill-button"), + ).toBeVisible(); + // The component pill is a small part of the sidebar: compare every + // pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); + + test("Shows the annotation actions of a main component", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickToggableLayer("Board"); + await workspace.clickToggableLayer("Group"); + await workspace.layers.getByText("B", { exact: true }).click(); + await workspace.rightSidebar + .getByRole("button", { name: "Options" }) + .click(); + await workspace.rightSidebar.getByText("Create annotation").click(); + + // Creating: save (disabled while the text is empty) and discard. + const annotation = workspace.rightSidebar.locator("#annotation-textarea"); + await expect(annotation).toBeFocused(); + await expect(workspace.rightSidebar).toHaveScreenshot( + "annotation-creating.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + + // Saved: edit and delete show when hovering the title. + await annotation.fill("An annotation"); + await workspace.rightSidebar + .getByRole("button", { name: "Create", exact: true }) + .click(); + const title = workspace.rightSidebar.getByText("Annotation", { + exact: true, + }); + await title.hover(); + await expect(workspace.rightSidebar).toHaveScreenshot( + "annotation-saved.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + }); +}); + test.describe("Assets tab", () => { test("Shows the libraries modal correctly", async ({ page }) => { const workspace = new WorkspacePage(page); 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..00660a3dfb 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 @@ -198,31 +198,35 @@ (if (or ^boolean editing? ^boolean creating?) [:* - [:div {:title (if ^boolean creating? - (tr "labels.create") - (tr "labels.save")) - :on-click on-save - :class (stl/css :annotation-title-icon-action)} - [:> icon* {:icon-id i/tick - :class (stl/css-case :annotation-title-icon-ok true - :disabled invalid-text?)}]] - [:div {:class (stl/css :annotation-title-icon-action) - :title (tr "labels.discard") - :on-click on-discard} - [:> icon* {:icon-id i/close - :class (stl/css :annotation-title-icon-nok)}]]] + [:> icon-button* {:variant "ghost" + :aria-label (if ^boolean creating? + (tr "labels.create") + (tr "labels.save")) + :on-click on-save + :disabled invalid-text? + :tooltip-class (stl/css :annotation-title-icon-action) + :tooltip-placement "top-left" + :icon i/tick}] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.discard") + :on-click on-discard + :tooltip-class (stl/css :annotation-title-icon-action) + :tooltip-placement "top-left" + :icon i/close}]] [:* - [:div {:class (stl/css :annotation-title-icon-action) - :title (tr "labels.edit") - :on-click on-edit} - [:> icon* {:icon-id i/curve - :class (stl/css :annotation-title-icon-ok)}]] - [:div {:class (stl/css :annotation-title-icon-action) - :title (tr "labels.delete") - :on-click on-delete-annotation} - [:> icon* {:icon-id i/delete - :class (stl/css :annotation-title-icon-nok)}]]]))]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.edit") + :on-click on-edit + :tooltip-class (stl/css :annotation-title-icon-action) + :tooltip-placement "top-left" + :icon i/curve}] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.delete") + :on-click on-delete-annotation + :tooltip-class (stl/css :annotation-title-icon-action) + :tooltip-placement "top-left" + :icon i/delete}]]))]] [:div {:class (stl/css-case :annotation-body-hidden (not expanded?))} [:div {:class (stl/css :annotation-body)} @@ -896,10 +900,14 @@ (when menu-entries? [:div {:class (stl/css :pill-actions)} - [:button {:class (stl/css-case :pill-actions-btn true - :selected menu-open?) - :on-click on-menu-click} - [:> icon* {:icon-id i/menu}]] + [:> icon-button* {:variant "secondary" + :class (stl/css :pill-actions-btn) + :tooltip-class (stl/css :pill-actions-tooltip) + :aria-label (tr "labels.options") + :aria-pressed menu-open? + :tooltip-placement "top-left" + :on-click on-menu-click + :icon i/menu}] [:& dropdown {:show menu-open? :on-close on-menu-close} @@ -910,7 +918,7 @@ [:li {:key title :class (stl/css :pill-actions-dropdown-item) :on-click (partial do-action action)} - [:span title]]))]]])])) + [:span {:class (stl/css :pill-actions-dropdown-item-label)} title]]))]]])])) (mf/defc component-menu* [{:keys [shapes is-swap-opened]}] @@ -1039,12 +1047,14 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.component.variants-help-modal.title") :on-click on-click-variant-title-help + :tooltip-placement "top-left" :icon i/help}]) (when main-instance? [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.shape.menu.add-variant") :on-click (if is-variant? create-variant transform-into-variant) + :tooltip-placement "top-left" :icon i/variant}])])] (when open? @@ -1067,6 +1077,7 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.shape.menu.add-variant-property") :on-click add-new-property + :tooltip-placement "top-left" :icon i/add}])] (when is-swap-opened @@ -1151,7 +1162,8 @@ (tr "workspace.shape.menu.remove-variant-property.last-property") (tr "workspace.shape.menu.remove-variant-property")) :on-click on-remove - :disabled is-remove-disabled}]]])) + :disabled is-remove-disabled + :tooltip-placement "top-left"}]]])) (mf/defc component-variant-main* [{:keys [shapes]}] @@ -1283,10 +1295,12 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.component.variants-help-modal.title") :on-click on-click-variant-title-help + :tooltip-placement "top-left" :icon i/help}] [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.shape.menu.add-variant") :on-click (partial create-variant "workspace:button-design-tab-component") + :tooltip-placement "top-left" :icon i/variant}]]] (when open? @@ -1301,6 +1315,7 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.shape.menu.add-variant-property") :on-click (partial add-new-property "workspace:button-design-tab-component") + :tooltip-placement "top-left" :icon i/add}]] (when-not multi? diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index ca8bbd7aab..acbe025003 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -10,7 +10,6 @@ @use "ds/spacing.scss" as *; @use "ds/typography.scss" as t; @use "../../../sidebar/common/sidebar.scss" as sidebar; -@use "refactor/common-refactor.scss" as deprecated; .annotation { @include t.use-typography("body-small"); @@ -23,7 +22,12 @@ border: $b-1 solid var(--color-accent-primary); .annotation-title { - border-bottom: $b-1 solid var(--entry-border-color-disabled); + border-bottom: $b-1 solid var(--color-background-quaternary); + } + + // Keep the hover background of the actions off the border. + .annotation-title-actions { + padding: var(--sp-xxs); } .annotation-title-icon-action { @@ -46,6 +50,10 @@ display: flex; } + .annotation-title-actions { + padding: var(--sp-xxs); + } + .annotation-title-name { margin-inline-start: var(--sp-m); } @@ -59,7 +67,7 @@ .annotation-title { display: flex; align-items: center; - block-size: $sz-32; + min-block-size: $sz-32; &.expanded { border-bottom: $b-1 solid var(--color-background-quaternary); @@ -90,29 +98,10 @@ line-height: 2.5; } -.annotation-title-icon-action { - cursor: pointer; +// Tooltip wrapper of each action: hidden until the title is hovered or the +// annotation is being edited or created. +.annotation-title-actions .annotation-title-icon-action { display: none; - justify-content: center; - align-items: center; - inline-size: $sz-28; - block-size: $sz-32; - border-radius: $br-8; - margin: 0; - padding: 0; -} - -.annotation-title-icon-ok:hover { - color: var(--color-accent-success); - - &.disabled { - cursor: default; - color: var(--color-foreground-secondary); - } -} - -.annotation-title-icon-nok:hover { - color: var(--color-accent-error); } .annotation-body-hidden { @@ -489,11 +478,6 @@ color: var(--color-foreground-secondary); } -.component-title-actions { - display: flex; - gap: var(--sp-xs); -} - .component-content { grid-column: span 8; display: flex; @@ -588,25 +572,35 @@ position: relative; } -.pill-actions-btn { - @extend %button-secondary; +// The tooltip wrapper is the flex item: let it fill the pill segment. +.pill-actions-tooltip { + block-size: 100%; + inline-size: 100%; +} - cursor: unset; +// Right segment of the pill: full height and square corners on the left. +.pill-actions-btn { block-size: 100%; inline-size: 100%; border-radius: 0 $br-8 $br-8 0; - - &.selected { - @extend %button-icon-selected; - } } .pill-actions-dropdown { - @extend %dropdown-wrapper; - - inline-size: $sz-252; + position: absolute; + inset-block-start: $sz-32; inset-inline-end: 0; - inset-inline-start: unset; + inline-size: $sz-252; + max-block-size: var(--menu-max-height, px2rem(300)); + padding: var(--sp-xxs); + margin: 0; + margin-block-start: px2rem(1); + border: $b-2 solid var(--color-background-quaternary); + border-radius: $br-8; + box-shadow: 0 0 px2rem(12) 0 var(--color-shadow-dark); + z-index: var(--z-index-dropdown); + overflow: hidden auto; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); &.extended { inset-block-start: $sz-48; @@ -614,7 +608,33 @@ } .pill-actions-dropdown-item { - @extend %dropdown-element-base; + @include t.use-typography("body-small"); + + --pill-actions-dropdown-item-foreground-color: var(--color-foreground-secondary); + --pill-actions-dropdown-item-background-color: transparent; + + display: flex; + align-items: center; + gap: var(--sp-s); + block-size: $sz-32; + padding: 0 var(--sp-s); + border-radius: $br-6; + cursor: pointer; + color: var(--pill-actions-dropdown-item-foreground-color); + background-color: var(--pill-actions-dropdown-item-background-color); + + &:hover { + --pill-actions-dropdown-item-foreground-color: var(--color-foreground-primary); + --pill-actions-dropdown-item-background-color: var(--color-background-quaternary); + } +} + +.pill-actions-dropdown-item-label { + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .variant-property-list { From 47d7ad2de17e9817138717c9887fa3b2936d5156 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 20 Oct 2026 09:19:17 +0200 Subject: [PATCH 096/167] :recycle: Explain layout_container's deprecated import and format visual spec (#12449) * :recycle: Explain why layout_container.scss keeps the deprecated import The TODO at the top said to remove the refactor/ import once the buttons were replaced, and sat above the DS imports instead of that one. The buttons are now DS buttons (#12428, #12431), but the import is still needed by the numeric inputs (%input-element, %disabled-input) and by the two raw track header buttons, which keep the secondary and tertiary placeholders. Replace the TODO with a comment on the import that says so. AI-assisted-by: claude-opus-5-5 * :paperclip: Format the workspace visual spec with Prettier Three expectations added in #12423, #12428 and #12431 broke a call chain in a way Prettier rewrites. check-fmt:js did not catch it: its playwright/**/*.js glob is expanded by the shell without globstar, so it does not reach visual-specs/. AI-assisted-by: claude-opus-5-5 --- .../playwright/ui/visual-specs/workspace.spec.js | 15 +++++++++------ .../sidebar/options/menus/layout_container.scss | 4 +++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 1c382e7c25..f18514cc9d 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -71,8 +71,9 @@ test.describe("Grid layout", () => { await workspace.clickLeafLayer("Board"); const section = page.getByTestId("inspect-layout"); - await expect(section.getByRole("button", { name: "Edit grid" })) - .toBeVisible(); + await expect( + section.getByRole("button", { name: "Edit grid" }), + ).toBeVisible(); // The buttons are a small part of the section: compare every pixel. await expect(section).toHaveScreenshot("grid-layout-options.png", { maxDiffPixelRatio: 0, @@ -187,8 +188,9 @@ test.describe("Design tab", () => { await padding.click(); await padding.blur(); await page.mouse.move(0, 0); - await expect(section.getByRole("textbox", { name: /top padding/i })) - .toBeVisible(); + await expect( + section.getByRole("textbox", { name: /top padding/i }), + ).toBeVisible(); await expect(section).toHaveScreenshot("flex-layout-pressed.png", { maxDiffPixelRatio: 0, threshold: 0, @@ -255,8 +257,9 @@ test.describe("Design tab", () => { await marginMode.click(); await marginMode.blur(); await page.mouse.move(0, 0); - await expect(section.getByRole("textbox", { name: "Top margin" })) - .toBeVisible(); + await expect( + section.getByRole("textbox", { name: "Top margin" }), + ).toBeVisible(); await expect(section).toHaveScreenshot("margin-multiple.png", { maxDiffPixelRatio: 0, threshold: 0, diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index 34f3d81b43..c92abb2dc9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -4,13 +4,15 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -// TODO: When button replace remove this @use @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @use "ds/typography.scss" as t; @use "ds/spacing.scss" as *; @use "ds/mixins.scss" as *; + +// Only for %input-element and %disabled-input (numeric inputs) and the +// secondary and tertiary placeholders of the raw track header buttons. @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; From 60a94c65ccabe34e6f59ae5f1336153931c6a112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 20 Oct 2026 09:32:53 +0200 Subject: [PATCH 097/167] :bug: Fix missing letters when copying text from exported PDFs (#12441) Skia maps ligatures and other substituted glyphs to U+0000 in the PDF ToUnicode maps. Rebuild those maps from the laid-out paragraphs and append them as an incremental update. Closes #12393 --- render-wasm/Cargo.lock | 1 + render-wasm/Cargo.toml | 1 + render-wasm/src/render/pdf.rs | 8 +- render-wasm/src/render/pdf/glyph_text.rs | 278 ++++++++ render-wasm/src/render/pdf/to_unicode.rs | 793 +++++++++++++++++++++++ 5 files changed, 1080 insertions(+), 1 deletion(-) create mode 100644 render-wasm/src/render/pdf/glyph_text.rs create mode 100644 render-wasm/src/render/pdf/to_unicode.rs diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index ee162a0029..1142a20b67 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -463,6 +463,7 @@ dependencies = [ "indexmap", "insta", "macros", + "miniz_oxide", "skia-safe", "thiserror", "unicode-segmentation", diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 22bb7e1590..e71f616191 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -26,6 +26,7 @@ gl = "0.14.0" glam = "0.24.2" indexmap = "2.7.1" macros = { path = "macros" } +miniz_oxide = "0.8.0" skia-safe = { version = "0.93.1", default-features = false, features = [ "gl", "svg", diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index b8e204dcb6..d70f353ef4 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -7,6 +7,9 @@ use crate::uuid::Uuid; use super::vector; use super::RenderResources; +mod glyph_text; +mod to_unicode; + /// Renders a shape tree to a PDF document and returns the raw PDF bytes. /// /// This is a dedicated vector-PDF render path that draws directly to a Skia @@ -49,5 +52,8 @@ pub fn render_to_pdf( let document = on_page.end_page(); document.close(); - Ok(pdf_bytes) + // Skia maps ligatures and other substituted glyphs to U+0000; give them + // back their text so it can be copied from the PDF. + let glyph_texts = glyph_text::collect(tree, id); + Ok(to_unicode::fix_to_unicode(pdf_bytes, &glyph_texts)) } diff --git a/render-wasm/src/render/pdf/glyph_text.rs b/render-wasm/src/render/pdf/glyph_text.rs new file mode 100644 index 0000000000..d3623bf87f --- /dev/null +++ b/render-wasm/src/render/pdf/glyph_text.rs @@ -0,0 +1,278 @@ +//! Collects the text behind each drawn glyph, per font. Skia's PDF backend +//! maps substituted glyphs (ligatures, contextual alternates) to U+0000. + +use std::collections::HashMap; + +use skia_safe as skia; + +use crate::shapes::Type; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +#[derive(Debug, Clone)] +struct GlyphText { + text: String, + /// The glyph is the only one drawn for its cluster. + single_glyph: bool, + typeface: skia::typeface::TypefaceId, + /// Two typefaces sharing a font name disagree on this glyph. + conflict: bool, +} + +/// Text for each glyph, keyed by the font name Skia writes in `/BaseFont` +/// (without the subset tag). +#[derive(Debug, Default)] +pub struct GlyphTexts { + fonts: HashMap>, +} + +impl GlyphTexts { + /// Text that should replace the current mapping of `glyph` in `font`, if + /// any. `current` is the mapping Skia wrote (empty or `"\0"` when unknown). + pub fn replacement(&self, font: &str, glyph: skia::GlyphId, current: &str) -> Option<&str> { + let entry = self.fonts.get(font)?.get(&glyph)?; + if entry.conflict || entry.text.is_empty() || entry.text == current { + return None; + } + let unmapped = current.is_empty() || current == "\0"; + // A single glyph standing for several characters is a ligature: the + // font's `cmap` entry (e.g. U+FB02 for "fl") is not what was typed. + let ligature = entry.single_glyph && entry.text.chars().count() > 1; + (unmapped || ligature).then_some(entry.text.as_str()) + } + + pub fn is_empty(&self) -> bool { + self.fonts.is_empty() + } + + #[cfg(test)] + pub(super) fn with_glyph(mut self, font: &str, glyph: skia::GlyphId, text: &str) -> Self { + let value = GlyphText { + text: text.to_string(), + single_glyph: true, + typeface: 1, + conflict: false, + }; + self.insert(font, glyph, value); + self + } + + fn insert(&mut self, font: &str, glyph: skia::GlyphId, value: GlyphText) { + let glyphs = self.fonts.entry(font.to_string()).or_default(); + match glyphs.get_mut(&glyph) { + None => { + glyphs.insert(glyph, value); + } + // Same typeface, different text: keep the first one, as Skia does. + Some(existing) + if existing.typeface != value.typeface && existing.text != value.text => + { + existing.conflict = true; + } + Some(_) => {} + } + } + + /// Records the glyphs of a laid-out paragraph. `text` is the UTF-8 text the + /// paragraph was built from. + pub fn add_paragraph(&mut self, text: &str, paragraph: &mut skia::textlayout::Paragraph) { + let mut runs: Vec = Vec::new(); + paragraph.visit(|_, info| { + let Some(info) = info else { + return; + }; + let typeface = info.font().typeface(); + runs.push(Run { + typeface: typeface.unique_id(), + font_name: pdf_font_name(&typeface), + glyphs: info.glyphs().to_vec(), + starts: info.utf8_starts().iter().map(|s| *s as usize).collect(), + }); + }); + self.add_runs(text, &runs); + } + + fn add_runs(&mut self, text: &str, runs: &[Run]) { + // Cluster starts of the whole paragraph: a cluster ends where the next + // one starts, and that one may sit in another run or line. + let mut boundaries: Vec = runs + .iter() + .flat_map(|run| run.starts.iter().copied()) + .chain([text.len()]) + .collect(); + boundaries.sort_unstable(); + boundaries.dedup(); + + for run in runs { + let count = run.glyphs.len(); + for (i, &glyph) in run.glyphs.iter().enumerate() { + let start = run.starts[i]; + if i > 0 && run.starts[i - 1] == start { + continue; + } + let single_glyph = i + 1 >= count || run.starts[i + 1] != start; + if let Some(cluster) = cluster_text(text, &boundaries, start) { + self.insert( + &run.font_name, + glyph, + GlyphText { + text: cluster.to_string(), + single_glyph, + typeface: run.typeface, + conflict: false, + }, + ); + } + } + } + } +} + +struct Run { + typeface: skia::typeface::TypefaceId, + font_name: String, + glyphs: Vec, + starts: Vec, +} + +/// Text of the cluster starting at `start`. Whitespace ends it: spaces trimmed +/// at a line break are not drawn, so their start is missing from `boundaries`. +fn cluster_text<'a>(text: &'a str, boundaries: &[usize], start: usize) -> Option<&'a str> { + let end = boundaries + .iter() + .copied() + .find(|b| *b > start) + .unwrap_or(text.len()); + let cluster = text.get(start..end)?; + match cluster + .char_indices() + .skip(1) + .find(|(_, c)| c.is_whitespace()) + { + Some((at, _)) => Some(&cluster[..at]), + None => Some(cluster), + } +} + +/// The name Skia's PDF backend writes for a typeface: its PostScript name, or +/// its family name when it has none. +fn pdf_font_name(typeface: &skia::Typeface) -> String { + typeface + .post_script_name() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| typeface.family_name()) +} + +/// Collects the glyph texts of every visible text shape under `root`. +pub fn collect(tree: ShapesPoolRef, root: &Uuid) -> GlyphTexts { + let mut glyph_texts = GlyphTexts::default(); + let Some(root_shape) = tree.get(root) else { + return glyph_texts; + }; + for id in root_shape.all_children_iter(tree, false, true) { + let Some(shape) = tree.get(&id) else { + continue; + }; + let Type::Text(text_content) = &shape.shape_type else { + continue; + }; + if shape.hidden { + continue; + } + let text_content = text_content.new_bounds(shape.selrect()); + let width = text_content.get_width(shape.selrect().width()); + for group in text_content + .paragraph_builder_group_from_text(None) + .iter_mut() + { + for builder in group.iter_mut() { + // `build` takes the text out of the builder. + let text = builder.get_text().to_string(); + let mut paragraph = builder.build(); + paragraph.layout(width); + glyph_texts.add_paragraph(&text, &mut paragraph); + } + } + } + glyph_texts +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(glyphs: &[u16], starts: &[usize]) -> Run { + Run { + typeface: 1, + font_name: "Font".to_string(), + glyphs: glyphs.to_vec(), + starts: starts.to_vec(), + } + } + + #[test] + fn ligature_glyph_maps_to_its_whole_cluster() { + let mut texts = GlyphTexts::default(); + // "office": o, ffi (one glyph), c, e + texts.add_runs("office", &[run(&[10, 20, 30, 40], &[0, 1, 4, 5, 6])]); + + assert_eq!(texts.replacement("Font", 20, "\0"), Some("ffi")); + assert_eq!(texts.replacement("Font", 20, "\u{FB03}"), Some("ffi")); + assert_eq!(texts.replacement("Font", 10, "o"), None); + assert_eq!(texts.replacement("Font", 30, "\0"), Some("c")); + } + + #[test] + fn single_char_glyph_with_cmap_entry_keeps_it() { + let mut texts = GlyphTexts::default(); + texts.add_runs("a", &[run(&[5], &[0, 1])]); + + assert_eq!(texts.replacement("Font", 5, "\u{00E1}"), None); + assert_eq!(texts.replacement("Font", 5, ""), Some("a")); + } + + #[test] + fn multi_glyph_cluster_only_fills_the_first_glyph() { + let mut texts = GlyphTexts::default(); + // "é" drawn as base + mark glyphs sharing one cluster. + texts.add_runs("é", &[run(&[7, 8], &[0, 0, 2])]); + + assert_eq!(texts.replacement("Font", 7, "\0"), Some("é")); + assert_eq!(texts.replacement("Font", 8, "\0"), None); + } + + #[test] + fn clusters_end_at_starts_from_other_runs() { + let mut texts = GlyphTexts::default(); + // Right-to-left run: glyph order is the reverse of text order, and its + // last cluster ends where the next run starts. + texts.add_runs( + "abcd", + &[run(&[3, 2], &[1, 0, 0]), run(&[4, 5], &[2, 3, 4])], + ); + + assert_eq!(texts.replacement("Font", 3, "\0"), Some("b")); + assert_eq!(texts.replacement("Font", 2, "\0"), Some("a")); + } + + #[test] + fn cluster_stops_at_whitespace_dropped_at_line_breaks() { + let mut texts = GlyphTexts::default(); + // "lo world" wraps after "lo": the space at 2 is not drawn, so the + // "o" cluster would run until "w" without the whitespace stop. + texts.add_runs("lo world", &[run(&[8, 9], &[0, 1, 3])]); + + assert_eq!(texts.replacement("Font", 9, "\0"), Some("o")); + } + + #[test] + fn conflicting_typefaces_with_the_same_name_are_ignored() { + let mut texts = GlyphTexts::default(); + texts.add_runs("a", &[run(&[5], &[0, 1])]); + let mut other = run(&[5], &[0, 1]); + other.typeface = 2; + texts.add_runs("b", &[other]); + + assert_eq!(texts.replacement("Font", 5, "\0"), None); + } +} diff --git a/render-wasm/src/render/pdf/to_unicode.rs b/render-wasm/src/render/pdf/to_unicode.rs new file mode 100644 index 0000000000..a724490bc7 --- /dev/null +++ b/render-wasm/src/render/pdf/to_unicode.rs @@ -0,0 +1,793 @@ +//! Rewrites the `/ToUnicode` maps of a Skia PDF's Type0 fonts in an appended +//! incremental update, leaving the bytes Skia wrote untouched. + +use std::collections::{BTreeMap, HashMap}; + +use super::glyph_text::GlyphTexts; + +/// Glyph code to UTF-16 text. +type CMap = BTreeMap>; + +/// Returns `pdf` with fixed `/ToUnicode` maps, or unchanged when nothing needs +/// fixing or the file is not laid out as expected. +pub fn fix_to_unicode(pdf: Vec, glyph_texts: &GlyphTexts) -> Vec { + if glyph_texts.is_empty() { + return pdf; + } + match updated_cmaps(&pdf, glyph_texts) { + Some(Update { + cmaps, + xref_offset, + trailer, + }) if !cmaps.is_empty() => append_update(pdf, &cmaps, xref_offset, &trailer), + _ => pdf, + } +} + +struct Update { + /// ToUnicode object number to its new CMap. + cmaps: BTreeMap, + xref_offset: usize, + trailer: Vec, +} + +fn updated_cmaps(pdf: &[u8], glyph_texts: &GlyphTexts) -> Option { + let xref = parse_xref(pdf)?; + let mut cmaps = BTreeMap::new(); + + for &offset in xref.offsets.values() { + let Some(font) = read_object(pdf, offset) else { + continue; + }; + if dict_name(font.dict, b"/Type") != Some(b"Font".as_slice()) + || dict_name(font.dict, b"/Subtype") != Some(b"Type0".as_slice()) + { + continue; + } + let (Some(base_font), Some(to_unicode)) = ( + dict_name(font.dict, b"/BaseFont"), + dict_ref(font.dict, b"/ToUnicode"), + ) else { + continue; + }; + let font_name = strip_subset_tag(&decode_name(base_font)); + let Some(stream) = xref + .offsets + .get(&to_unicode) + .and_then(|offset| read_object(pdf, *offset)) + .and_then(|object| stream_data(&object)) + else { + continue; + }; + + let mut cmap = parse_cmap(&stream); + let mut changed = false; + for (code, value) in cmap.iter_mut() { + let current = String::from_utf16_lossy(value); + if let Some(text) = glyph_texts.replacement(&font_name, *code, ¤t) { + *value = text.encode_utf16().collect(); + changed = true; + } + } + if changed { + cmaps.insert(to_unicode, cmap); + } + } + + Some(Update { + cmaps, + xref_offset: xref.offset, + trailer: xref.trailer, + }) +} + +fn append_update( + mut pdf: Vec, + cmaps: &BTreeMap, + prev_xref: usize, + trailer: &[u8], +) -> Vec { + if pdf.last() != Some(&b'\n') { + pdf.push(b'\n'); + } + let mut offsets = Vec::with_capacity(cmaps.len()); + for (&number, cmap) in cmaps { + let data = write_cmap(cmap); + offsets.push((number, pdf.len())); + pdf.extend_from_slice( + format!("{number} 0 obj\n<> stream\n", data.len()).as_bytes(), + ); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream\nendobj\n"); + } + + let xref_offset = pdf.len(); + pdf.extend_from_slice(b"xref\n"); + for (number, offset) in offsets { + pdf.extend_from_slice(format!("{number} 1\n{offset:010} 00000 n \n").as_bytes()); + } + // Same entries as the previous trailer (`/Size`, `/Root`, `/Info`, `/ID`), + // plus `/Prev`. + let body = &trailer[..trailer.len() - 2]; + pdf.extend_from_slice(b"trailer\n"); + pdf.extend_from_slice(body); + pdf.extend_from_slice( + format!("\n/Prev {prev_xref}>>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes(), + ); + pdf +} + +// --------------------------------------------------------------------------- +// CMap + +fn parse_cmap(data: &[u8]) -> CMap { + let mut cmap = CMap::new(); + let mut tokens = CMapTokens { data, pos: 0 }; + while let Some(token) = tokens.next() { + match token { + Token::Keyword(b"beginbfchar") => { + while let Some(Token::Hex(code)) = tokens.next() { + let Some(Token::Hex(value)) = tokens.next() else { + break; + }; + if let Some(code) = to_code(&code) { + cmap.insert(code, to_utf16(&value)); + } + } + } + Token::Keyword(b"beginbfrange") => { + while let Some(Token::Hex(start)) = tokens.next() { + let (Some(Token::Hex(end)), Some(first)) = (tokens.next(), tokens.next()) + else { + break; + }; + let (Some(start), Some(end)) = (to_code(&start), to_code(&end)) else { + continue; + }; + match first { + Token::Hex(value) => { + let value = to_utf16(&value); + for (step, code) in (start..=end).enumerate() { + let mut value = value.clone(); + if let Some(last) = value.last_mut() { + *last = last.wrapping_add(step as u16); + } + cmap.insert(code, value); + } + } + Token::ArrayStart => { + let mut code = start; + while let Some(Token::Hex(value)) = tokens.next() { + cmap.insert(code, to_utf16(&value)); + code = code.wrapping_add(1); + } + } + _ => break, + } + } + } + _ => {} + } + } + cmap +} + +fn write_cmap(cmap: &CMap) -> Vec { + let mut out = String::from( + "/CIDInit /ProcSet findresource begin\n\ + 12 dict begin\n\ + begincmap\n\ + /CIDSystemInfo\n\ + << /Registry (Adobe)\n\ + /Ordering (UCS)\n\ + /Supplement 0\n\ + >> def\n\ + /CMapName /Adobe-Identity-UCS def\n\ + /CMapType 2 def\n\ + 1 begincodespacerange\n\ + <0000> \n\ + endcodespacerange\n", + ); + let entries: Vec<_> = cmap.iter().collect(); + // At most 100 entries per section (PDF 32000-1, 9.10.3). + for chunk in entries.chunks(100) { + out.push_str(&format!("{} beginbfchar\n", chunk.len())); + for (code, value) in chunk { + out.push_str(&format!("<{code:04X}> <")); + for unit in value.iter() { + out.push_str(&format!("{unit:04X}")); + } + out.push_str(">\n"); + } + out.push_str("endbfchar\n"); + } + out.push_str( + "endcmap\n\ + CMapName currentdict /CMap defineresource pop\n\ + end\n\ + end", + ); + out.into_bytes() +} + +enum Token<'a> { + Hex(Vec), + ArrayStart, + ArrayEnd, + Keyword(&'a [u8]), + Other, +} + +struct CMapTokens<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Iterator for CMapTokens<'a> { + type Item = Token<'a>; + + fn next(&mut self) -> Option> { + self.pos = skip_ws(self.data, self.pos); + let byte = *self.data.get(self.pos)?; + let start = self.pos; + self.pos += 1; + match byte { + b'<' if self.data.get(self.pos) == Some(&b'<') => { + self.pos += 1; + Some(Token::Other) + } + b'<' => { + let end = find(self.data, b">", self.pos)?; + let hex = parse_hex(&self.data[self.pos..end]); + self.pos = end + 1; + Some(Token::Hex(hex)) + } + b'[' => Some(Token::ArrayStart), + b']' => Some(Token::ArrayEnd), + _ => { + while self + .data + .get(self.pos) + .is_some_and(|b| !is_ws(*b) && !is_delimiter(*b)) + { + self.pos += 1; + } + Some(Token::Keyword(&self.data[start..self.pos])) + } + } + } +} + +fn parse_hex(hex: &[u8]) -> Vec { + let digits: Vec = hex + .iter() + .filter_map(|b| (*b as char).to_digit(16).map(|d| d as u8)) + .collect(); + digits + .chunks(2) + .map(|pair| (pair[0] << 4) | pair.get(1).copied().unwrap_or(0)) + .collect() +} + +fn to_code(bytes: &[u8]) -> Option { + match bytes { + [byte] => Some(*byte as u16), + [high, low] => Some(u16::from_be_bytes([*high, *low])), + _ => None, + } +} + +fn to_utf16(bytes: &[u8]) -> Vec { + bytes + .chunks(2) + .map(|pair| u16::from_be_bytes([pair[0], pair.get(1).copied().unwrap_or(0)])) + .collect() +} + +// --------------------------------------------------------------------------- +// PDF objects + +struct Xref { + /// Byte offset of the `xref` keyword. + offset: usize, + /// Object number to byte offset, for objects in use. + offsets: HashMap, + /// The trailer dictionary, `<<` to `>>`. + trailer: Vec, +} + +/// Reads the last xref table and its trailer. Skia writes a single classic +/// table, without xref streams. +fn parse_xref(pdf: &[u8]) -> Option { + let startxref = rfind(pdf, b"startxref")?; + let (offset, _) = parse_uint(pdf, skip_ws(pdf, startxref + b"startxref".len()))?; + parse_xref_at(pdf, offset as usize) +} + +fn parse_xref_at(pdf: &[u8], offset: usize) -> Option { + if !pdf.get(offset..)?.starts_with(b"xref") { + return None; + } + let mut offsets = HashMap::new(); + let mut pos = offset + b"xref".len(); + loop { + pos = skip_ws(pdf, pos); + if pdf.get(pos..)?.starts_with(b"trailer") { + break; + } + let (first, next) = parse_uint(pdf, pos)?; + let (count, next) = parse_uint(pdf, skip_ws(pdf, next))?; + pos = next; + for number in first..first + count { + let (entry_offset, next) = parse_uint(pdf, skip_ws(pdf, pos))?; + let (_generation, next) = parse_uint(pdf, skip_ws(pdf, next))?; + pos = skip_ws(pdf, next); + if *pdf.get(pos)? == b'n' { + offsets.insert(number as u32, entry_offset as usize); + } + pos += 1; + } + } + let start = skip_ws(pdf, pos + b"trailer".len()); + let end = skip_dict(pdf, start)?; + Some(Xref { + offset, + offsets, + trailer: pdf[start..end].to_vec(), + }) +} + +struct Object<'a> { + dict: &'a [u8], + stream: Option<&'a [u8]>, +} + +/// Reads the dictionary (and stream, if any) of the object at `offset`. +fn read_object(pdf: &[u8], offset: usize) -> Option> { + let (_, pos) = parse_uint(pdf, offset)?; + let (_, pos) = parse_uint(pdf, skip_ws(pdf, pos))?; + let pos = skip_ws(pdf, pos); + if !pdf.get(pos..)?.starts_with(b"obj") { + return None; + } + let start = skip_ws(pdf, pos + b"obj".len()); + let end = skip_dict(pdf, start)?; + let dict = &pdf[start..end]; + + let mut pos = skip_ws(pdf, end); + if !pdf.get(pos..)?.starts_with(b"stream") { + return Some(Object { dict, stream: None }); + } + pos += b"stream".len(); + if pdf.get(pos) == Some(&b'\r') { + pos += 1; + } + if pdf.get(pos) == Some(&b'\n') { + pos += 1; + } + let length = dict_uint(dict, b"/Length")? as usize; + Some(Object { + dict, + stream: Some(pdf.get(pos..pos + length)?), + }) +} + +fn stream_data(object: &Object) -> Option> { + let stream = object.stream?; + match dict_name(object.dict, b"/Filter") { + None => Some(stream.to_vec()), + Some(b"FlateDecode") => miniz_oxide::inflate::decompress_to_vec_zlib(stream).ok(), + Some(_) => None, + } +} + +/// Position right after the `>>` closing the dictionary at `pos`. +fn skip_dict(pdf: &[u8], pos: usize) -> Option { + if !pdf.get(pos..)?.starts_with(b"<<") { + return None; + } + let mut depth = 0; + let mut pos = pos; + while pos < pdf.len() { + match pdf[pos] { + b'<' if pdf.get(pos + 1) == Some(&b'<') => { + depth += 1; + pos += 2; + } + b'>' if pdf.get(pos + 1) == Some(&b'>') => { + depth -= 1; + pos += 2; + if depth == 0 { + return Some(pos); + } + } + b'<' => pos = find(pdf, b">", pos)? + 1, + b'(' => pos = skip_string(pdf, pos)?, + _ => pos += 1, + } + } + None +} + +/// Position right after the literal string starting at `pos`. +fn skip_string(pdf: &[u8], pos: usize) -> Option { + let mut depth = 0; + let mut pos = pos; + while pos < pdf.len() { + match pdf[pos] { + b'\\' => pos += 1, + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + return Some(pos + 1); + } + } + _ => {} + } + pos += 1; + } + None +} + +/// Position of the value for `key`, matching whole keys only. +fn dict_value<'a>(dict: &'a [u8], key: &[u8]) -> Option<&'a [u8]> { + let mut from = 0; + while let Some(at) = find(dict, key, from) { + let end = at + key.len(); + if dict.get(end).is_some_and(|b| is_ws(*b) || is_delimiter(*b)) { + return Some(&dict[skip_ws(dict, end)..]); + } + from = end; + } + None +} + +fn dict_name<'a>(dict: &'a [u8], key: &[u8]) -> Option<&'a [u8]> { + let value = dict_value(dict, key)?.strip_prefix(b"/")?; + let end = value + .iter() + .position(|b| is_ws(*b) || is_delimiter(*b)) + .unwrap_or(value.len()); + Some(&value[..end]) +} + +fn dict_uint(dict: &[u8], key: &[u8]) -> Option { + parse_uint(dict_value(dict, key)?, 0).map(|(value, _)| value) +} + +/// Object number of an indirect reference (`N 0 R`). +fn dict_ref(dict: &[u8], key: &[u8]) -> Option { + let value = dict_value(dict, key)?; + let (number, pos) = parse_uint(value, 0)?; + let (_, pos) = parse_uint(value, skip_ws(value, pos))?; + (value.get(skip_ws(value, pos)) == Some(&b'R')).then_some(number as u32) +} + +/// Decodes `#xx` escapes in a PDF name. +fn decode_name(name: &[u8]) -> String { + let mut out = Vec::with_capacity(name.len()); + let mut i = 0; + while i < name.len() { + if name[i] == b'#' && i + 2 < name.len() { + if let [byte] = parse_hex(&name[i + 1..i + 3])[..] { + out.push(byte); + i += 3; + continue; + } + } + out.push(name[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Drops the `ABCDEF+` subset tag Skia puts before every font name. +fn strip_subset_tag(name: &str) -> String { + let bytes = name.as_bytes(); + if bytes.len() > 7 && bytes[6] == b'+' && bytes[..6].iter().all(u8::is_ascii_uppercase) { + name[7..].to_string() + } else { + name.to_string() + } +} + +fn parse_uint(data: &[u8], pos: usize) -> Option<(u64, usize)> { + let digits = data + .get(pos..)? + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + if digits == 0 { + return None; + } + let value = std::str::from_utf8(&data[pos..pos + digits]) + .ok()? + .parse() + .ok()?; + Some((value, pos + digits)) +} + +fn skip_ws(data: &[u8], mut pos: usize) -> usize { + while data.get(pos).is_some_and(|b| is_ws(*b)) { + pos += 1; + } + pos +} + +fn is_ws(byte: u8) -> bool { + matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | b'\x0c' | b'\0') +} + +fn is_delimiter(byte: u8) -> bool { + matches!( + byte, + b'/' | b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'{' | b'}' | b'%' + ) +} + +fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + hay.get(from..)? + .windows(needle.len()) + .position(|window| window == needle) + .map(|at| at + from) +} + +fn rfind(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()) + .rposition(|window| window == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + use skia_safe as skia; + + use crate::render::RenderResources; + use crate::shapes::{ + Fill, FontFamily, FontStyle, GrowType, Paragraph, SolidColor, TextAlign, TextContent, + TextDirection, TextSpan, Type, + }; + use crate::state::ShapesPool; + use crate::utils::uuid_from_u32_quartet; + use crate::uuid::Uuid; + + fn cmap(entries: &[(u16, &str)]) -> CMap { + entries + .iter() + .map(|(code, text)| (*code, text.encode_utf16().collect())) + .collect() + } + + /// Objects visible in the final file, following `/Prev` back to the + /// first xref table. + fn latest_offsets(pdf: &[u8]) -> HashMap { + let mut xref = parse_xref(pdf).expect("xref"); + let mut offsets = xref.offsets.clone(); + while let Some(prev) = dict_uint(&xref.trailer, b"/Prev") { + xref = parse_xref_at(pdf, prev as usize).expect("previous xref"); + for (number, offset) in &xref.offsets { + offsets.entry(*number).or_insert(*offset); + } + } + offsets + } + + fn object<'a>(pdf: &'a [u8], offsets: &HashMap, number: u32) -> Object<'a> { + read_object(pdf, offsets[&number]).expect("object") + } + + /// The Type0 font's ToUnicode map, as seen by a reader. + fn to_unicode_of_only_font(pdf: &[u8]) -> CMap { + let offsets = latest_offsets(pdf); + let fonts: Vec<_> = offsets + .values() + .filter_map(|offset| read_object(pdf, *offset)) + .filter(|o| dict_name(o.dict, b"/Subtype") == Some(b"Type0".as_slice())) + .collect(); + assert_eq!(fonts.len(), 1, "expected a single Type0 font"); + let number = dict_ref(fonts[0].dict, b"/ToUnicode").expect("ToUnicode ref"); + parse_cmap(&stream_data(&object(pdf, &offsets, number)).expect("cmap data")) + } + + /// Builds a classic PDF from object bodies numbered from 1. + fn build_pdf(objects: &[Vec]) -> Vec { + let mut pdf = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(body); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice( + format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(), + ); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!( + "trailer\n<>\nstartxref\n{xref}\n%%EOF", + objects.len() + 1 + ) + .as_bytes(), + ); + pdf + } + + fn stream_object(dict: &str, data: &[u8]) -> Vec { + let mut body = format!("<<{dict}/Length {}>> stream\n", data.len()).into_bytes(); + body.extend_from_slice(data); + body.extend_from_slice(b"\nendstream"); + body + } + + #[test] + fn parses_bfchar_and_both_bfrange_forms() { + let data = b"2 beginbfchar\n<0003> <0020>\n<0332> <00660066>\nendbfchar\n\ + 2 beginbfrange\n<0010> <0012> <0041>\n<0020> <0021> [<0061> ]\nendbfrange\n"; + + assert_eq!( + parse_cmap(data), + cmap(&[ + (0x03, " "), + (0x10, "A"), + (0x11, "B"), + (0x12, "C"), + (0x20, "a"), + (0x21, "\u{1F600}"), + (0x0332, "ff"), + ]) + ); + } + + #[test] + fn written_cmap_parses_back() { + let entries: Vec<(u16, &str)> = (0..250).map(|code| (code, "x")).collect(); + let mut original = cmap(&entries); + original.insert(0x0332, "ffi".encode_utf16().collect()); + + let written = write_cmap(&original); + + assert_eq!(parse_cmap(&written), original); + assert!(String::from_utf8(written) + .unwrap() + .contains("100 beginbfchar")); + } + + #[test] + fn appends_fixed_cmap_as_incremental_update() { + let skia_cmap = write_cmap(&cmap(&[(0x03, " "), (0x0332, "\0"), (0x0335, "\u{FB02}")])); + let compressed = miniz_oxide::deflate::compress_to_vec_zlib(&skia_cmap, 6); + let pdf = build_pdf(&[ + b"<>".to_vec(), + b"<>" + .to_vec(), + stream_object("/Filter /FlateDecode", &compressed), + ]); + let texts = GlyphTexts::default() + .with_glyph("My Font", 0x0332, "ffi") + .with_glyph("My Font", 0x0335, "fl") + .with_glyph("My Font", 0x03, " "); + + let fixed = fix_to_unicode(pdf.clone(), &texts); + + assert!( + fixed.starts_with(&pdf), + "original bytes must stay untouched" + ); + assert_eq!( + to_unicode_of_only_font(&fixed), + cmap(&[(0x03, " "), (0x0332, "ffi"), (0x0335, "fl")]) + ); + let trailer = parse_xref(&fixed).unwrap().trailer; + assert_eq!(dict_ref(&trailer, b"/Root"), Some(1)); + assert_eq!(dict_uint(&trailer, b"/Size"), Some(4)); + } + + #[test] + fn leaves_pdf_alone_when_nothing_changes() { + let pdf = build_pdf(&[ + b"<>".to_vec(), + b"<>".to_vec(), + stream_object("", &write_cmap(&cmap(&[(0x03, " ")]))), + ]); + let texts = GlyphTexts::default().with_glyph("Other", 0x0332, "ffi"); + + assert_eq!(fix_to_unicode(pdf.clone(), &texts), pdf); + } + + /// EB Garamond forms standard ligatures (fi, fl, ffi, ...). + const LIGATURE_FONT: &[u8] = + include_bytes!("../../../../frontend/playwright/data/render-wasm/assets/ebgaramond.ttf"); + + fn add_text(pool: &mut ShapesPool, id: Uuid, family: FontFamily, text: &str) { + let mut content = TextContent::new( + skia::Rect::from_xywh(0.0, 0.0, 600.0, 100.0), + GrowType::Fixed, + ); + let span = TextSpan::new( + text.to_string(), + family, + 40.0, + 1.2, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![Fill::Solid(SolidColor(skia::Color::BLACK))], + ); + content.add_paragraph(Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + vec![span], + )); + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 600.0, 100.0); + shape.set_shape_type(Type::Text(content)); + } + + /// The text a PDF reader copies from the page: every glyph code drawn, + /// read through the font's final ToUnicode map. + fn copied_text(pdf: &[u8]) -> String { + let offsets = latest_offsets(pdf); + let page = offsets + .values() + .filter_map(|offset| read_object(pdf, *offset)) + .find(|o| dict_name(o.dict, b"/Type") == Some(b"Page".as_slice())) + .expect("page"); + let contents = dict_ref(page.dict, b"/Contents").expect("contents"); + let content = stream_data(&object(pdf, &offsets, contents)).expect("content"); + let to_unicode = to_unicode_of_only_font(pdf); + CMapTokens { + data: &content, + pos: 0, + } + .filter_map(|token| match token { + Token::Hex(codes) => Some(codes), + _ => None, + }) + .flat_map(|codes| { + codes + .chunks(2) + .filter_map(to_code) + .map(|code| String::from_utf16_lossy(&to_unicode[&code])) + .collect::>() + }) + .collect() + } + + #[test] + fn exported_ligatures_copy_back_as_typed() { + let text = "office fluffy affinity"; + let family = FontFamily::new(uuid_from_u32_quartet(0, 0, 0, 99), 400, FontStyle::Normal); + let id = uuid_from_u32_quartet(0, 0, 0, 1); + let mut pool = ShapesPool::new(); + add_text(&mut pool, id, family, text); + + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + resources + .fonts + .add(family, LIGATURE_FONT, false, false) + .expect("ligature font"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let pdf = super::super::render_to_pdf(&mut resources, &id, &pool, 1.0).expect("pdf"); + + let trailer = parse_xref(&pdf).expect("xref").trailer; + assert!( + dict_uint(&trailer, b"/Prev").is_some(), + "the ligatures should need a ToUnicode fix" + ); + assert_eq!(copied_text(&pdf), text); + } +} From c0450a77c37ad85e92f6f7019ee25298abfa627e Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 20 Oct 2026 09:49:05 +0200 Subject: [PATCH 098/167] :bug: Limit board guide hit testing to the board frame (#12460) WASM find_guide_at only checked distance on the guide axis, so board-bound guides stayed interactive outside their board. Respect frame_range in GuidePool hit testing so interaction matches the drawn solid segment and the SVG hit area. Closes #12322 --- render-wasm/src/state/ui.rs | 80 +++++++++++++++++++++++++++++++++++-- render-wasm/src/ui.rs | 18 +++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/render-wasm/src/state/ui.rs b/render-wasm/src/state/ui.rs index d29645cc02..6878875c5e 100644 --- a/render-wasm/src/state/ui.rs +++ b/render-wasm/src/state/ui.rs @@ -36,8 +36,11 @@ impl GuidePool { } let world_tolerance = tolerance / zoom; - let vertical = Self::find_closest_in_axis(&self.vertical, x, world_tolerance); - let horizontal = Self::find_closest_in_axis(&self.horizontal, y, world_tolerance); + // Vertical guides run along y; horizontal along x. Pass the along-line + // coordinate so board-bound guides are only hittable inside their + // frame_range (matching the solid drawn segment). + let vertical = Self::find_closest_in_axis(&self.vertical, x, y, world_tolerance); + let horizontal = Self::find_closest_in_axis(&self.horizontal, y, x, world_tolerance); match (vertical, horizontal) { (Some(v), Some(h)) => { @@ -53,7 +56,12 @@ impl GuidePool { } } - fn find_closest_in_axis(guides: &[Guide], coord: f32, world_tolerance: f32) -> Option<&Guide> { + fn find_closest_in_axis( + guides: &[Guide], + coord: f32, + along: f32, + world_tolerance: f32, + ) -> Option<&Guide> { if guides.is_empty() { return None; } @@ -66,6 +74,9 @@ impl GuidePool { for candidate_idx in [idx.wrapping_sub(1), idx] { if candidate_idx < guides.len() { let guide = &guides[candidate_idx]; + if !guide.contains_along(along) { + continue; + } let dist = (guide.position() - coord).abs(); if dist <= world_tolerance && dist <= closest_dist { closest_dist = dist; @@ -183,6 +194,24 @@ mod tests { ) } + fn vertical_board_guide(x: f32, y0: f32, y1: f32, index: usize) -> Guide { + Guide::new( + GuideKind::Vertical(x), + Color::BLACK, + Some(index), + Some((y0, y1)), + ) + } + + fn horizontal_board_guide(y: f32, x0: f32, x1: f32, index: usize) -> Guide { + Guide::new( + GuideKind::Horizontal(y), + Color::BLACK, + Some(index), + Some((x0, x1)), + ) + } + fn pool_with(guides: Vec) -> GuidePool { let mut pool = GuidePool::new(); pool.set(guides); @@ -264,4 +293,49 @@ mod tests { assert!(pool.find_at(104.0, 0.0, 2.0, 8.0).is_some()); assert!(pool.find_at(105.0, 0.0, 2.0, 8.0).is_none()); } + + #[test] + fn find_at_hits_vertical_board_guide_inside_frame_range() { + let pool = pool_with(vec![vertical_board_guide(100.0, 0.0, 200.0, 0)]); + let guide = pool.find_at(102.0, 50.0, 1.0, 8.0).unwrap(); + assert_eq!(guide.index, 0); + } + + #[test] + fn find_at_misses_vertical_board_guide_outside_frame_range() { + let pool = pool_with(vec![vertical_board_guide(100.0, 0.0, 200.0, 0)]); + assert!(pool.find_at(102.0, 250.0, 1.0, 8.0).is_none()); + } + + #[test] + fn find_at_hits_horizontal_board_guide_inside_frame_range() { + let pool = pool_with(vec![horizontal_board_guide(200.0, 0.0, 300.0, 1)]); + let guide = pool.find_at(50.0, 203.0, 1.0, 8.0).unwrap(); + assert_eq!(guide.index, 1); + } + + #[test] + fn find_at_misses_horizontal_board_guide_outside_frame_range() { + let pool = pool_with(vec![horizontal_board_guide(200.0, 0.0, 300.0, 1)]); + assert!(pool.find_at(350.0, 203.0, 1.0, 8.0).is_none()); + } + + #[test] + fn find_at_free_guide_still_hits_outside_any_board() { + let pool = pool_with(vec![vertical_guide(100.0, 0)]); + let guide = pool.find_at(102.0, 999.0, 1.0, 8.0).unwrap(); + assert_eq!(guide.index, 0); + } + + #[test] + fn find_at_misses_hidden_board_guide_empty_range() { + // Serializer uses [Infinity, Infinity] for rotated / non-root boards. + let pool = pool_with(vec![vertical_board_guide( + 100.0, + f32::INFINITY, + f32::INFINITY, + 0, + )]); + assert!(pool.find_at(100.0, 50.0, 1.0, 8.0).is_none()); + } } diff --git a/render-wasm/src/ui.rs b/render-wasm/src/ui.rs index d0e47c18e7..c7dfe0d8af 100644 --- a/render-wasm/src/ui.rs +++ b/render-wasm/src/ui.rs @@ -55,4 +55,22 @@ impl Guide { pub fn position(&self) -> f32 { self.kind.value() } + + /// Whether `along` (the coordinate along the guide line: y for vertical + /// guides, x for horizontal) falls inside this guide's interactive + /// segment. Free guides (`frame_range = None`) accept any value; board- + /// bound guides only accept points inside their clipped range. + pub fn contains_along(&self, along: f32) -> bool { + match self.frame_range { + None => true, + Some((start, end)) => { + let (lo, hi) = if start <= end { + (start, end) + } else { + (end, start) + }; + along >= lo && along <= hi + } + } + } } From ecfa350470bf92ac2a8ac5e5696c58d155264092 Mon Sep 17 00:00:00 2001 From: Shreyash Agare Date: Tue, 20 Oct 2026 14:57:31 +0530 Subject: [PATCH 099/167] :sparkles: Auto-scroll sortable lists while dragging near their edges (#11595) * :sparkles: Auto-scroll sortable lists while dragging near their edges Native drag and drop swallows wheel events, so a long list could only be scrolled mid-drag by hitting the browser's narrow auto-scroll strip at the very edge of the container. use-sortable now scrolls the item's nearest scrollable ancestor on every animation frame while the pointer sits within 56px of its top or bottom edge, ramping the speed up to 18px per frame as the pointer gets closer. Every list built on the hook (layers, sitemap, option rows, gradients, token sets) gets it without any change on their side. * :sparkles: Cap auto-scroll zones and test the scroll loop Each edge zone now takes at most a quarter of the container, so the middle half of a short list, like the pages panel, never scrolls. The scroll container is found once when the drag starts. The speed is scaled by the time between frames, so 120 Hz screens no longer scroll twice as fast. Starting a drag also stops any loop left over from a drag whose end event was lost. auto-scroll-speed is now private. New tests cover the running loop: only one loop starts, it stops on drag end, when the speed goes back to zero and after 500ms without dragover, and the frame time scaling. AI-assisted-by: claude-opus-5-5 --------- Co-authored-by: Shreyash Agare Co-authored-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com> Co-authored-by: Andrey Antukh --- frontend/src/app/main/ui/hooks.cljs | 122 +++++++++++- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/sortable_auto_scroll_test.cljs | 178 ++++++++++++++++++ 3 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 frontend/test/frontend_tests/ui/sortable_auto_scroll_test.cljs diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 3225932153..5bc113bab9 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -87,6 +87,122 @@ ;; Do not remove commented out lines, they are useful to debug events when ;; things go weird. +;; Native drag and drop swallows wheel events while a drag is in progress, so +;; scrolling a long list mid-drag is done here: when the pointer gets near the +;; top or bottom edge of the scrollable container, it is scrolled on every +;; animation frame, faster the closer the pointer is to the edge. + +(def ^:private auto-scroll-zone-size 56) +(def ^:private auto-scroll-max-speed 18) +(def ^:private auto-scroll-stale-ms 500) + +;; Speeds are given in pixels per frame at 60 Hz and scaled by the real time +;; between frames, so the scroll goes as fast on a 120 Hz screen. +(def ^:private auto-scroll-frame-ms (/ 1000 60)) +(def ^:private auto-scroll-max-frame-ms 100) + +;; State of the current drag: the scroll container, found once when the drag +;; starts, the scroll speed, the pending animation frame and when the pointer +;; was last seen over the list. +(defonce ^:private auto-scroll-state (atom nil)) + +(defn- scrollable-ancestor + [node] + (loop [node (some-> ^js node .-parentElement)] + (when (some? node) + (let [overflow (-> (dom/get-computed-styles node) + (dom/get-property-value "overflow-y"))] + (if (and (or (= overflow "auto") (= overflow "scroll")) + (> (.-scrollHeight ^js node) (.-clientHeight ^js node))) + node + (recur (.-parentElement ^js node))))))) + +(defn- auto-scroll-speed + "Pixels to scroll on each frame given the pointer position, negative + upwards. Zero when the pointer is outside the edge zones. The zones take + at most a quarter of the container each, so the middle half of a short + list never scrolls." + [{:keys [top bottom]} pointer-y] + (let [zone (mth/min auto-scroll-zone-size (/ (- bottom top) 4)) + top-distance (- pointer-y top) + bottom-distance (- bottom pointer-y) + ramp (fn [distance] + (-> (- zone distance) + (/ zone) + (* auto-scroll-max-speed) + (mth/ceil)))] + (cond + (or (neg? top-distance) (neg? bottom-distance)) 0 + (< top-distance zone) (- (ramp top-distance)) + (< bottom-distance zone) (ramp bottom-distance) + :else 0))) + +(defn- stop-auto-scroll! + "Stops the scroll loop, keeping the scroll container for the rest of the + drag." + [] + (some-> (:frame @auto-scroll-state) ts/cancel-af!) + (swap! auto-scroll-state dissoc :frame :last-frame-at :offset)) + +(defn- end-auto-scroll! + [] + (stop-auto-scroll!) + (reset! auto-scroll-state nil)) + +(defn- start-auto-scroll! + "Remembers the scroll container of the dragged `node`. A loop left over + from a previous drag whose end was lost is stopped first." + [node] + (stop-auto-scroll!) + (reset! auto-scroll-state {:element (scrollable-ancestor node)})) + +(defn- auto-scroll-tick + [timestamp] + (let [{:keys [element speed updated-at last-frame-at offset]} @auto-scroll-state] + ;; The stale check stops the loop if the drag ended without us being + ;; notified, for example when a dragend event is lost. + (if (or (nil? element) + (> (- timestamp updated-at) auto-scroll-stale-ms)) + (end-auto-scroll!) + (let [;; Capped so a late frame does not make the list jump. + elapsed (if (some? last-frame-at) + (mth/min (- timestamp last-frame-at) auto-scroll-max-frame-ms) + auto-scroll-frame-ms) + ;; Keep the fraction of a pixel for the next frame, otherwise + ;; slow speeds would be lost on fast screens. + offset (+ (or offset 0) (* speed (/ elapsed auto-scroll-frame-ms))) + step (js/Math.trunc offset)] + (when-not (zero? step) + (dom/scroll-by! element 0 step)) + (swap! auto-scroll-state assoc + :offset (- offset step) + :last-frame-at timestamp + :frame (ts/raf auto-scroll-tick)))))) + +(defn- update-auto-scroll! + "Starts, updates or stops the auto scroll of the container of `node` + depending on how close the pointer is to its edges." + [node pointer-y] + (let [element (:element @auto-scroll-state) + ;; The drag may have started outside this list, or the loop ended + ;; on the stale check: find the container of `node` again. + element (if (dom/child? node element) + element + (scrollable-ancestor node)) + speed (if (some? element) + (auto-scroll-speed (dom/get-bounding-rect element) pointer-y) + 0)] + (swap! auto-scroll-state assoc + :element element + :speed speed + :updated-at (js/performance.now)) + (cond + (zero? speed) + (stop-auto-scroll!) + + (nil? (:frame @auto-scroll-state)) + (swap! auto-scroll-state assoc :frame (ts/raf auto-scroll-tick))))) + (defn use-sortable [& {:keys [data-type data on-drop on-drag on-hold disabled detect-center? draggable?] :or {draggable? true} @@ -123,6 +239,7 @@ (dnd/set-data! event data-type data) (dnd/set-drag-image! event (dnd/invisible-image)) (dnd/set-allowed-effect! event "move") + (start-auto-scroll! (mf/ref-val ref)) (when (fn? on-drag) (on-drag data))))) @@ -148,7 +265,8 @@ (subscribe-to-drag-end) ;; (dnd/trace event data "drag-over") (let [side (dnd/drop-side event detect-center?)] - (swap! state assoc :over side))))) + (swap! state assoc :over side))) + (update-auto-scroll! (mf/ref-val ref) (.-clientY event)))) on-drag-leave (fn [event] @@ -162,6 +280,7 @@ ;; (dnd/trace event data "drop") (let [side (dnd/drop-side event detect-center?) drop-data (dnd/get-data event data-type)] + (end-auto-scroll!) (cleanup) (rx/push! global-drag-end nil) (when (fn? on-drop) @@ -171,6 +290,7 @@ (fn [event] (dom/stop-propagation event) ;; (dnd/trace event data "drag-end") + (end-auto-scroll!) (rx/push! global-drag-end nil) (cleanup)) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e5d2a3c11a..78743cea8b 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -136,6 +136,7 @@ [frontend-tests.ui.shape-filters-test] [frontend-tests.ui.shortcuts-labels-test] [frontend-tests.ui.sidebar-scroll-test] + [frontend-tests.ui.sortable-auto-scroll-test] [frontend-tests.ui.stroke-menu-test] [frontend-tests.ui.text-attrs-multiple-test] [frontend-tests.ui.workspace-libraries-test] @@ -296,6 +297,7 @@ 'frontend-tests.ui.shape-filters-test 'frontend-tests.ui.shortcuts-labels-test 'frontend-tests.ui.sidebar-scroll-test + 'frontend-tests.ui.sortable-auto-scroll-test 'frontend-tests.ui.stroke-menu-test 'frontend-tests.ui.text-attrs-multiple-test 'frontend-tests.ui.workspace-libraries-test diff --git a/frontend/test/frontend_tests/ui/sortable_auto_scroll_test.cljs b/frontend/test/frontend_tests/ui/sortable_auto_scroll_test.cljs new file mode 100644 index 0000000000..fb4d8348ac --- /dev/null +++ b/frontend/test/frontend_tests/ui/sortable_auto_scroll_test.cljs @@ -0,0 +1,178 @@ +;; 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.sortable-auto-scroll-test + (:require + [app.common.math :as mth] + [app.main.ui.hooks :as hooks] + [app.util.timers :as ts] + [cljs.test :as t :include-macros true])) + +(def ^:private auto-scroll-speed @#'hooks/auto-scroll-speed) +(def ^:private auto-scroll-state @#'hooks/auto-scroll-state) +(def ^:private start-auto-scroll! @#'hooks/start-auto-scroll!) +(def ^:private update-auto-scroll! @#'hooks/update-auto-scroll!) +(def ^:private end-auto-scroll! @#'hooks/end-auto-scroll!) + +(t/use-fixtures :each {:before #(reset! auto-scroll-state nil) + :after #(reset! auto-scroll-state nil)}) + +;; A 400px tall list, with the 56px edge zones at [0, 56] and [344, 400]. +(def ^:private rect {:top 0 :bottom 400}) + +(t/deftest auto-scroll-speed-test + (t/testing "does not scroll when the pointer is away from the edges" + (t/is (= 0 (auto-scroll-speed rect 200))) + (t/is (= 0 (auto-scroll-speed rect 56))) + (t/is (= 0 (auto-scroll-speed rect 344)))) + + (t/testing "does not scroll when the pointer is outside the container" + (t/is (= 0 (auto-scroll-speed rect -10))) + (t/is (= 0 (auto-scroll-speed rect 410)))) + + (t/testing "scrolls upwards near the top edge and downwards near the bottom" + (t/is (neg? (auto-scroll-speed rect 10))) + (t/is (pos? (auto-scroll-speed rect 390)))) + + (t/testing "scrolls faster the closer the pointer gets to the edge" + (t/is (< (auto-scroll-speed rect 5) (auto-scroll-speed rect 50) 0)) + (t/is (< 0 (auto-scroll-speed rect 350) (auto-scroll-speed rect 395))))) + +(t/deftest auto-scroll-speed-short-list-test + ;; In a 100px list each zone is capped at 25px, a quarter of the height. + (let [rect {:top 0 :bottom 100}] + (t/testing "the middle half of the list never scrolls" + (t/is (= 0 (auto-scroll-speed rect 25))) + (t/is (= 0 (auto-scroll-speed rect 50))) + (t/is (= 0 (auto-scroll-speed rect 75)))) + + (t/testing "the capped zones still scroll" + (t/is (neg? (auto-scroll-speed rect 10))) + (t/is (pos? (auto-scroll-speed rect 90)))))) + +(defn- scroll-container + "A fake 400px tall scroll container that records the scrolled pixels." + [scrolled] + #js {:contains (fn [_] true) + :getBoundingClientRect (fn [] #js {:top 0 :bottom 400}) + :scrollBy (fn [_ y] (swap! scrolled conj y))}) + +(defn- start-drag! + [element] + (reset! auto-scroll-state {:element element})) + +(t/deftest start-auto-scroll-without-container-test + (start-auto-scroll! #js {:parentElement nil}) + (t/is (= {:element nil} @auto-scroll-state))) + +(t/deftest start-auto-scroll-stops-previous-loop-test + ;; The previous drag ended without a dragend, so its loop is still running. + (let [cancelled (atom [])] + (with-redefs [ts/cancel-af! (fn [frame] (swap! cancelled conj frame))] + (reset! auto-scroll-state {:element #js {} :frame 9}) + (start-auto-scroll! #js {:parentElement nil}) + (t/is (= [9] @cancelled)) + (t/is (= {:element nil} @auto-scroll-state))))) + +(t/deftest update-auto-scroll-starts-one-loop-test + (let [scheduled (atom []) + element (scroll-container (atom []))] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) (count @scheduled))] + (start-drag! element) + (update-auto-scroll! #js {} 10) + (update-auto-scroll! #js {} 5) + (update-auto-scroll! #js {} 1) + (t/is (= 1 (count @scheduled)) "dragover events reuse the running loop") + (t/is (= 1 (:frame @auto-scroll-state))) + (t/is (= (auto-scroll-speed rect 1) (:speed @auto-scroll-state)) + "the loop uses the latest pointer position")))) + +(t/deftest update-auto-scroll-away-from-edges-does-not-start-test + (let [scheduled (atom []) + element (scroll-container (atom []))] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) 1)] + (start-drag! element) + (update-auto-scroll! #js {} 200) + (t/is (empty? @scheduled))))) + +(t/deftest auto-scroll-tick-scrolls-and-continues-test + (let [scheduled (atom []) + scrolled (atom []) + element (scroll-container scrolled)] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) (count @scheduled))] + (start-drag! element) + (update-auto-scroll! #js {} 0) + ((last @scheduled) (js/performance.now)) + (t/is (= [(auto-scroll-speed rect 0)] @scrolled)) + (t/is (= 2 (count @scheduled)) "the next frame is scheduled") + (t/is (= 2 (:frame @auto-scroll-state)))))) + +(t/deftest auto-scroll-stops-when-speed-goes-back-to-zero-test + (let [scheduled (atom []) + cancelled (atom []) + element (scroll-container (atom []))] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) 7) + ts/cancel-af! (fn [frame] (swap! cancelled conj frame))] + (start-drag! element) + (update-auto-scroll! #js {} 10) + (update-auto-scroll! #js {} 200) + (t/is (= [7] @cancelled) "the pending frame is cancelled") + (t/is (nil? (:frame @auto-scroll-state))) + (t/is (identical? element (:element @auto-scroll-state)) + "the container is kept for the rest of the drag") + + (update-auto-scroll! #js {} 10) + (t/is (= 2 (count @scheduled)) "it starts again near the edge")))) + +(t/deftest auto-scroll-stops-on-drag-end-test + (let [cancelled (atom []) + element (scroll-container (atom []))] + (with-redefs [ts/raf (fn [_] 3) + ts/cancel-af! (fn [frame] (swap! cancelled conj frame))] + (start-drag! element) + (update-auto-scroll! #js {} 10) + (end-auto-scroll!) + (t/is (= [3] @cancelled)) + (t/is (nil? @auto-scroll-state))))) + +(t/deftest auto-scroll-stale-guard-test + (let [scheduled (atom []) + scrolled (atom []) + element (scroll-container scrolled)] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) 1) + ts/cancel-af! (fn [_])] + (start-drag! element) + (update-auto-scroll! #js {} 10) + (let [updated-at (:updated-at @auto-scroll-state)] + ;; No dragover for more than 500ms, as when a dragend is lost. + ((last @scheduled) (+ updated-at 501))) + (t/is (empty? @scrolled) "it does not scroll on a stale frame") + (t/is (= 1 (count @scheduled)) "no further frames are scheduled") + (t/is (nil? @auto-scroll-state))))) + +(defn- scroll-for-two-60hz-frames + "Runs the loop for the time of two 60 Hz frames, at `frames` frames per + 60 Hz frame, and returns the scrolled pixels. The first frame has no + previous one to measure from, so it counts as a full 60 Hz frame." + [frames] + (let [scheduled (atom []) + scrolled (atom []) + frame-ms (/ 1000 60 frames)] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) 1)] + (reset! auto-scroll-state nil) + (start-drag! (scroll-container scrolled)) + (update-auto-scroll! #js {} 0) + (let [start (:updated-at @auto-scroll-state)] + (dotimes [i (inc frames)] + ((last @scheduled) (+ start (* i frame-ms)))))) + (reduce + @scrolled))) + +(t/deftest auto-scroll-speed-does-not-depend-on-refresh-rate-test + (let [at-60hz (scroll-for-two-60hz-frames 1) + at-120hz (scroll-for-two-60hz-frames 2)] + (t/is (neg? at-60hz)) + (t/is (<= (mth/abs (- at-120hz at-60hz)) 1) + "a 120 Hz screen scrolls the same distance in the same time"))) From a5dac030735d21da63a9c0dd54de7e5702c98248 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 20 Oct 2026 14:08:40 +0200 Subject: [PATCH 100/167] :sparkles: Add organization SSO and invitation audit events for nitrate (#12461) --- backend/src/app/auth/oidc.clj | 4 +- .../src/app/loggers/audit/consumer_task.clj | 7 ++++ backend/src/app/rpc/commands/verify_token.clj | 35 +++++++++------- backend/src/app/rpc/management/nitrate.clj | 14 ++++++- backend/test/backend_tests/auth_oidc_test.clj | 32 +++++++++++++++ .../loggers_audit_consumer_task_test.clj | 3 +- .../rpc_management_nitrate_test.clj | 28 +++++++++++++ backend/test/backend_tests/rpc_team_test.clj | 41 +++++++++++++++++-- 8 files changed, 141 insertions(+), 23 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 05121e7479..be8c9078eb 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -1037,7 +1037,9 @@ (when (and session organization-id) (let [props (-> (or (:props session) {}) (update :sso assoc organization-id exp))] - (session/update-session (::session/manager cfg) (assoc session :props props)))) + (session/update-session (::session/manager cfg) (assoc session :props props)) + (submit-organization-sso-auth-event + cfg request (:profile-id session) organization-id "organization-sso-token-issued"))) (submit-organization-sso-auth-event cfg request (:profile-id session) organization-id "organization-sso-auth-succeeded") (redirect-response dest-url)) diff --git a/backend/src/app/loggers/audit/consumer_task.clj b/backend/src/app/loggers/audit/consumer_task.clj index 6c4986eed5..9fa05c5e70 100644 --- a/backend/src/app/loggers/audit/consumer_task.clj +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -208,10 +208,13 @@ (def ^:private event-names-for-admin-console #{"accept-organization-invitation" + "accept-organization-invitation-from" "accept-team-invitation" "accept-team-invitation-from" "add-member-to-organization" "add-team-to-organization" + "auditlog-exported" + "auditlog-guide-downloaded" "cancel-organization-invitation" "create-export-binfile-job" "create-file" @@ -238,12 +241,16 @@ "organization-sso-auth-failed" "organization-sso-auth-started" "organization-sso-auth-succeeded" + "organization-sso-token-issued" + "organization-sso-token-revoked" "permanently-delete-team-files" "remove-organization-team" "remove-team-from-organization" "rename-organization" "rename-project" "restore-deleted-team-files" + "sso-config-deleted" + "sso-config-tested" "success-activate-organization-sso" "success-apply-organization-sso-changes" "update-organization-invitation" diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 26746382c8..4a0b84981f 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -302,21 +302,26 @@ ;; for the same name, and tied the record to the browser finishing ;; the flow. Everything the copy carried is computed above. (when organization-id-on-add - (audit/submit cfg (-> (audit/event-from-rpc-params params) - (assoc :name "accept-organization-invitation") - (assoc :props - (-> props - (assoc :organization-id organization-id-on-add) - (assoc :invited-by (:created-by invitation)) - (assoc :profile-id (:id profile)) - (assoc :profile-email (:email profile)) - (assoc :organization-member-add-source - organization-add-source) - (assoc :belongs-to-team-on-add - (boolean team-id)) - (assoc :organization-member-count-before - organization-member-count-before) - (audit/clean-props)))))) + (let [org-props (-> props + (assoc :organization-id organization-id-on-add + :invited-by (:created-by invitation) + :profile-id (:id profile) + :profile-email (:email profile) + :organization-member-add-source + organization-add-source + :belongs-to-team-on-add + (boolean team-id) + :organization-member-count-before + organization-member-count-before) + (audit/clean-props))] + (audit/submit cfg (-> (audit/event-from-rpc-params params) + (assoc :name "accept-organization-invitation" + :props org-props))) + (when-let [created-by (:created-by invitation)] + (audit/submit cfg (-> (audit/event-from-rpc-params params) + (assoc :profile-id created-by + :name "accept-organization-invitation-from" + :props org-props)))))) (cond-> (assoc claims :state :created diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index ac18a6c10d..c8c4f83e8c 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -1060,10 +1060,20 @@ RETURNING id, deleted_at;") [:updated-props ::sm/boolean] [:announce-activation ::sm/boolean]] ::rpc/auth false} - [{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props announce-activation]}] + [{:keys [::db/pool] :as cfg} + {:keys [organization-id updated-props announce-activation] :as params}] (when updated-props (rpc/invalidate-organization-sso-cache-by-organization! organization-id) - (session/clear-organization-sso-sessions! pool organization-id)) + (session/clear-organization-sso-sessions! pool organization-id) + (let [request (-> params meta ::http/request) + context (-> (audit/prepare-context-from-request request) + (assoc :request-id (::rpc/request-id params)))] + (audit/submit cfg {:type "action" + :name "organization-sso-token-revoked" + :profile-id uuid/zero + :ip-addr (::rpc/ip-addr params) + :props {:organization-id organization-id} + :context (d/without-nils context)}))) (notifications/notify-organization-change-sso cfg organization-id) (when announce-activation (neh/send-organization-setup-sso-emails! cfg organization-id)) diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index 90e1aa18f4..55bab1a134 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.time :as ct] + [app.common.uuid :as uuid] [app.config :as cf] [app.http.session :as session] [app.setup :as-alias setup] @@ -524,6 +525,7 @@ (t/is (.contains loc "error=unable-to-auth"))))))) (t/deftest organization-sso-callback-success-emits-succeeded + ;; No session on the request → auth-succeeded only (no token-issued). (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" :organization-id test-organization-id}) @@ -538,6 +540,36 @@ (t/is (= ["organization-sso-auth-succeeded"] (mapv :name @events))) (t/is (= test-organization-id (get-in (first @events) [:props :organization-id]))))))) +(t/deftest organization-sso-callback-success-with-session-emits-token-issued + (let [manager (session/inmemory-manager) + cfg (assoc (dissoc base-cfg :app.email/blacklist :app.email/whitelist) + ::session/manager manager) + state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" + :organization-id test-organization-id}) + request (default-request cfg :state state) + events (atom []) + sess {:id (uuid/next) + :profile-id test-profile-id + :props {}}] + (with-redefs [app.nitrate/call (constantly {:active true}) + app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"}) + app.auth.oidc/get-info (constantly {}) + app.http.session/get-session (constantly sess) + app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))] + (let [result (#'oidc/callback-handler cfg request) + names (mapv :name @events) + stored (session/read-session manager (:id sess))] + (t/is (= "https://penpot.example.com/#/workspace" (redirect-location result))) + (t/is (= ["organization-sso-token-issued" + "organization-sso-auth-succeeded"] + names)) + (t/is (= test-organization-id + (get-in (first @events) [:props :organization-id]))) + (t/is (= test-profile-id (:profile-id (first @events)))) + (t/is (= #{:organization-id} + (set (keys (:props (first @events)))))) + (t/is (contains? (get-in stored [:props :sso]) test-organization-id)))))) + (t/deftest organization-sso-callback-error-emits-failed (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" diff --git a/backend/test/backend_tests/loggers_audit_consumer_task_test.clj b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj index 146f0da10b..1fb3efd9e5 100644 --- a/backend/test/backend_tests/loggers_audit_consumer_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj @@ -113,12 +113,13 @@ http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") (insert-audit-row! (:id prof) "create-organization-attribute") + (insert-audit-row! (:id prof) "auditlog-exported") (insert-audit-row! (:id prof) "unrelated-event") (run-both! {:uri archive-uri}) (t/is (:called? @admin-console-mock)) (t/is (:called? @http-mock)) (let [[_ _ params] (:call-args @admin-console-mock)] - (t/is (= #{"create-project" "create-organization-attribute"} + (t/is (= #{"create-project" "create-organization-attribute" "auditlog-exported"} (into #{} (map :name) (:events params))))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index be36f84680..500b241279 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -576,6 +576,34 @@ (t/is (= :organization-change-sso (:type msg))) (t/is (= organization-id (:organization-id msg)))))) +(t/deftest notify-organization-sso-change-emits-token-revoked-when-updated-props + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [organization-id (uuid/random) + out (with-redefs [mbus/pub! (constantly nil)] + (th/management-command! {::th/type :notify-organization-sso-change + :organization-id organization-id + :updated-props true + :announce-activation false})) + revoked (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "organization-sso-token-revoked" (:name %))))] + (t/is (th/success? out)) + (t/is (= 1 (count revoked))) + (t/is (= uuid/zero (:profile-id (first revoked)))) + (t/is (= {:organization-id organization-id} (:props (first revoked)))))) + + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [out (with-redefs [mbus/pub! (constantly nil)] + (th/management-command! {::th/type :notify-organization-sso-change + :organization-id (uuid/random) + :updated-props false + :announce-activation false})) + revoked (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "organization-sso-token-revoked" (:name %))))] + (t/is (th/success? out)) + (t/is (empty? revoked))))) + (t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-organization-events ;; --- Deferred owned-organizations: nil during setup, filled before RPC --- (let [owned-organizations-ref (atom nil)] diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index ed0a12fb5b..b1bab1acb6 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -527,7 +527,8 @@ (t/is (th/success? out)) (t/is (not (contains? (:result out) :organization-invitation-audit))))) - (let [event (organization-event)] + (let [event (organization-event) + from-event (first (emitted-events "accept-organization-invitation-from"))] (t/is (= organization-id (get-in event [:props :organization-id]))) (t/is (= (:id invitee) (get-in event [:props :profile-id]))) (t/is (= (:id inviter) (get-in event [:props :invited-by]))) @@ -541,11 +542,39 @@ (t/is (not (contains? (:props event) :invitation-origin))) (t/is (not (contains? (:context event) :event-origin))) (t/is (= 1 (count (emitted-events "accept-organization-invitation")))) + (t/is (= 1 (count (emitted-events "accept-organization-invitation-from")))) + (t/is (= (:id inviter) (:profile-id from-event))) + (t/is (= (:props event) (:props from-event))) (t/is (not-any? #(contains? #{"accept-team-invitation" "accept-team-invitation-from"} (:name (second %))) (:call-args-list @audit-mock)))) + ;; Legacy org invitation with no :created-by → invitee event only, no -from. + (th/reset-mock! audit-mock) + (db/insert! (:app.db/pool th/*system*) + :team-invitation + {:org-id organization-id + :email-to (:email invitee) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call + (fn [_cfg method _params] + (case method + :get-organization-membership {:organization-id organization-id + :is-member false} + :get-organization-members [(:id inviter) (uuid/random) (uuid/random)] + nil)) + teams/initialize-user-in-organization + (fn [& _] default-team-id)] + (let [out (verify! direct-token)] + (t/is (th/success? out)))) + + (t/is (= 1 (count (emitted-events "accept-organization-invitation")))) + (t/is (empty? (emitted-events "accept-organization-invitation-from"))) + (th/reset-mock! audit-mock) (db/insert! (:app.db/pool th/*system*) :team-invitation @@ -569,8 +598,9 @@ (t/is (th/success? out)) (t/is (not (contains? (:result out) :organization-invitation-audit))))) - (let [events (mapv second (:call-args-list @audit-mock)) - event (organization-event)] + (let [events (mapv second (:call-args-list @audit-mock)) + event (organization-event) + org-from-event (first (emitted-events "accept-organization-invitation-from"))] (t/is (some #(= "accept-team-invitation" (:name %)) events)) (t/is (some #(= "accept-team-invitation-from" (:name %)) events)) (t/is (= (:id team) (get-in event [:props :team-id]))) @@ -583,7 +613,10 @@ (t/is (= 5 (get-in event [:props :organization-member-count-before]))) (t/is (not (contains? (:props event) :invitation-origin))) (t/is (not (contains? (:context event) :event-origin))) - (t/is (= 1 (count (emitted-events "accept-organization-invitation"))))) + (t/is (= 1 (count (emitted-events "accept-organization-invitation")))) + (t/is (= 1 (count (emitted-events "accept-organization-invitation-from")))) + (t/is (= (:id inviter) (:profile-id org-from-event))) + (t/is (= (:props event) (:props org-from-event)))) (let [from-event (first (emitted-events "accept-team-invitation-from"))] (t/is (= (:id team) (get-in from-event [:props :team-id]))) From 41d95abc5c1067690d1d5eb111b587055286d233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 20 Oct 2026 14:09:01 +0200 Subject: [PATCH 101/167] :bug: Fix math symbols rendered as boxes in the new render engine (#12471) Noto Sans Symbols lacks most arrows and math operators. Fall back to Noto Sans Math for them, and to Noto Sans for letterlike symbols. Closes #12469 AI-assisted-by: claude-opus-5-5 --- common/src/app/common/fonts.cljs | 11 +++++-- common/test/common_tests/fonts_test.cljs | 37 ++++++++++++++++++++++++ common/test/common_tests/runner.cljc | 2 ++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 common/test/common_tests/fonts_test.cljs diff --git a/common/src/app/common/fonts.cljs b/common/src/app/common/fonts.cljs index 237a6b362b..ee96b6c9df 100644 --- a/common/src/app/common/fonts.cljs +++ b/common/src/app/common/fonts.cljs @@ -213,7 +213,12 @@ ;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF), ;; Legacy Computing Symbols (U+1FB00-1FBFF) :symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]" - :music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"}) + :music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]" + ;; Arrows, Mathematical Operators, Misc Mathematical Symbols A/B, Supplemental + ;; Arrows A/B, Supplemental Math Operators: the Symbols fonts lack most of them + :math #"[\u2190-\u21FF\u2200-\u22FF\u27C0-\u27FF\u2900-\u2AFF]" + ;; Letterlike Symbols (ℝ, ℕ, ℃, ™, ...) + :letterlike #"[\u2100-\u214F]"}) (defn contains-emoji? [text] (let [result (re-find emoji-pattern text)] @@ -292,7 +297,9 @@ :meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} :symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} :symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} - :music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}}) + :music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} + :math {:font-id "gfont-noto-sans-math" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} + :letterlike {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}}) (defn add-noto-fonts [fonts languages] (reduce (fn [acc lang] diff --git a/common/test/common_tests/fonts_test.cljs b/common/test/common_tests/fonts_test.cljs new file mode 100644 index 0000000000..36786ccb7c --- /dev/null +++ b/common/test/common_tests/fonts_test.cljs @@ -0,0 +1,37 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns common-tests.fonts-test + (:require + [app.common.fonts :as cfnt] + [clojure.test :as t])) + +(defn- fallback-font-ids + [text] + (->> (cfnt/collect-used-languages #{} text) + (cfnt/add-noto-fonts #{}) + (into #{} (map :font-id)))) + +(t/deftest math-symbols-use-noto-sans-math + (t/testing "arrows" + (t/is (contains? (fallback-font-ids "↳") "gfont-noto-sans-math")) + (t/is (contains? (fallback-font-ids "⇒") "gfont-noto-sans-math"))) + (t/testing "mathematical operators" + (t/is (contains? (fallback-font-ids "∀x ∈ A") "gfont-noto-sans-math")) + (t/is (contains? (fallback-font-ids "a ≠ b") "gfont-noto-sans-math"))) + (t/testing "supplemental arrows and operators" + (t/is (contains? (fallback-font-ids "⟹") "gfont-noto-sans-math")) + (t/is (contains? (fallback-font-ids "⤴") "gfont-noto-sans-math")) + (t/is (contains? (fallback-font-ids "⨁") "gfont-noto-sans-math")))) + +(t/deftest letterlike-symbols-use-noto-sans + (t/is (contains? (fallback-font-ids "x ∈ ℝ") "gfont-noto-sans")) + (t/is (contains? (fallback-font-ids "20 ℃") "gfont-noto-sans")) + (t/is (contains? (fallback-font-ids "Penpot™") "gfont-noto-sans"))) + +(t/deftest plain-text-does-not-use-noto-sans-math + (t/is (not (contains? (fallback-font-ids "Hello world") "gfont-noto-sans-math"))) + (t/is (not (contains? (fallback-font-ids "★ ☀") "gfont-noto-sans-math")))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 7a4c4243f4..e78fbf3da9 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -10,6 +10,7 @@ #?(:cljs [app.common.logging :as l]) #?(:cljs [clojure.string :as str]) #?(:cljs [clojure.tools.cli :refer [parse-opts]]) + #?(:cljs [common-tests.fonts-test]) #?(:cljs [goog.object :as gobj]) [clojure.test :as t] [common-tests.attrs-test] @@ -123,6 +124,7 @@ 'common-tests.data-test 'common-tests.exceptions-test 'common-tests.files-builder-test + #?(:cljs 'common-tests.fonts-test) 'common-tests.files-changes-test 'common-tests.files-migrations-0025-test 'common-tests.files-migrations-0026-test From ef68304cb88f72704fbc4ddc62b1f4eec58d39cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 20 Oct 2026 15:04:54 +0200 Subject: [PATCH 102/167] :bug: Draw masked group inner shadows in PDF and multi-element export (#12468) * :bug: Fix render_to_pdf call in the ToUnicode test The test passed a scale argument that render_to_pdf does not take, so the render-wasm test build failed to compile. * :bug: Draw masked group inner shadows in PDF and multi-element export The CPU renderer behind PDF and multi-element raster export drew a masked group's drop shadow but never its inner shadow, so the masked content came out flat. Wrap the masked content in a layer that draws the inner shadows over it, as the GPU single export does. * :bug: Export every visible inner shadow of a masked group The workspace skips shadows too small to see at the current zoom, to render faster. Export has no such need, so draw every visible inner shadow instead of filtering by scale. --- render-wasm/src/render/pdf/to_unicode.rs | 2 +- render-wasm/src/render/svg/tests.rs | 70 ++++++++++++++++++++++++ render-wasm/src/render/vector.rs | 15 +++++ render-wasm/src/shapes.rs | 41 ++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) diff --git a/render-wasm/src/render/pdf/to_unicode.rs b/render-wasm/src/render/pdf/to_unicode.rs index a724490bc7..ffb36f233e 100644 --- a/render-wasm/src/render/pdf/to_unicode.rs +++ b/render-wasm/src/render/pdf/to_unicode.rs @@ -781,7 +781,7 @@ mod tests { .add(family, LIGATURE_FONT, false, false) .expect("ligature font"); let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); - let pdf = super::super::render_to_pdf(&mut resources, &id, &pool, 1.0).expect("pdf"); + let pdf = super::super::render_to_pdf(&mut resources, &id, &pool).expect("pdf"); let trailer = parse_xref(&pdf).expect("xref").trailer; assert!( diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 0a047e8d07..02eda33b79 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -3795,3 +3795,73 @@ fn raster_export_of_child_paints_inherited_group_fill() { assert_eq!(inherited_png, raster_png(&own, uid(2))); assert_ne!(inherited_png, raster_png(&bare, uid(2))); } + +/// Reads one pixel of an encoded raster export as unpremultiplied RGBA. +fn raster_pixel(png: &[u8], (x, y): (i32, i32)) -> skia::Color { + let image = skia::Image::from_encoded(skia::Data::new_copy(png)).expect("decode png"); + let info = skia::ImageInfo::new( + (1, 1), + skia::ColorType::RGBA8888, + skia::AlphaType::Unpremul, + None, + ); + let mut px = [0u8; 4]; + assert!(image.read_pixels( + &info, + &mut px, + 4, + (x, y), + skia::image::CachingHint::Disallow + )); + skia::Color::from_argb(px[3], px[0], px[1], px[2]) +} + +/// #12334: headless raster/PDF must draw a masked group's inner shadow +/// inside the mask outline, as the GPU single export does. +#[test] +fn raster_export_of_masked_group_draws_inner_shadow() { + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let mask_id = uid(2); + let content_id = uid(3); + let blue = skia::Color::from_rgb(0, 63, 255); + + add_masked_group( + &mut pool, + group_id, + Uuid::nil(), + (0.0, 0.0, 100.0, 100.0), + &[mask_id, content_id], + ); + add_solid_circle( + &mut pool, + mask_id, + group_id, + (0.0, 0.0, 100.0, 100.0), + skia::Color::BLACK, + ); + add_solid_rect( + &mut pool, + content_id, + group_id, + (0.0, 0.0, 100.0, 100.0), + blue, + ); + pool.get_mut(&group_id).unwrap().add_shadow(Shadow::new( + skia::Color::BLACK, + 0.0, + 0.0, + (20.0, 0.0), + ShadowStyle::Inner, + false, + )); + + let (png, width, height) = raster_png_size(&pool, group_id); + assert_eq!((width, height), (100, 100)); + + // Left edge: the shifted silhouette leaves a band the shadow fills. + assert_eq!(raster_pixel(&png, (8, 50)), skia::Color::BLACK); + assert_eq!(raster_pixel(&png, (50, 50)), blue); + // Outside the circle the mask still clears everything. + assert_eq!(raster_pixel(&png, (3, 3)).a(), 0); +} diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index e9d873f353..bfbeedf0d0 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -821,7 +821,22 @@ fn render_group( let child_opts = opts.for_children_of(element); if masked { + // Inner shadows filter the masked pixels, so they follow the mask's + // outline. The canvas matrix already carries `scale`. + let inner_shadows = element.masked_group_inner_shadow_filter(); + if let Some(filter) = &inner_shadows { + let mut paint = Paint::default(); + paint.set_image_filter(filter.clone()); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&element.extrect(tree, scale)) + .paint(&paint), + ); + } render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?; + if inner_shadows.is_some() { + canvas.restore(); // inner shadow layer + } } else { for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index aaf4e55a49..3baa6dd66c 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1998,6 +1998,25 @@ impl Shape { }) } + /// Source graphic with every visible inner shadow of the masked group over + /// it, for the CPU export renderer; `None` without inner shadows. + pub fn masked_group_inner_shadow_filter(&self) -> Option { + if !self.is_masked_group() { + return None; + } + + let mut layers: Vec> = vec![None]; + for shadow in self.inner_shadows_visible() { + layers.push(shadow.get_inner_shadow_filter()); + } + + if layers.len() > 1 { + skia::image_filters::merge(layers, None) + } else { + None + } + } + /// Image filter for the `save_layer` that wraps a masked group, i.e. for /// the result *after* the mask has been composited into it. /// @@ -2390,6 +2409,28 @@ mod tests { math::Rect::from_ltrb(0.0, 0.0, 100.0, 100.0) } + #[test] + fn masked_group_inner_shadow_filter_needs_an_inner_shadow() { + let drop_only = masked_group_with(vec![drop_shadow(4.0, 0.0, (10.0, 10.0))], None); + assert!(drop_only.masked_group_inner_shadow_filter().is_none()); + + let inner = masked_group_with(vec![inner_shadow(4.0, (10.0, 10.0))], None); + assert!(inner.masked_group_inner_shadow_filter().is_some()); + + let mut unmasked = inner.clone(); + unmasked.set_masked(false); + assert!(unmasked.masked_group_inner_shadow_filter().is_none()); + } + + #[test] + fn masked_group_inner_shadow_filter_keeps_imperceptible_shadows() { + // Too small for the workspace filter at 1x, but export draws it. + let shape = masked_group_with(vec![inner_shadow(1.0, (0.0, 0.0))], None); + + assert!(shape.masked_group_layer_filter(1.0, false, false).is_none()); + assert!(shape.masked_group_inner_shadow_filter().is_some()); + } + #[test] fn masked_group_layer_filter_is_none_without_effects() { let shape = masked_group_with(vec![], None); From a1606c42ff52e95ef9f2c1c01fad96402b80186b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 20 Oct 2026 15:37:07 +0200 Subject: [PATCH 103/167] :bug: Export bitmaps over the GPU surface limit through the exporter (#12455) --- .../src/app/main/data/exports/assets.cljs | 31 ++++++++++++++----- frontend/src/app/render_wasm/api.cljs | 15 +++++++++ frontend/translations/en.po | 4 +++ frontend/translations/es.po | 4 +++ render-wasm/src/main.rs | 25 +++++++++++++++ render-wasm/src/render/raster.rs | 21 +++++++++++++ 6 files changed, 92 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index fd6d98e010..5ca4f3e983 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -18,6 +18,7 @@ [app.main.features :as features] [app.main.repo :as rp] [app.main.store :as st] + [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.websocket :as ws] @@ -300,11 +301,18 @@ [state] (features/active-feature? state "render-wasm/v1")) -(defn- use-wasm-export? - "Whether to take the client-side WASM export path for `export`." - [state export] - (and (wasm-export-enabled? state) - (contains? wasm-export-types (:type export)))) +(defn- export-target + [state {:keys [type object-id scale]}] + (cond + (not (and (wasm-export-enabled? state) + (contains? wasm-export-types type))) + :exporter + + (contains? #{:pdf :svg} type) + :client + + :else + (or (wasm.api/export-target object-id (or scale 1) type) :exporter))) (defn- request-simple-export-wasm [export] @@ -318,18 +326,25 @@ (defn request-simple-export [{:keys [export]}] - (let [export (normalize-export export)] + (let [export (normalize-export export) + target (volatile! nil)] (ptk/reify ::request-simple-export ptk/UpdateEvent (update [_ state] + (vreset! target (export-target state export)) (cond-> state - (not (use-wasm-export? state export)) + (= :exporter @target) (update :export assoc :in-progress true :id uuid/zero))) ptk/WatchEvent (watch [_ state _] - (if (use-wasm-export? state export) + (case @target + :client (rx/of (request-simple-export-wasm export)) + + :too-large + (rx/of (ntf/error (tr "errors.export-too-large"))) + (let [profile-id (:profile-id state) params (normalize-export-shapes-params {:exports [export] :profile-id profile-id diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 2227b4155e..7bed759057 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -3009,6 +3009,21 @@ (mem/free) {:x x :y y :width w :height h}))))) +(defn export-target + [shape-id scale format] + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id)] + (case (h/call wasm/internal-module "_export_target" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale + (sr/translate-raster-format format)) + 0 :client + 2 :too-large + :exporter)))) + (defn render-shape-pdf [shape-id] (when (initialized?) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fe435da917..014f5b622b 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1586,6 +1586,10 @@ msgstr "The email «%s» has been reported as spam or permanently bounce." msgid "errors.export-failed" msgstr "The export failed. Try again in a moment." +#: src/app/main/data/exports/assets.cljs:346 +msgid "errors.export-too-large" +msgstr "Too large to export, try a smaller scale" + #: src/app/main/errors.cljs:974 msgid "errors.feature-mismatch" msgstr "" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index df0f23c90c..6eaf06da2a 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1596,6 +1596,10 @@ msgstr "El email «%s» tiene reportes de spam o de rebote permanente." msgid "errors.export-failed" msgstr "La exportación ha fallado. Inténtalo de nuevo en un momento." +#: src/app/main/data/exports/assets.cljs:346 +msgid "errors.export-too-large" +msgstr "El archivo es demasiado grande, prueba con otra escala" + #: src/app/main/errors.cljs:974 msgid "errors.feature-mismatch" msgstr "" diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 11a8256842..34f3aa8dad 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -806,6 +806,31 @@ pub extern "C" fn render_shape_pixels( }) } +#[no_mangle] +pub extern "C" fn export_target(a: u32, b: u32, c: u32, d: u32, scale: f32, format: u32) -> u32 { + let id = uuid_from_u32_quartet(a, b, c, d); + let Ok(format) = RasterFormat::from_u32(format) else { + return 1; + }; + + with_state!(state, { + let Some(shape) = state.shapes.get(&id) else { + return 1; + }; + let extrect = get_render_state().get_cached_extrect(shape, &state.shapes, scale); + let width = (scale * extrect.width()).ceil() as i32; + let height = (scale * extrect.height()).ceil() as i32; + let max_size = get_gpu_state().max_surface_size(); + if width <= max_size && height <= max_size { + 0 + } else if format.fits_raster(width, height) { + 1 + } else { + 2 + } + }) +} + #[no_mangle] pub extern "C" fn render_stats() { get_render_state().print_stats(); diff --git a/render-wasm/src/render/raster.rs b/render-wasm/src/render/raster.rs index 52dd798006..5bd001ac32 100644 --- a/render-wasm/src/render/raster.rs +++ b/render-wasm/src/render/raster.rs @@ -8,6 +8,16 @@ use macros::ToJs; use super::vector; use super::RenderResources; +/// Half the default 2 GiB emscripten heap (a 16384² RGBA surface), leaving +/// room for the encoded copies and the scene. Estimated, not measured. +const MAX_RASTER_BYTES: i64 = 1 << 30; +/// PNG stores each side in 31 bits; the memory budget limits it first. +const MAX_PNG_SIDE: i32 = i32::MAX; +/// libjpeg-turbo's `JPEG_MAX_DIMENSION`, below the 16-bit JPEG header limit. +const MAX_JPEG_SIDE: i32 = 65500; +/// libwebp's `WEBP_MAX_DIMENSION`: WebP stores each side in 14 bits. +const MAX_WEBP_SIDE: i32 = 16383; + /// Encoded output format for [`render_to_raster`] and `render_shape_pixels`. /// `ToJs` publishes the discriminants to `api/shared.js`, so the CLJS side /// reads them from here instead of hardcoding the codes. @@ -65,6 +75,17 @@ impl RasterFormat { pub fn clear_color(self) -> skia::Color { self.opaque_background().unwrap_or(skia::Color::TRANSPARENT) } + + pub fn fits_raster(self, width: i32, height: i32) -> bool { + let max_side = match self { + Self::Png => MAX_PNG_SIDE, + Self::Jpeg => MAX_JPEG_SIDE, + Self::Webp => MAX_WEBP_SIDE, + }; + width <= max_side + && height <= max_side + && width as i64 * height as i64 * 4 <= MAX_RASTER_BYTES + } } /// Renders a shape tree to encoded image bytes on a CPU raster surface (no From 114928ea5f5f9e30656bb13e848670f33ab801f6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 20 Oct 2026 15:33:37 +0000 Subject: [PATCH 104/167] :bug: Fix the key reported when a redis DEL times out `app.redis/del` logged `:key key`, but `key` is not bound in that `let`: it resolved to `clojure.core/key`, so the line carried `#object[clojure.core$key ...]` instead of the keys that failed to be deleted. Log `:keys keys`, which is always the normalized vector, so a single key and a collection report the same shape. The tests pin the line: the keys, the hint, the error level, the logger and the timeout as cause, plus the `nil` the caller gets. The prod-infra memory now notes that client-side redis timeouts mean the JVM is paused, not that the server is slow. AI-assisted-by: deepseek-v4.1-flash --- .serena/memories/prod-infra/core.md | 2 +- backend/src/app/redis.clj | 2 +- backend/test/backend_tests/redis_test.clj | 76 +++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 backend/test/backend_tests/redis_test.clj diff --git a/.serena/memories/prod-infra/core.md b/.serena/memories/prod-infra/core.md index 5dfaeebed7..0ab9ecde8e 100644 --- a/.serena/memories/prod-infra/core.md +++ b/.serena/memories/prod-infra/core.md @@ -5,7 +5,7 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose ## Services - **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends. -- **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue::`. `PENPOT_REDIS_URI`. +- **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue::`. `PENPOT_REDIS_URI`. A burst of client-side `RedisCommandTimeoutException` (2s on `DEL`/`SET`, 10s on `BLPOP`) with the exporter healthy is the JVM paused or starved, not Redis: the timeout fires because nothing reads the reply. - **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses RustFS. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, object lifecycle, and file-data backends): `mem:backend/storage`. - **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task). - **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`. diff --git a/backend/src/app/redis.clj b/backend/src/app/redis.clj index be9e331dda..fba9268193 100644 --- a/backend/src/app/redis.clj +++ b/backend/src/app/redis.clj @@ -375,7 +375,7 @@ (try (-del conn keys) (catch RedisCommandTimeoutException cause - (l/err :hint "timeout on del redis key" :key key :cause cause) + (l/err :hint "timeout on del redis key" :keys keys :cause cause) nil)))) (defn ping diff --git a/backend/test/backend_tests/redis_test.clj b/backend/test/backend_tests/redis_test.clj new file mode 100644 index 0000000000..df3c8e15a8 --- /dev/null +++ b/backend/test/backend_tests/redis_test.clj @@ -0,0 +1,76 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.redis-test + "The redis wrapper swallows a command timeout and returns nil, so the log + line is the only trace of the command that did not reach the server. These + tests pin what that line has to carry." + (:require + [app.common.logging :as l] + [app.redis :as rds] + [clojure.test :as t]) + (:import + io.lettuce.core.RedisCommandTimeoutException)) + +(def ^:private timeout-hint "timeout on del redis key") + +(defn- timeout-connection + "A connection whose DEL always exceeds the command timeout, the way Lettuce + answers when the response does not arrive in time." + [] + (reify rds/IDefaultConnection + (-del [_ _keys] + (throw (RedisCommandTimeoutException. "DEL. Command timed out after 2 second(s)"))))) + +(defn- capture-records + "Run `f` with every log record collected, newest last. The logging API emits + asynchronously, so a record may land after `f` returns; callers wait for the + one they need." + [f] + (let [records (atom [])] + (add-watch l/log-record ::capture + (fn [_ _ _ record] + (swap! records conj record))) + (try + (f records) + (finally + (remove-watch l/log-record ::capture))))) + +(defn- timeout-record + "Wait until the record that reports a timed out DEL is captured." + [records timeout] + (let [deadline (+ (System/currentTimeMillis) timeout)] + (loop [] + (or (some (fn [record] + (when (= timeout-hint (get (::l/props record) :hint)) + record)) + @records) + (when (<= (System/currentTimeMillis) deadline) + (Thread/sleep 10) + (recur)))))) + +(t/deftest del-logs-the-keys-that-timed-out + ;; The server never saw the command, so the operator has no other way to know + ;; which key stayed behind. + (capture-records + (fn [records] + (t/is (nil? (rds/del (timeout-connection) ["file-summary:1" "file-summary:2"]))) + (let [record (timeout-record records 2000)] + (t/is (some? record) "a timed out DEL must leave a log line") + (t/is (= ["file-summary:1" "file-summary:2"] + (get (::l/props record) :keys))) + (t/is (instance? RedisCommandTimeoutException (::l/cause record))) + (t/is (= :error (::l/level record))) + (t/is (= "app.redis" (::l/logger record))))))) + +(t/deftest del-logs-a-single-key-as-a-collection + ;; `del` accepts one key or a collection and always hands a collection to the + ;; driver; the log keeps the same shape so it does not depend on the call. + (capture-records + (fn [records] + (rds/del (timeout-connection) "file-summary:1") + (t/is (= ["file-summary:1"] + (get (::l/props (timeout-record records 2000)) :keys)))))) From 3bab9fa0aaad4edb485db6942ec4bc61a16015a9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 20 Oct 2026 20:28:06 +0000 Subject: [PATCH 105/167] :books: Note the host variable needs three updates The prod-infra memory now says that the `$host` multi-variable needs its `query`, its `options` list and its `allValue` regex updated together. A host present only in `query` is dropped when `All` is selected, so the panels that filter with `instance=~"$host..."` stop showing it. Panel 104 is the visible symptom. AI-assisted-by: minimax-m3.1-flash-free --- .serena/memories/prod-infra/grafana.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.serena/memories/prod-infra/grafana.md b/.serena/memories/prod-infra/grafana.md index 533b09516f..14324b1f7f 100644 --- a/.serena/memories/prod-infra/grafana.md +++ b/.serena/memories/prod-infra/grafana.md @@ -20,6 +20,7 @@ Keep this memory free of internal names: no host names, no deployed versions, no ## Main dashboard - `Production status`, uid `penpot-production-status`. Sections: general state and deploy, `RPC y CLIMIT`, PostgreSQL and pool, Redis/msgbus, tasks, JVM/process. Variables `$env` and `$host` (custom), no auto refresh. Every section opens collapsed except `RPC y CLIMIT`, which is the one read on a normal day. + - `$host` is a custom multi-variable: its `options` list and its `allValue` regex must both enumerate every host. A host added to `query` but missing from `options`/`allValue` is silently dropped when `All` is selected, so panels that filter with `instance=~"$host..."` stop showing it (panel 104 is the visible symptom). Update all three places together when adding a host. - Per-host investigation dashboards are separate, and the alert rules deeplink into them. - The PostgreSQL section ends with a block of vacuum panels: two stats (autovacuum workers active on the primary, and the launcher alive), a Loki timeseries of finished vacuums per interval and table, and then the `Vacuum TXID age` table, which is the last panel of the section. The worker stat is an approximation — it counts the `pg_stat_activity` bucket described above, because the exporter publishes no `backend_type` — and the Loki panel is retrospective by construction. A per-table, in-flight view (table, phase, progress) needs a `pg_stat_progress_vacuum` collector added to the exporter itself. From 021492a57772ff3178b8c21f215a1806c54b5ee3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 20 Oct 2026 20:36:24 +0000 Subject: [PATCH 106/167] :bug: Make migration 0156 compatible with PostgreSQL 15 `SET STORAGE DEFAULT` needs PostgreSQL 16, but the official docker images pin `postgres:15`, so migration 0156 failed on those deployments. The migration now uses `SET STORAGE EXTENDED`, the storage mode of the type of every column it touches (text, text[], jsonb and bytea), which leaves the catalog in the same state on any supported version. The documented PostgreSQL floor goes back to >=15, the reason for raising it was that syntax. Verified on the test database: the 62 columns reset and no table column keeps the legacy EXTERNAL storage. The backend memory now records that migrations must stay within PostgreSQL 15 syntax. AI-assisted-by: minimax-m3.1-flash-free --- .serena/memories/backend/core.md | 2 + .../sql/0156-reset-pg-storage-defaults.sql | 136 +++++++++--------- docs/technical-guide/configuration.md | 2 +- 3 files changed, 72 insertions(+), 68 deletions(-) diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 42e7c7b756..7ed309bd18 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -41,6 +41,8 @@ Backend RPC command areas without focused memories include access tokens, binfil Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table. +Migrations must stay within PostgreSQL 15 syntax: the official docker images (`docker/images/docker-compose.yaml`) pin `postgres:15`, so anything newer breaks those deployments (`SET STORAGE DEFAULT` is PostgreSQL 16, for instance). The documented floor is >=15. + For interactive PostgreSQL access with correct dev defaults, use `scripts/psql`; to dump the current DDL schema, use `scripts/db-schema` (see `mem:scripts/psql`). diff --git a/backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql b/backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql index 54bd98ace5..46a7cce54b 100644 --- a/backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql +++ b/backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql @@ -12,118 +12,120 @@ -- already stored TOAST values keep their strategy until the row is -- rewritten, new writes use the default. -- --- NOTE: DEFAULT restores the storage mode of the column's data type --- (EXTENDED for every column below: text, text[], jsonb and bytea). It --- requires PostgreSQL 16 or newer, which is the minimum version the --- project supports (see the database section of the technical guide). --- RESET STORAGE is not valid syntax for a column. +-- NOTE: `SET STORAGE DEFAULT` would express the same thing (it restores +-- the storage mode of the column's data type), but it only exists since +-- PostgreSQL 16 and the project also runs on PostgreSQL 15. Here every +-- column below has a type whose default storage mode is EXTENDED +-- (text, text[], jsonb and bytea), so `SET STORAGE EXTENDED` reaches the +-- same catalog state on any supported version. RESET STORAGE is not +-- valid syntax for a column. ALTER TABLE access_token - ALTER COLUMN name SET STORAGE DEFAULT, - ALTER COLUMN perms SET STORAGE DEFAULT, - ALTER COLUMN token SET STORAGE DEFAULT; + ALTER COLUMN name SET STORAGE EXTENDED, + ALTER COLUMN perms SET STORAGE EXTENDED, + ALTER COLUMN token SET STORAGE EXTENDED; ALTER TABLE audit_log - ALTER COLUMN context SET STORAGE DEFAULT, - ALTER COLUMN name SET STORAGE DEFAULT, - ALTER COLUMN props SET STORAGE DEFAULT, - ALTER COLUMN source SET STORAGE DEFAULT, - ALTER COLUMN type SET STORAGE DEFAULT; + ALTER COLUMN context SET STORAGE EXTENDED, + ALTER COLUMN name SET STORAGE EXTENDED, + ALTER COLUMN props SET STORAGE EXTENDED, + ALTER COLUMN source SET STORAGE EXTENDED, + ALTER COLUMN type SET STORAGE EXTENDED; ALTER TABLE comment - ALTER COLUMN content SET STORAGE DEFAULT; + ALTER COLUMN content SET STORAGE EXTENDED; ALTER TABLE comment_thread - ALTER COLUMN page_name SET STORAGE DEFAULT, - ALTER COLUMN participants SET STORAGE DEFAULT; + ALTER COLUMN page_name SET STORAGE EXTENDED, + ALTER COLUMN participants SET STORAGE EXTENDED; ALTER TABLE file - ALTER COLUMN data SET STORAGE DEFAULT, - ALTER COLUMN data_backend SET STORAGE DEFAULT, - ALTER COLUMN features SET STORAGE DEFAULT, - ALTER COLUMN name SET STORAGE DEFAULT; + ALTER COLUMN data SET STORAGE EXTENDED, + ALTER COLUMN data_backend SET STORAGE EXTENDED, + ALTER COLUMN features SET STORAGE EXTENDED, + ALTER COLUMN name SET STORAGE EXTENDED; ALTER TABLE file_change - ALTER COLUMN changes SET STORAGE DEFAULT, - ALTER COLUMN data SET STORAGE DEFAULT, - ALTER COLUMN features SET STORAGE DEFAULT, - ALTER COLUMN label SET STORAGE DEFAULT; + ALTER COLUMN changes SET STORAGE EXTENDED, + ALTER COLUMN data SET STORAGE EXTENDED, + ALTER COLUMN features SET STORAGE EXTENDED, + ALTER COLUMN label SET STORAGE EXTENDED; ALTER TABLE file_data_fragment - ALTER COLUMN metadata SET STORAGE DEFAULT; + ALTER COLUMN metadata SET STORAGE EXTENDED; ALTER TABLE file_media_object - ALTER COLUMN mtype SET STORAGE DEFAULT, - ALTER COLUMN name SET STORAGE DEFAULT; + ALTER COLUMN mtype SET STORAGE EXTENDED, + ALTER COLUMN name SET STORAGE EXTENDED; ALTER TABLE file_thumbnail - ALTER COLUMN data SET STORAGE DEFAULT, - ALTER COLUMN props SET STORAGE DEFAULT; + ALTER COLUMN data SET STORAGE EXTENDED, + ALTER COLUMN props SET STORAGE EXTENDED; ALTER TABLE global_complaint_report - ALTER COLUMN content SET STORAGE DEFAULT, - ALTER COLUMN type SET STORAGE DEFAULT; + ALTER COLUMN content SET STORAGE EXTENDED, + ALTER COLUMN type SET STORAGE EXTENDED; ALTER TABLE profile - ALTER COLUMN auth_backend SET STORAGE DEFAULT, - ALTER COLUMN email SET STORAGE DEFAULT, - ALTER COLUMN fullname SET STORAGE DEFAULT, - ALTER COLUMN lang SET STORAGE DEFAULT, - ALTER COLUMN password SET STORAGE DEFAULT, - ALTER COLUMN props SET STORAGE DEFAULT, - ALTER COLUMN theme SET STORAGE DEFAULT; + ALTER COLUMN auth_backend SET STORAGE EXTENDED, + ALTER COLUMN email SET STORAGE EXTENDED, + ALTER COLUMN fullname SET STORAGE EXTENDED, + ALTER COLUMN lang SET STORAGE EXTENDED, + ALTER COLUMN password SET STORAGE EXTENDED, + ALTER COLUMN props SET STORAGE EXTENDED, + ALTER COLUMN theme SET STORAGE EXTENDED; ALTER TABLE profile_complaint_report - ALTER COLUMN content SET STORAGE DEFAULT, - ALTER COLUMN type SET STORAGE DEFAULT; + ALTER COLUMN content SET STORAGE EXTENDED, + ALTER COLUMN type SET STORAGE EXTENDED; ALTER TABLE project - ALTER COLUMN name SET STORAGE DEFAULT; + ALTER COLUMN name SET STORAGE EXTENDED; ALTER TABLE scheduled_task - ALTER COLUMN cron_expr SET STORAGE DEFAULT, - ALTER COLUMN id SET STORAGE DEFAULT; + ALTER COLUMN cron_expr SET STORAGE EXTENDED, + ALTER COLUMN id SET STORAGE EXTENDED; ALTER TABLE server_error_report - ALTER COLUMN content SET STORAGE DEFAULT; + ALTER COLUMN content SET STORAGE EXTENDED; ALTER TABLE server_prop - ALTER COLUMN content SET STORAGE DEFAULT, - ALTER COLUMN id SET STORAGE DEFAULT; + ALTER COLUMN content SET STORAGE EXTENDED, + ALTER COLUMN id SET STORAGE EXTENDED; ALTER TABLE storage_object - ALTER COLUMN backend SET STORAGE DEFAULT, - ALTER COLUMN metadata SET STORAGE DEFAULT; + ALTER COLUMN backend SET STORAGE EXTENDED, + ALTER COLUMN metadata SET STORAGE EXTENDED; ALTER TABLE task - ALTER COLUMN error SET STORAGE DEFAULT, - ALTER COLUMN label SET STORAGE DEFAULT, - ALTER COLUMN name SET STORAGE DEFAULT, - ALTER COLUMN props SET STORAGE DEFAULT, - ALTER COLUMN queue SET STORAGE DEFAULT, - ALTER COLUMN status SET STORAGE DEFAULT; + ALTER COLUMN error SET STORAGE EXTENDED, + ALTER COLUMN label SET STORAGE EXTENDED, + ALTER COLUMN name SET STORAGE EXTENDED, + ALTER COLUMN props SET STORAGE EXTENDED, + ALTER COLUMN queue SET STORAGE EXTENDED, + ALTER COLUMN status SET STORAGE EXTENDED; ALTER TABLE team - ALTER COLUMN name SET STORAGE DEFAULT; + ALTER COLUMN name SET STORAGE EXTENDED; ALTER TABLE team_font_variant - ALTER COLUMN font_family SET STORAGE DEFAULT, - ALTER COLUMN font_style SET STORAGE DEFAULT; + ALTER COLUMN font_family SET STORAGE EXTENDED, + ALTER COLUMN font_style SET STORAGE EXTENDED; ALTER TABLE team_invitation - ALTER COLUMN email_to SET STORAGE DEFAULT, - ALTER COLUMN role SET STORAGE DEFAULT; + ALTER COLUMN email_to SET STORAGE EXTENDED, + ALTER COLUMN role SET STORAGE EXTENDED; ALTER TABLE usage_quote - ALTER COLUMN target SET STORAGE DEFAULT; + ALTER COLUMN target SET STORAGE EXTENDED; ALTER TABLE webhook - ALTER COLUMN error_code SET STORAGE DEFAULT, - ALTER COLUMN mtype SET STORAGE DEFAULT, - ALTER COLUMN secret_key SET STORAGE DEFAULT, - ALTER COLUMN uri SET STORAGE DEFAULT; + ALTER COLUMN error_code SET STORAGE EXTENDED, + ALTER COLUMN mtype SET STORAGE EXTENDED, + ALTER COLUMN secret_key SET STORAGE EXTENDED, + ALTER COLUMN uri SET STORAGE EXTENDED; ALTER TABLE webhook_delivery - ALTER COLUMN error_code SET STORAGE DEFAULT, - ALTER COLUMN req_data SET STORAGE DEFAULT, - ALTER COLUMN rsp_data SET STORAGE DEFAULT; + ALTER COLUMN error_code SET STORAGE EXTENDED, + ALTER COLUMN req_data SET STORAGE EXTENDED, + ALTER COLUMN rsp_data SET STORAGE EXTENDED; diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 59939a522b..374c5e408c 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -484,7 +484,7 @@ PENPOT_SECRET_KEY: my-super-secure-key ### Database -Penpot only supports PostgreSQL and we highly recommend >=16 version. If you are using official +Penpot only supports PostgreSQL and we highly recommend >=15 version. If you are using official docker images this is already solved for you. Essential database configuration: From 3863e929936970420e7b5845841c172c99b6a435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Wed, 21 Oct 2026 10:35:40 +0200 Subject: [PATCH 107/167] :bug: Fix loss of swap slot when copying to clipboard (#12434) * :bug: Fix loss of swap slot when copying to clipboard * :wrench: Add test and make test helpers run same code as real functions * :wrench: Changes after review --- common/src/app/common/types/file.cljc | 25 ++- .../app/main/data/workspace/clipboard.cljs | 81 +++++----- .../test/frontend_tests/helpers/pages.cljs | 35 +---- .../logic/comp_remove_swap_slots_test.cljs | 147 ++++++++++++++++++ 4 files changed, 208 insertions(+), 80 deletions(-) diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index 3a52dd79ca..df4733c25a 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -1259,23 +1259,22 @@ (defn get-ref-chain-until-target-ref "Returns a vector with the shape ref chain until target-ref, including itself" [container libraries shape target-ref] - (loop [chain [shape] - current shape] - (if (= current target-ref) + (loop [chain [shape] + current shape + container container] + (if (= (:id current) target-ref) chain (if-let [ref (find-ref-shape nil container libraries current :with-context? true)] - (recur (conj chain ref) ref) + (recur (conj chain ref) ref (:container (meta ref))) chain)))) (defn get-touched-from-ref-chain-until-target-ref "Returns a set with the :touched of all the items on the shape ref chain until target-ref, including itself" - [container libraries shape target-ref] - (let [chain (get-ref-chain-until-target-ref container libraries shape target-ref) - more-touched (->> chain - (map :touched) - (remove nil?) - (apply set/union) - (remove ctk/swap-slot?) - set)] - (set/union (or (:touched shape) #{}) more-touched))) + [container libraries shape target-ref & {:keys [remove-slots?] :or {remove-slots? true}}] + (let [chain (get-ref-chain-until-target-ref container libraries shape target-ref) + more-touched (cond->> chain + :always (map :touched) + :always (reduce set/union #{}) + remove-slots? (remove ctk/swap-slot?))] + (set/union (or (:touched shape) #{}) (set more-touched)))) \ No newline at end of file diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 7145bd3ad5..d763e48e2e 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -67,6 +67,43 @@ [potok.v2.core :as ptk] [promesa.core :as p])) +(defn- advance-shape + [file libraries page level-delta head-id objects shape] + (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true}) + container (ctn/make-container page :page) + ;; The head becomes a root copy, which cannot carry a swap slot, so + ;; strip inherited slots only for it. Nested sub-heads keep them so + ;; they still pair with the children of the new near main. + head? (= (:id shape) head-id) + new-touched (ctf/get-touched-from-ref-chain-until-target-ref + container libraries shape new-shape-ref :remove-slots? head?)] + (cond-> objects + (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) + (update (:id shape) assoc :shape-ref new-shape-ref :touched new-touched)))) + +(defn- advance-copy + [file libraries page objects shape] + (if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape))) + (let [level-delta (if (nil? (ctk/get-swap-slot shape)) + (ctn/get-nesting-level-delta (:objects page) shape uuid/zero) + 0)] + (if (pos? level-delta) + (reduce (partial advance-shape file libraries page level-delta (:id shape)) + objects + (cfh/get-children-with-self objects (:id shape))) + objects)) + objects)) + +(defn advance-copies + "When copying an instance that is nested inside another one, we need to + advance the shape refs to one or more levels of remote mains." + [file libraries page selected data] + (let [heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)] + (update data :objects + #(reduce (partial advance-copy file libraries page) + % + heads)))) + (defn copy-selected [] (letfn [(sort-selected [state data] @@ -148,39 +185,13 @@ (let [frame (get objects parent-frame-id)] (gsh/translate-to-frame shape frame)))) - ;; When copying an instance that is nested inside another one, we need to - ;; advance the shape refs to one or more levels of remote mains. - (advance-copies [state selected data] - (let [file (dsh/lookup-file state) - libraries (:files state) - ;; FIXME - page (dsh/lookup-page state) - heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)] - (update data :objects - #(reduce (partial advance-copy file libraries page) - % - heads)))) - - (advance-copy [file libraries page objects shape] - (if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape))) - (let [level-delta (if (nil? (ctk/get-swap-slot shape)) - (ctn/get-nesting-level-delta (:objects page) shape uuid/zero) - 0)] - (if (pos? level-delta) - (reduce (partial advance-shape file libraries page level-delta) - objects - (cfh/get-children-with-self objects (:id shape))) - objects)) - objects)) - - (advance-shape [file libraries page level-delta objects shape] - (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true}) - container (ctn/make-container page :page) - new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref)] - (cond-> objects - (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) - (-> (assoc-in [(:id shape) :shape-ref] new-shape-ref) - (assoc-in [(:id shape) :touched] new-touched))))) + (advance-copies-from-state [state selected data] + (advance-copies (dsh/lookup-file state) + (:files state) + ;; FIXME + (dsh/lookup-page state) + selected + data)) (on-copy-error [error] (js/console.error "clipboard blocked:" error) @@ -232,7 +243,7 @@ (rx/merge-map (partial prepare-object objects frame-id)) (rx/reduce (partial collect-data state) initial) (rx/map (partial sort-selected state)) - (rx/map (partial advance-copies state selected)) + (rx/map (partial advance-copies-from-state state selected)) (rx/map #(t/encode-str % {:type :json-verbose})) (rx/map #(wapi/create-blob % "text/plain")) (rx/subs! resolve reject))))] @@ -247,7 +258,7 @@ (rx/merge-map (partial prepare-object objects frame-id)) (rx/reduce (partial collect-data state) initial) (rx/map (partial sort-selected state)) - (rx/map (partial advance-copies state selected)) + (rx/map (partial advance-copies-from-state state selected)) (rx/map #(t/encode-str % {:type :json-verbose})) (rx/map clipboard/to-clipboard) (rx/catch on-copy-error) diff --git a/frontend/test/frontend_tests/helpers/pages.cljs b/frontend/test/frontend_tests/helpers/pages.cljs index 6ac59e6259..4a4b7784a0 100644 --- a/frontend/test/frontend_tests/helpers/pages.cljs +++ b/frontend/test/frontend_tests/helpers/pages.cljs @@ -14,13 +14,11 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.logic.libraries :as cll] - [app.common.types.component :as ctk] - [app.common.types.container :as ctn] - [app.common.types.file :as ctf] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] [app.main.data.helpers :as dsh] + [app.main.data.workspace.clipboard :as dwc] [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.layout :as layout])) @@ -225,34 +223,7 @@ (if (= parent-frame-id uuid/zero) shape (let [frame (get objects parent-frame-id)] - (gsh/translate-to-frame shape frame)))) - - ;; When copying an instance that is nested inside another one, we need to - ;; advance the shape refs to one or more levels of remote mains. - (advance-copies [data] - (let [heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)] - (update data :objects - #(reduce (partial advance-copy file libraries page) - % - heads)))) - - (advance-copy [file libraries page objects shape] - (if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape))) - (let [level-delta (if (nil? (ctk/get-swap-slot shape)) - (ctn/get-nesting-level-delta (:objects page) shape uuid/zero) - 0)] - (if (pos? level-delta) - (reduce (partial advance-shape file libraries page level-delta) - objects - (cfh/get-children-with-self objects (:id shape))) - objects)) - objects)) - - (advance-shape [file libraries page level-delta objects shape] - (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})] - (cond-> objects - (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) - (assoc-in [(:id shape) :shape-ref] new-shape-ref))))] + (gsh/translate-to-frame shape frame))))] (let [file-id (:id file) frame-id (cfh/common-parent-frame objects selected) @@ -273,4 +244,4 @@ (map (partial prepare-object objects frame-id)) (reduce collect-data initial) sort-selected - advance-copies)))) + (dwc/advance-copies file libraries page selected))))) diff --git a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs index 476d549c5d..f93bd40d3b 100644 --- a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs +++ b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs @@ -5,6 +5,7 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.comp-remove-swap-slots-test (:require + [app.common.files.validate :as cfv] [app.common.test-helpers.components :as cthc] [app.common.test-helpers.compositions :as ctho] [app.common.test-helpers.files :as cthf] @@ -870,3 +871,149 @@ (t/is (some? copied-blue1')) (t/is (nil? (ctk/get-swap-slot copied-blue1'))))))))) + +(t/deftest test-keep-inherited-swap-slot-copy-paste-nested-copy-to-root + (t/async + done + (let [;; ==== Setup + ;; {:frame-red} [:name frame-red] # [Component :red] + ;; {:frame-blue} [:name frame-blue] # [Component :blue] + ;; {:frame-icon} [:name frame-icon] # [Component :icon] + ;; :red-copy-icon [:name frame-red] @--> frame-red + ;; {:frame-title} [:name frame-title] # [Component :title] + ;; :icon-copy-title [:name frame-icon] @--> frame-icon + ;; :blue-copy-title [:name frame-blue, :swap-slot-label :red-copy-icon] @--> frame-blue + ;; :title-copy [:name frame-title] #--> frame-title + ;; :icon-copy-in-title-copy [:name frame-icon] @--> :icon-copy-title + ;; :blue-copy-in-title-copy [:name frame-blue] @--> :blue-copy-title + file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame-red :name "frame-red") + (cthc/make-component :red :frame-red) + (ctho/add-frame :frame-blue :name "frame-blue") + (cthc/make-component :blue :frame-blue) + (ctho/add-frame :frame-icon :name "frame-icon") + (cthc/make-component :icon :frame-icon) + (cthc/instantiate-component :red :red-copy-icon :parent-label :frame-icon) + (ctho/add-frame :frame-title :name "frame-title") + (cthc/make-component :title :frame-title) + (cthc/instantiate-component :icon :icon-copy-title :parent-label :frame-title + :children-labels [:red-copy-in-icon-copy-title]) + (cthc/component-swap :red-copy-in-icon-copy-title :blue :blue-copy-title) + (cthc/instantiate-component :title :title-copy + :children-labels [:icon-copy-in-title-copy + :blue-copy-in-title-copy])) + store (ths/setup-store file) + + ;; ==== Action + page (cthf/current-page file) + icon (cths/get-shape file :icon-copy-in-title-copy) + features #{"components/v2"} + version 47 + + pdata (thp/simulate-copy-shape #{(:id icon)} (:objects page) {(:id file) file} page file features version) + + events + [(dws/select-shape uuid/zero) + (dw/paste-shapes pdata)]] + + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + icon' (cths/get-shape file' :icon-copy-in-title-copy) + blue' (cths/get-shape file' :blue-copy-in-title-copy) + copied-icon' (find-copied-shape icon' page' uuid/zero) + copied-blue' (find-copied-shape blue' page' (:id copied-icon')) + errors (try + (cfv/validate-file! file' {(:id file') file'}) + nil + (catch :default cause + (ex-data cause)))] + + ;; ==== Check + ;; the copied icon now refers to the icon main, where blue + ;; does not exist, so the copied blue must keep the swap slot + ;; inherited from :blue-copy-title + (t/is (some? copied-icon')) + (t/is (= (:shape-ref copied-icon') (cthi/id :frame-icon))) + (t/is (some? copied-blue')) + (t/is (= (ctk/get-swap-slot copied-blue') (cthi/id :red-copy-icon))) + + ;; the file must be valid after the paste + (t/is (nil? errors)))))))) + +(t/deftest test-keep-touched-copy-paste-nested-copy-to-root + (t/async + done + (let [;; ==== Setup + ;; Add a nested copy inside a copy of :green, and override its fill + ;; so that the copy has a touched attribute. + file (-> (setup-file) + (cthc/instantiate-component :green :green-copy-2 + :parent-label :frame-b2 + :children-labels [:red-copy-in-green-copy-2]) + (cths/update-shape :red-copy-in-green-copy-2 + :fills (cths/sample-fills-color :fill-color "#ff0000"))) + store (ths/setup-store file) + + ;; ==== Action + page (cthf/current-page file) + red (cths/get-shape file :red-copy-in-green-copy-2) + features #{"components/v2"} + version 47 + + pdata (thp/simulate-copy-shape #{(:id red)} (:objects page) {(:id file) file} page file features version) + + events + [(dws/select-shape uuid/zero) + (dw/paste-shapes pdata)]] + + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + red' (cths/get-shape file' :red-copy-in-green-copy-2) + copied-red' (find-copied-shape red' page' uuid/zero)] + + ;; ==== Check + ;; the touched fill override must be preserved in the pasted copy + (t/is (some? copied-red')) + (t/is (contains? (:touched copied-red') :fill-group)) + (t/is (= (:touched red') (:touched copied-red'))))))))) + +(t/deftest test-remove-swap-slot-copy-paste-nested-swapped-copy-to-root + (t/async + done + (let [;; ==== Setup (sample file as is) + file (setup-file) + store (ths/setup-store file) + + ;; ==== Action + page (cthf/current-page file) + blue (cths/get-shape file :blue-copy-in-green-copy) + features #{"components/v2"} + version 47 + + pdata (thp/simulate-copy-shape #{(:id blue)} (:objects page) {(:id file) file} page file features version) + + events + [(dws/select-shape uuid/zero) + (dw/paste-shapes pdata)]] + + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + blue' (cths/get-shape file' :blue-copy-in-green-copy) + copied-blue' (find-copied-shape blue' page' uuid/zero)] + + ;; ==== Check + ;; the copied blue has no swap-id + (t/is (some? copied-blue')) + (t/is (nil? (ctk/get-swap-slot copied-blue'))))))))) From d8862c759b023ee7b94b1247b3747188d3643068 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 12:31:03 +0200 Subject: [PATCH 108/167] :bug: Fix shared files 500 on path content without coordinates (#12426) * :bug: Fix legacy path migrations on content without coordinates PRE still returned 500 on get-team-shared-files after #12357, now with "expected rect instance" in legacy-6. When a path has no points, legacy-6 rebuilds its rect from the content, but content whose points are not numbers (NaN or infinite coordinates, segments without params, only a close path) gives no rect either, and rect->points rejects nil. Fall back to the empty rect in legacy-6. A path with segments missing coordinates then failed later, in 0003-convert-path-content-v2, since the binary path data can not store them; drop those segments there. Relates to #12343 AI-assisted-by: claude-opus-5-5 * :bug: Skip shared files that fail to load in get-team-shared-files get-team-shared-files loads and migrates every shared file of the team, so one file whose migration fails made the whole request return 500 and the "Add library" modal could not open. Catch the error per file, log it with the file and team ids, and leave that file out of the list. The other libraries still show, and the error report names the broken file. Closes #12343 AI-assisted-by: claude-opus-5-5 --- backend/src/app/rpc/commands/files.clj | 16 ++++- backend/test/backend_tests/rpc_file_test.clj | 20 +++++++ common/src/app/common/files/migrations.cljc | 22 +++++-- .../common_tests/files_migrations_test.cljc | 58 +++++++++++++++++++ 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 058141829d..5c17effec4 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -607,11 +607,21 @@ (let [process-row (fn [{:keys [id library-file-ids]}] - (let [file (get-file-with-summary cfg id)] - (assoc file :library-file-ids (db/decode-pgarray library-file-ids #{})))) + ;; One shared file that fails to load (for example, a migration + ;; error on old data) must not break the whole list; skip it + ;; and report it with its id. + (try + (let [file (get-file-with-summary cfg id)] + (assoc file :library-file-ids (db/decode-pgarray library-file-ids #{}))) + (catch Exception cause + (l/error :hint "unable to load shared file" + :file-id (str id) + :team-id (str team-id) + :cause cause) + nil))) xform - (map process-row)] + (keep process-row)] (->> (db/plan conn [sql:team-shared-files team-id] {:fetch-size 1}) (transduce xform conj #{})))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 34adcae63e..c885219fc2 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2907,3 +2907,23 @@ (get-in (meta (:result out)) [:app.loggers.audit/props :id]))) (t/is (= (:id team) (get-in (meta (:result out)) [:app.loggers.audit/props :team-id]))))) + +(t/deftest get-team-shared-files-skips-files-that-fail-to-load + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + file2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + get-file (deref #'files/get-file-with-summary) + broken (fn [cfg id] + (if (= id (:id file1)) + (throw (ex-info "broken file" {:type :assertion})) + (get-file cfg id))) + out (with-redefs-fn {#'files/get-file-with-summary broken} + #(th/command! {::th/type :get-team-shared-files + ::rpc/profile-id (:id profile) + :team-id (:default-team-id profile)}))] + (t/is (th/success? out)) + (t/is (= #{(:id file2)} (into #{} (map :id) (:result out)))))) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index d0312f5a56..de82f16f9e 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -230,7 +230,10 @@ ;; its geometry from the content as for line-like paths. (let [{:keys [width height] :as rect} (grc/points->rect (:points shape))] (if (or (nil? rect) (mth/almost-zero? width) (mth/almost-zero? height)) - (let [selrect (path.segment/content->selrect (:content shape)) + ;; Content whose points are not numbers gives no rect + ;; either; fall back to the empty rect. + (let [selrect (or (path.segment/content->selrect (:content shape)) + (grc/make-rect)) points (grc/rect->points selrect) transform (gmt/matrix) transform-inv (gmt/matrix)] @@ -1339,6 +1342,16 @@ (let [decode-segments (sm/decoder path/schema:segments sm/json-transformer) + ;; Old files may have segments with missing coordinates, which + ;; the binary path data can not store; drop them. + valid-segment? + (fn [{:keys [command params]}] + (case command + :close-path true + (:move-to :line-to) (and (number? (:x params)) (number? (:y params))) + :curve-to (every? #(number? (get params %)) [:x :y :c1x :c1y :c2x :c2y]) + false)) + update-object (fn [object] (if (or (cfh/bool-shape? object) @@ -1352,9 +1365,10 @@ (path/content []) :else - (-> content - (decode-segments) - (path/content)))] + (->> content + (decode-segments) + (filterv valid-segment?) + (path/content)))] (assoc object :content content)) object)) diff --git a/common/test/common_tests/files_migrations_test.cljc b/common/test/common_tests/files_migrations_test.cljc index 61ba0e7486..cb27492b6a 100644 --- a/common/test/common_tests/files_migrations_test.cljc +++ b/common/test/common_tests/files_migrations_test.cljc @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.files.migrations :as cfm] [app.common.files.tokens :as cfo] + [app.common.geom.rect :as grc] [app.common.test-helpers.components :as thk] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] @@ -16,6 +17,7 @@ [app.common.test-helpers.shapes :as ths] [app.common.test-helpers.variants :as thv] [app.common.types.file :as ctf] + [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.token :as ctt] [app.common.uuid :as uuid] @@ -637,6 +639,62 @@ (t/is (= (:selrect shape) (:selrect shape')) "selrect rebuilt from content"))))) +(def ^:private contents-without-rect + "Path contents from old files whose points give no rect" + {"segments without params" [{:command :move-to} {:command :line-to}] + "only a close path" [{:command :close-path :params {}}] + "not finite coordinates" [{:command :move-to :params {:x ##NaN :y ##Inf}} + {:command :line-to :params {:x 10 :y 10}}]}) + +(t/deftest migration-legacy-6-repairs-paths-whose-content-has-no-rect + (let [migration-id "legacy-6" + page-id (uuid/next) + shape-id (uuid/next)] + + (doseq [[label content] contents-without-rect] + (let [shape (-> (make-path-shape shape-id) + (assoc :points [] :content content)) + data {:pages-index {page-id {:objects {shape-id shape}}}} + data' (cfm/migrate-data data migration-id) + shape' (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (grc/rect? (:selrect shape')) (str "selrect is a rect: " label)) + (t/is (= 4 (count (:points shape'))) (str "points rebuilt: " label)))))) + +(t/deftest migration-0003-convert-path-content-drops-segments-without-coordinates + (let [migration-id "0003-convert-path-content-v2" + page-id (uuid/next) + shape-id (uuid/next) + content [{:command :move-to :params {:x 10 :y 20}} + {:command :line-to} + {:command :curve-to :params {:x 50 :y 50}} + {:command :line-to :params {:x 110 :y 70}} + {:command :close-path :params {}}] + shape (assoc (make-path-shape shape-id) :content content) + data {:pages-index {page-id {:objects {shape-id shape}}}} + data' (cfm/migrate-data data migration-id) + content' (get-in data' [:pages-index page-id :objects shape-id :content])] + + (t/is (path/content? content')) + (t/is (= [:move-to :line-to :close-path] (mapv :command (vec content')))))) + +(t/deftest migration-legacy-6-chain-with-content-without-rect + ;; The file must load: every migration from legacy-6 on and the final + ;; data validation must succeed. + (doseq [[label content] contents-without-rect] + (let [shape-id (uuid/next) + file (ctf/make-file {:name "Legacy path without rect"}) + page-id (first (get-in file [:data :pages])) + shape (-> (make-path-shape shape-id) + (assoc :points [] :content content)) + file (-> file + (dissoc :migrations) + (assoc :version 5) + (assoc-in [:data :pages-index page-id :objects shape-id] shape)) + file' (cfm/migrate-file file (delay {}))] + + (t/is (contains? (:migrations file') "legacy-6") (str "migrated: " label))))) + (t/deftest migration-legacy-6-runs-through-file-migration ;; A file saved before version 6 runs every migration from legacy-6 on, ;; and the whole chain must succeed for the file to load. From 858d5dad9c2dfd7fdb02c6f26c03e080576bf416 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 12:32:09 +0200 Subject: [PATCH 109/167] :recycle: Use DS buttons in the debug shape panel (#12484) * :recycle: Use DS buttons in the debug shape panel The object and tree dump buttons of the debug shape panel were raw buttons with the browser default look. Use DS secondary buttons. The DS buttons are taller, so the title row centers the shape name. Add a visual test that turns on the shape-panel debug option and shows the title row of the selected shape. AI-assisted-by: claude-opus-5-5 * :recycle: Move debug shape panel styles off the deprecated styles Move debug_shape_info.scss to DS spacing tokens, borders and logical properties, resolve the panel background alias, and drop the refactor import. The two font sizes match no DS typography, so they stay with a TODO, and the unused shape-info-title class is commented out for a manual check. The panel looks the same as with the previous commit. AI-assisted-by: claude-opus-5-5 * :bug: Let the debugging tools options scroll The debugging tools panel lists 32 options in the left sidebar, whose content area hides what overflows. On a short window the last options were cut off with no way to reach them. Make the options list fill the space under the title and scroll on its own. AI-assisted-by: claude-opus-5-5 * :recycle: Move debugging tools panel styles to DS tokens Move debug.scss spacing, size and the panel background alias to DS tokens and logical properties. The refactor import stays only for the checkbox placeholders, until the checkboxes move to the DS. The panel looks the same as before. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 29 +++++++++++++ .../app/main/ui/workspace/sidebar/debug.scss | 14 +++++-- .../workspace/sidebar/debug_shape_info.cljs | 9 +++- .../workspace/sidebar/debug_shape_info.scss | 42 +++++++++++-------- 4 files changed, 72 insertions(+), 22 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index f18514cc9d..4ff50815a0 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -389,6 +389,35 @@ test.describe("Assets tab", () => { }); }); +test.describe("Debug shape panel", () => { + test("Shows the debug info of the selected shape", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickLeafLayer("Board"); + await page.getByRole("button", { name: "Debugging tool" }).click(); + await page.getByText("shape-panel", { exact: true }).click(); + await expect( + workspace.rightSidebar.getByText("Board", { exact: true }), + ).toBeVisible(); + await page.mouse.move(640, 600); + // Only the title row with the dump buttons: the attributes list below + // holds ids that change on every run. + const title = workspace.rightSidebar + .getByText("Board", { exact: true }) + .locator(".."); + await expect(title).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Guides", () => { test("Shows the advanced options of a square guide", async ({ page }) => { // A taller window keeps the whole Design tab visible: a scrolled diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug.scss b/frontend/src/app/main/ui/workspace/sidebar/debug.scss index da70295d97..ced6252661 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug.scss @@ -4,26 +4,34 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +@use "ds/_sizes.scss" as *; + +// Only for the checkbox placeholders (%input-checkbox, %checkbox-icon), +// until the checkboxes move to the DS. @use "refactor/common-refactor.scss" as deprecated; .debug-panel { display: flex; flex-direction: column; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); } .debug-panel-title { margin: var(--sp-s) var(--sp-s) 0 var(--sp-s); } +// The options list scrolls under the title when it does not fit. .debug-panel-inner { - padding: deprecated.$s-16 deprecated.$s-8; + flex: 1; + min-block-size: 0; + overflow-y: auto; + padding: var(--sp-l) var(--sp-s); } .checkbox-wrapper { @extend %input-checkbox; - height: deprecated.$s-32; + block-size: $sz-32; padding: 0; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs index 92c9ae2e2b..d2cc9888af 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs @@ -13,6 +13,7 @@ [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] [debug :as dbg] [rumext.v2 :as mf])) @@ -130,8 +131,12 @@ [:div {:class (stl/css :attrs-container) :key (dm/str "shape" idx)} [:div {:class (stl/css :shape-title)} [:div {:class (stl/css :shape-name)} (:name current)] - [:button {:on-click #(debug/dump-object (dm/str (:id current)))} "object"] - [:button {:on-click #(debug/dump-subtree (dm/str (:id current)) true)} "tree"]] + [:> button* {:variant "secondary" + :on-click #(debug/dump-object (dm/str (:id current)))} + "object"] + [:> button* {:variant "secondary" + :on-click #(debug/dump-subtree (dm/str (:id current)) true)} + "tree"]] [:div {:class (stl/css :shape-attrs)} (let [attrs (get-attrs current)] 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 c849565bb5..9f770611f7 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 @@ -4,34 +4,42 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; .shape-info { display: flex; flex-direction: column; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); color: var(--color-foreground-primary); - font-size: deprecated.$fs-12; + + // TODO: No exact DS typography match found. + // Closest match: body-small. + font-size: px2rem(12); user-select: text; } -.shape-info-title { - margin: var(--sp-s) var(--sp-s) 0 var(--sp-s); -} +// 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: deprecated.$s-16 deprecated.$s-8; + padding: var(--sp-l) var(--sp-s); overflow: hidden; } .shape-title { - font-size: deprecated.$fs-14; + // TODO: No exact DS typography match found. + // Closest match: body-medium. + font-size: px2rem(14); background: var(--color-background-quaternary); color: var(--color-foreground-primary); - padding: deprecated.$s-8; - border-radius: deprecated.$s-8; + padding: var(--sp-s); + border-radius: $br-8; display: flex; - gap: deprecated.$s-4; + align-items: center; + gap: var(--sp-xs); } .shape-name { @@ -41,7 +49,7 @@ .attrs-container-attr { display: grid; grid-template-columns: 25% auto; - padding: deprecated.$s-4 0; + padding: var(--sp-xs) 0; &.vertical-layout { grid-template-columns: auto; @@ -51,8 +59,8 @@ .shape-attrs { overflow: auto; - height: calc(100% - 8px); - padding-bottom: 8px; + block-size: calc(100% - var(--sp-s)); + padding-block-end: var(--sp-s); } .shape-link { @@ -63,15 +71,15 @@ .shape-list { display: flex; flex-direction: column; - gap: deprecated.$s-4; + gap: var(--sp-xs); } .point-list { display: flex; - gap: deprecated.$s-8; + gap: var(--sp-s); } .cell-container { display: grid; - grid-template-columns: 100px 1fr; + grid-template-columns: px2rem(100) 1fr; } From 5dbf0731e5b94b125f96499a37a28b2d02ebe372 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 13:11:04 +0200 Subject: [PATCH 110/167] :recycle: Use DS buttons and icons in the plugins dialogs (#12470) * :recycle: Use DS buttons and icons in the plugins dialogs The plugins manager and the permissions, permissions update and try-out dialogs used raw buttons, input elements styled as buttons and deprecated icons. Replace them with the DS components: - The four close buttons become ghost icon buttons labeled "Close", shown in their DS tooltip. The class that places them in the corner goes on the tooltip wrapper, so the tooltip anchors to the button. - Install, and the cancel and accept buttons of the three dialogs, become DS primary and secondary buttons. - The empty list puzzle and the external link icons use the DS icon. The permission icons (oauth-*) stay: they are brand art. Add a visual test with fixtures for a team with the plugins feature and a profile with one installed plugin. It shows the empty manager, the manager with a plugin and the permissions dialog. AI-assisted-by: claude-opus-5-5 * :recycle: Move plugins dialog styles off the deprecated styles Remove the styles the DS buttons and icons no longer need (close-icon, primary-button, cancel-button, search-icon and the svg rules of the migrated icons) and move the rest of plugins.scss to DS tokens, DS typographies and logical properties. - The close button wrapper keeps only its position; the deprecated tertiary button style was landing on the tooltip wrapper. - The full-width cancel and accept buttons center their label again: the DS button does not center it when it grows. - The external link icon gets a class instead of an svg rule. - The deprecated body-small used line-height 1.4 and the DS one 1.3, so the permissions dialog is a few pixels shorter. The refactor import stays for the modal placeholders until the modals move to the DS modal. AI-assisted-by: claude-opus-5-5 --- .../get-profile-with-plugins.json | 44 ++++ .../data/workspace/get-team-plugins.json | 24 +++ .../ui/visual-specs/workspace.spec.js | 51 +++++ .../src/app/main/ui/workspace/plugins.cljs | 97 +++++---- .../src/app/main/ui/workspace/plugins.scss | 203 ++++++++---------- 5 files changed, 264 insertions(+), 155 deletions(-) create mode 100644 frontend/playwright/data/logged-in-user/get-profile-with-plugins.json create mode 100644 frontend/playwright/data/workspace/get-team-plugins.json diff --git a/frontend/playwright/data/logged-in-user/get-profile-with-plugins.json b/frontend/playwright/data/logged-in-user/get-profile-with-plugins.json new file mode 100644 index 0000000000..38dbba2b31 --- /dev/null +++ b/frontend/playwright/data/logged-in-user/get-profile-with-plugins.json @@ -0,0 +1,44 @@ +{ + "~:email": "foo@example.com", + "~:is-demo": false, + "~:auth-backend": "penpot", + "~:fullname": "Princesa Leia", + "~:modified-at": "~m1713533116365", + "~:is-active": true, + "~:default-project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b", + "~:is-muted": false, + "~:default-team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713533116365", + "~:is-blocked": false, + "~:props": { + "~:nudge": { + "~:big": 10, + "~:small": 1 + }, + "~:v2-info-shown": true, + "~:viewed-tutorial?": false, + "~:viewed-walkthrough?": false, + "~:plugins": { + "~:ids": [ + "0a5b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d" + ], + "~:data": { + "0a5b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d": { + "~:plugin-id": "0a5b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", + "~:version": 2, + "~:name": "Lorem Ipsum generator", + "~:description": "Fill text layers with placeholder text.", + "~:host": "https://plugin.example.com/", + "~:code": "plugin.js", + "~:permissions": { + "~#set": [ + "content:read", + "content:write" + ] + } + } + } + } + } +} diff --git a/frontend/playwright/data/workspace/get-team-plugins.json b/frontend/playwright/data/workspace/get-team-plugins.json new file mode 100644 index 0000000000..83d7cf47ce --- /dev/null +++ b/frontend/playwright/data/workspace/get-team-plugins.json @@ -0,0 +1,24 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type", + "plugins/runtime" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true + }, + "~:name": "Default", + "~:modified-at": "~m1713533116375", + "~:id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713533116375", + "~:is-default": true +} diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 4ff50815a0..99a79bef99 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -509,6 +509,57 @@ test.describe("Palette", () => { }); }); +test.describe("Plugins", () => { + const setupPlugins = async (workspace, profile) => { + await workspace.setupEmptyFile(); + await workspace.mockRPC("get-team?id=*", "workspace/get-team-plugins.json"); + if (profile) { + await workspace.mockRPC("get-profile", profile); + } + await workspace.goToWorkspace(); + await workspace.page.keyboard.press("Control+Alt+P"); + }; + + test("Shows the plugins manager without plugins", async ({ page }) => { + const workspace = new WorkspacePage(page); + await setupPlugins(workspace); + + await expect(page.getByText("No plugins installed yet")).toBeVisible(); + await expect(page.getByRole("button", { name: "Close" })).toBeVisible(); + await expect(page).toHaveScreenshot(); + }); + + test("Shows the plugins manager and the permissions dialog", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await page.route("https://plugin.example.com/manifest.json", (route) => + route.fulfill({ + json: { + name: "Contrast checker", + description: "Check the contrast of the selected layers.", + code: "plugin.js", + permissions: ["content:write", "user:read", "library:write"], + }, + }), + ); + await setupPlugins( + workspace, + "logged-in-user/get-profile-with-plugins.json", + ); + + await expect(page.getByText("Lorem Ipsum generator")).toBeVisible(); + await expect(page).toHaveScreenshot(); + + await page + .getByRole("textbox") + .fill("https://plugin.example.com/manifest.json"); + await page.getByRole("button", { name: "Install" }).click(); + await expect(page.getByText("CONTRAST CHECKER")).toBeVisible(); + await expect(page).toHaveScreenshot(); + }); +}); + test.describe("Nudge modal", () => { test("Shows the nudge amount modal", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index 478566b45a..cd73c45664 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -20,7 +20,7 @@ [app.main.ui.components.title-bar :refer [title-bar*]] [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.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.icons :as deprecated-icon] [app.plugins.register :as preg] [app.util.avatars :as avatars] @@ -31,9 +31,6 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(def ^:private close-icon - (deprecated-icon/icon-xref :close (stl/css :close-icon))) - (defn- normalize-plugin-url "Automatically appens manifest.json if the plugin-uri comes without it." [plugin-url] @@ -211,7 +208,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :plugin-management)} - [:button {:class (stl/css :close-btn) :on-click modal/hide!} close-icon] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}] [:div {:class (stl/css :modal-title)} (tr "workspace.plugins.title")] [:div {:class (stl/css :modal-content)} @@ -222,9 +223,9 @@ :placeholder (tr "workspace.plugins.search-placeholder") :class (stl/css-case :input-error error?)}] - [:button {:class (stl/css :primary-button) - :disabled @fetching-manifest? - :on-click on-install} + [:> button* {:variant "primary" + :disabled @fetching-manifest? + :on-click on-install} (tr "workspace.plugins.install")]] (when error-url? @@ -245,13 +246,17 @@ (if (empty? plugins-state) [:div {:class (stl/css :plugins-empty)} - [:div {:class (stl/css :plugins-empty-logo)} deprecated-icon/puzzle] + [:div {:class (stl/css :plugins-empty-logo)} + [:> icon* {:icon-id i/puzzle :size "m"}]] [:div {:class (stl/css :plugins-empty-text)} (tr "workspace.plugins.empty-plugins")] [:a {:class (stl/css :plugins-link) :href cfg/plugins-list-uri :target "_blank" :on-click #(st/emit! (ev/event {::ev/name "open-plugins-list"}))} - (tr "workspace.plugins.plugin-list-link") deprecated-icon/external-link]] + (tr "workspace.plugins.plugin-list-link") + [:> icon* {:icon-id i/external-link + :size "s" + :class (stl/css :plugins-link-icon)}]]] [:* [:> title-bar* {:collapsable false @@ -411,7 +416,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :plugin-permissions)} - [:button {:class (stl/css :close-btn) :on-click on-close-dialog} close-icon] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close-dialog}] [:div {:class (stl/css :modal-title)} (tr "workspace.plugins.permissions.title" (str/upper (:name plugin)))] [:div {:class (stl/css :modal-content)} @@ -423,17 +432,15 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input - {:class (stl/css :cancel-button :button-expand) - :type "button" - :value (tr "ds.confirm-cancel") - :on-click on-close-dialog}] + [:> button* {:variant "secondary" + :class (stl/css :button-expand) + :on-click on-close-dialog} + (tr "ds.confirm-cancel")] - [:input - {:class (stl/css :primary-button :button-expand) - :type "button" - :value (tr "ds.confirm-allow") - :on-click on-accept-dialog}]]]]])) + [:> button* {:variant "primary" + :class (stl/css :button-expand) + :on-click on-accept-dialog} + (tr "ds.confirm-allow")]]]]])) (mf/defc plugins-permissions-updated-dialog @@ -475,7 +482,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :plugin-permissions)} - [:button {:class (stl/css :close-btn) :on-click on-close-dialog} close-icon] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close-dialog}] [:div {:class (stl/css :modal-title)} (tr "workspace.plugins.permissions-update.title" (str/upper (:name plugin)))] @@ -486,17 +497,15 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input - {:class (stl/css :cancel-button :button-expand) - :type "button" - :value (tr "ds.confirm-cancel") - :on-click on-close-dialog}] + [:> button* {:variant "secondary" + :class (stl/css :button-expand) + :on-click on-close-dialog} + (tr "ds.confirm-cancel")] - [:input - {:class (stl/css :primary-button :button-expand) - :type "button" - :value (tr "ds.confirm-allow") - :on-click on-accept-dialog}]]]]])) + [:> button* {:variant "primary" + :class (stl/css :button-expand) + :on-click on-accept-dialog} + (tr "ds.confirm-allow")]]]]])) (mf/defc plugins-try-out-dialog @@ -530,7 +539,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :plugin-try-out)} - [:button {:class (stl/css :close-btn) :on-click on-close-dialog} close-icon] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close-dialog}] [:div {:class (stl/css :modal-title)} [:div {:class (stl/css :plugin-icon)} [:img {:src (if (some? icon) @@ -544,14 +557,12 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input - {:class (stl/css :cancel-button :button-expand) - :type "button" - :value (tr "workspace.plugins.try-out.cancel") - :on-click on-close-dialog}] + [:> button* {:variant "secondary" + :class (stl/css :button-expand) + :on-click on-close-dialog} + (tr "workspace.plugins.try-out.cancel")] - [:input - {:class (stl/css :primary-button :button-expand) - :type "button" - :value (tr "workspace.plugins.try-out.try") - :on-click on-accept-dialog}]]]]])) + [:> button* {:variant "primary" + :class (stl/css :button-expand) + :on-click on-accept-dialog} + (tr "workspace.plugins.try-out.try")]]]]])) diff --git a/frontend/src/app/main/ui/workspace/plugins.scss b/frontend/src/app/main/ui/workspace/plugins.scss index c67d5ea498..40f770c57c 100644 --- a/frontend/src/app/main/ui/workspace/plugins.scss +++ b/frontend/src/app/main/ui/workspace/plugins.scss @@ -4,8 +4,15 @@ // // 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/mixins.scss" as *; +@use "ds/typography.scss" as *; + +// Only for the modal placeholders (%modal-overlay-base, +// %modal-container-base), until the modals move to the DS modal. +@use "refactor/common-refactor.scss" as deprecated; .modal-overlay { @extend %modal-overlay-base; @@ -17,49 +24,47 @@ display: grid; grid-template-rows: auto 1fr auto; grid-template-columns: minmax(0, 1fr); - max-height: initial; + max-block-size: initial; &.plugin-permissions { - width: deprecated.$s-412; - max-width: deprecated.$s-412; + inline-size: px2rem(412); + max-inline-size: px2rem(412); } &.plugin-management { - width: deprecated.$s-472; - max-width: deprecated.$s-472; + inline-size: px2rem(472); + max-inline-size: px2rem(472); } &.plugin-try-out { - width: deprecated.$s-452; - max-width: deprecated.$s-452; + inline-size: px2rem(452); + max-inline-size: px2rem(452); } + // The separator of the plugins manager has no class. hr { border-color: var(--color-background-tertiary); } } .modal-footer { - margin-top: 2rem; + margin-block-start: var(--sp-xxxl); } +// Goes on the tooltip wrapper of the close icon button. .close-btn { - @extend %modal-close-btn-base; -} - -.close-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-title { - @include deprecated.headline-medium-typography; + @include use-typography("headline-medium"); - margin-block-end: deprecated.$s-32; - color: var(--modal-title-foreground-color); + margin-block-end: var(--sp-xxxl); + color: var(--color-foreground-primary); display: flex; - gap: deprecated.$s-12; + gap: var(--sp-m); } .modal-content { @@ -67,113 +72,92 @@ flex-direction: column; .plugin-permissions & { - gap: deprecated.$s-20; + gap: var(--sp-xl); } .plugin-management & { - height: deprecated.$s-380; - max-height: deprecated.$s-380; + block-size: px2rem(380); + max-block-size: px2rem(380); } } .modal-message { - font-size: deprecated.$fs-14; + // TODO: No exact DS typography match found. + // Closest match: body-medium. + font-size: px2rem(14); color: var(--color-foreground-secondary); } .modal-paragraph { - font-size: deprecated.$fs-14; + // TODO: No exact DS typography match found. + // Closest match: body-medium. + font-size: px2rem(14); color: var(--color-foreground-primary); } -.primary-button { - @extend %button-primary; - @include deprecated.headline-small-typography; - - padding: deprecated.$s-0 deprecated.$s-16; -} - +// The DS button does not center its label when it grows. .button-expand { - width: 100%; + inline-size: 100%; margin: 0; -} - -.cancel-button { - @extend %button-secondary; - @include deprecated.headline-small-typography; - - padding: deprecated.$s-0 deprecated.$s-16; -} - -.search-icon { - @include deprecated.flex-center; - - width: deprecated.$s-20; - padding: 0 0 0 deprecated.$s-8; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } + justify-content: center; } .top-bar { display: flex; - gap: deprecated.$s-8; + gap: var(--sp-s); } .open-button { display: flex; justify-content: center; align-items: center; - width: deprecated.$s-68; - min-width: deprecated.$s-68; - height: deprecated.$s-32; + inline-size: px2rem(68); + min-inline-size: px2rem(68); + block-size: $sz-32; text-transform: uppercase; } .plugins-list { - padding-top: deprecated.$s-20; + padding-block-start: var(--sp-xl); overflow: hidden auto; flex: 1; display: flex; flex-direction: column; - gap: deprecated.$s-12; + gap: var(--sp-m); } .plugins-list-element { display: flex; - gap: deprecated.$s-12; + gap: var(--sp-m); } .plugin-icon { - min-width: deprecated.$s-32; - min-height: deprecated.$s-32; - width: deprecated.$s-32; - height: deprecated.$s-32; - background: var(--button-secondary-background-color-rest); - padding: deprecated.$s-2; - border-radius: deprecated.$s-4; + min-inline-size: $sz-32; + min-block-size: $sz-32; + inline-size: $sz-32; + block-size: $sz-32; + background: var(--color-background-tertiary); + padding: var(--sp-xxs); + border-radius: $br-4; } .plugin-description { display: flex; flex-direction: column; - gap: deprecated.$s-8; - width: 100%; - min-width: 0; + gap: var(--sp-s); + inline-size: 100%; + min-inline-size: 0; } .plugin-title { - @include deprecated.body-medium-typography; + @include use-typography("body-medium"); @include text-ellipsis; color: var(--color-foreground-primary); } .plugin-summary { - @include deprecated.body-small-typography; + @include use-typography("body-small"); @include two-line-text-ellipsis; overflow-wrap: anywhere; @@ -184,116 +168,111 @@ display: flex; flex-direction: column; align-items: center; - gap: deprecated.$s-20; - height: 100%; + gap: var(--sp-xl); + block-size: 100%; justify-content: center; - padding: deprecated.$s-36 0; + padding: px2rem(36) 0; } .plugins-empty-logo { - width: deprecated.$s-44; - height: deprecated.$s-44; - border-radius: 50%; + inline-size: px2rem(44); + block-size: px2rem(44); + border-radius: $br-circle; display: flex; justify-content: center; align-items: center; background: var(--color-background-tertiary); - - svg { - width: deprecated.$s-16; - height: deprecated.$s-16; - stroke: var(--color-foreground-secondary); - fill: none; - } + color: var(--color-foreground-secondary); } .plugins-empty-text { - @include deprecated.body-small-typography; + @include use-typography("body-small"); color: var(--color-foreground-primary); } div.input-error { - border: deprecated.$s-1 solid var(--input-border-color-error); + border: $b-1 solid var(--status-color-error-500); } .info { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - margin-top: deprecated.$s-4; + margin-block-start: var(--sp-xs); + color: var(--info-foreground-color); &.error { - color: var(--input-border-color-error); + --info-foreground-color: var(--status-color-error-500); } &.success { - color: var(--input-border-color-success); + --info-foreground-color: var(--color-accent-primary); } } .plugins-link { + // TODO: No exact DS typography match found. + // Closest match: body-small. + font-size: px2rem(12); color: var(--color-accent-primary); - font-size: deprecated.$fs-12; display: inline-flex; align-items: center; - gap: deprecated.$s-4; + gap: var(--sp-xs); +} - svg { - margin-top: calc(-1 * deprecated.$s-2); - width: deprecated.$s-12; - height: deprecated.$s-12; - stroke: var(--color-accent-primary); - fill: none; - } +.plugins-link-icon { + margin-block-start: calc(-1 * var(--sp-xxs)); } .permissions-list { display: flex; flex-direction: column; - gap: deprecated.$s-24; + gap: var(--sp-xxl); } .permissions-list-entry { display: grid; - grid-template-columns: 24px 1fr; - gap: deprecated.$s-16; + grid-template-columns: $sz-24 1fr; + gap: var(--sp-l); align-items: center; + // The permission icons are legacy brand art with no class. svg { - width: deprecated.$s-24; - height: deprecated.$s-24; + inline-size: $sz-24; + block-size: $sz-24; stroke: var(--color-accent-primary); fill: none; } } .permissions-list-text { - @include deprecated.body-small-typography; + @include use-typography("body-small"); margin: 0; color: var(--color-foreground-secondary); } .permissions-disclaimer { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - padding: deprecated.$s-16; + padding: var(--sp-l); background: var(--color-background-quaternary); color: var(--color-foreground-primary); - border-radius: deprecated.$br-4; + border-radius: $br-4; } .action-buttons { display: flex; - gap: deprecated.$s-12; + gap: var(--sp-m); } .discover { - @include deprecated.body-small-typography; + @include use-typography("body-small"); color: var(--color-foreground-secondary); - margin-top: deprecated.$s-24; + margin-block-start: var(--sp-xxl); + // The link comes from a translated HTML string, with no class. a { color: var(--color-accent-primary); } From 5259b4b2d5114173a7025e2fef8731a4344508da Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 14:10:34 +0200 Subject: [PATCH 111/167] :recycle: Use DS buttons in the workspace right header (#12454) * :recycle: Use DS buttons in the workspace right header The comments and history buttons were raw buttons with deprecated icons; they become DS ghost icon buttons that show their state through aria-pressed, with the unread dot as a child of the comments button. The view mode link had a click handler and no href; it becomes a DS icon button too, so it gets a button role and keyboard activation. Their tooltips open below and to the left, at the top right corner of the screen. The zoom menu's Reset is a DS ghost button. The share link stays raw with its deprecated icon until the DS has a share icon; it now uses its own class instead of the view mode one. Add a visual test that captures the header at rest, with comments active, and with the zoom menu open. AI-assisted-by: claude-opus-5-5 * :recycle: Move workspace right header styles off the deprecated styles The comments, history and view mode buttons are DS icon buttons, so their placeholders go away; the comments class only holds the unread dot. The zoom widget color goes through a local variable, and spacing, sizes, borders and the color aliases move to DS tokens and logical properties. Delete the save status rules: the status widget moved to the left header in #5773 and nothing here uses them anymore. refactor/ stays only for the shared menu placeholders and for the tertiary button and icon placeholders of the share link. AI-assisted-by: claude-opus-5-5 * :bug: Give the action icon button a color while pressed or active The action variant of the DS icon button set the active background but not the active foreground, so it kept the base value, initial. A custom property set to initial is invalid at computed time, so the icon took the color of its container while the button was pressed (:active) or toggled on (aria-pressed): yellow in the workspace header, for example. Use the accent color, as the ghost and secondary variants do. AI-assisted-by: claude-opus-5-5 * :lipstick: Use the action variant for the right header icon buttons The comments, history and view mode buttons had no background at rest, on hover or while active. The DS action variant keeps that: no background at rest and on hover, accent icon on hover. It is 24px instead of the ghost 32px, and shows a small background while comments or history is on. The unread dot moves to 4px from the button corner, so it stays over the top right corner of the icon in the smaller button. AI-assisted-by: claude-opus-5-5 * :lipstick: Drop the background of a pressed action icon button A toggled-on action icon button (aria-pressed) showed the quaternary background next to the accent icon. Keep only the accent icon, as the workspace header buttons looked before the DS migration. The background stays for the click feedback (:active). Only the right header comments and history buttons use the action variant with aria-pressed today. AI-assisted-by: claude-opus-5-5 * :lipstick: Widen the gap between the right header actions The action icon buttons are 24px wide, smaller than the old ones, so the comments, history and view mode buttons sat closer together than before the DS migration. Raise the header gap from --sp-s to --sp-m to keep the old spacing between the icons without resizing the buttons. The separator absorbs the extra space, so the header width stays the same. AI-assisted-by: claude-opus-5-5 * :recycle: Make the right header zoom widget a button The zoom percentage of the right header opened its menu from a div with a click handler: no button role and no keyboard access. Make it a button with aria-expanded, keeping its classes, title and handler; its visible percentage is its accessible name. Reset the browser padding of the button so the percentage stays in place. AI-assisted-by: claude-opus-5-5 * :sparkles: Toggle the zoom dropdown from the zoom widget Clicking the zoom widget while its dropdown was open left it open: the handler always set the open state, and it stops the click before the dropdown sees it. The widget now toggles the dropdown, as its aria-expanded already suggests. Closing from outside or with Escape works as before. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 34 +++ .../app/main/ui/ds/buttons/icon_button.scss | 7 + .../app/main/ui/workspace/right_header.cljs | 59 ++--- .../app/main/ui/workspace/right_header.scss | 226 +++++------------- 4 files changed, 135 insertions(+), 191 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 99a79bef99..2d0752bbad 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -481,6 +481,40 @@ test.describe("Left header", () => { }); }); +test.describe("Right header", () => { + test("Shows the header actions and the zoom menu", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + const header = page.locator('[class*="workspace-header-right"]'); + await expect(header).toBeVisible(); + await page.mouse.move(640, 300); + // The buttons are a small part of the header: compare every pixel. + await expect(header).toHaveScreenshot("right-header.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await header.getByRole("button", { name: /Comments/ }).click(); + await page.mouse.move(640, 300); + await expect(header).toHaveScreenshot("right-header-comments.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + await page.keyboard.press("Escape"); + + await header.getByTitle("Zoom").click(); + await expect(page.getByText("Reset")).toBeVisible(); + await page.mouse.move(640, 300); + await expect(page).toHaveScreenshot("right-header-zoom.png", { + clip: { x: 900, y: 0, width: 380, height: 260 }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Palette", () => { test("Shows the bottom palette expanded and collapsed", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.scss b/frontend/src/app/main/ui/ds/buttons/icon_button.scss index 291c352081..85cda9703e 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.scss +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.scss @@ -41,6 +41,7 @@ --button-hover-bg-color: transparent; --button-hover-fg-color: var(--color-accent-primary); --button-active-bg-color: var(--color-background-quaternary); + --button-active-fg-color: var(--color-accent-primary); --button-disabled-bg-color: transparent; --button-disabled-fg-color: var(--color-accent-primary-muted); --button-focus-bg-color: transparent; @@ -49,6 +50,12 @@ --button-focus-outer-ring-color: var(--color-accent-primary); --button-width: #{$sz-24}; --button-height: #{$sz-24}; + + // A toggled-on action button shows only the accent icon; the background + // stays for the click feedback (:active). + &[aria-pressed="true"] { + --button-active-bg-color: transparent; + } } .flyout-indicator { diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index 28233e3204..0b2f9a9b0e 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -20,6 +20,7 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.context :as ctx] [app.main.ui.dashboard.team] + [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.exports.assets :refer [progress-widget]] @@ -44,11 +45,11 @@ custom-shortcuts (mf/deref refs/custom-shortcuts) get-tt #(sc/get-effective-tooltip % custom-shortcuts) - open-dropdown + toggle-dropdown (mf/use-fn (fn [event] (dom/stop-propagation event) - (reset! open* true))) + (swap! open* not))) close-dropdown (mf/use-fn @@ -73,10 +74,12 @@ zoom (fmt/format-percent zoom {:precision 0})] [:* - [:div {:on-click open-dropdown - :class (stl/css-case :zoom-widget true - :selected open?) - :title (tr "workspace.header.zoom")} + [:button {:type "button" + :on-click toggle-dropdown + :class (stl/css-case :zoom-widget true + :selected open?) + :title (tr "workspace.header.zoom") + :aria-expanded open?} [:span {:class (stl/css :label)} zoom]] [:& dropdown {:show open? :on-close close-dropdown} [:ul {:class (stl/css :dropdown)} @@ -91,8 +94,8 @@ :aria-label (tr "shortcuts.increase-zoom") :on-click on-increase :icon i/add}]] - [:button {:class (stl/css :reset-btn) - :on-click on-zoom-reset} + [:> button* {:variant "ghost" + :on-click on-zoom-reset} (tr "workspace.header.reset-zoom")]] [:li {:class (stl/css :zoom-option) :on-click on-zoom-fit} @@ -215,35 +218,35 @@ :on-zoom-selected on-zoom-selected}]] [:div {:class (stl/css :comments-section)} - [:button {:title (tr "workspace.toolbar.comments" (get-tt :add-comment)) - :aria-label (tr "workspace.toolbar.comments" (get-tt :add-comment)) - :class (stl/css-case :comments-btn true - :selected (= selected-drawtool :comments)) - :on-click toggle-comments - :data-tool "comments" - :style {:position "relative"}} - deprecated-icon/comments + [:> icon-button* {:variant "action" + :class (stl/css :comments-btn) + :tooltip-placement "bottom-left" + :aria-pressed (= selected-drawtool :comments) + :aria-label (tr "workspace.toolbar.comments" (get-tt :add-comment)) + :icon i/comments + :on-click toggle-comments + :data-tool "comments"} (when ^boolean has-unread-comments? [:div {:class (stl/css :unread)}])]] (when-not ^boolean read-only? [:div {:class (stl/css :history-section)} - [:button - {:title (tr "workspace.sidebar.history") - :aria-label (tr "workspace.sidebar.history") - :class (stl/css-case :selected (contains? layout :document-history) - :history-button true) - :on-click toggle-history} - deprecated-icon/history]]) + [:> icon-button* {:variant "action" + :tooltip-placement "bottom-left" + :aria-pressed (contains? layout :document-history) + :aria-label (tr "workspace.sidebar.history") + :icon i/history + :on-click toggle-history}]]) (when display-share-button? - [:a {:class (stl/css :viewer-btn) + [:a {:class (stl/css :share-btn) :title (tr "workspace.header.share") :on-click open-share-dialog} deprecated-icon/share]) - [:a {:class (stl/css :viewer-btn) - :title (tr "workspace.header.viewer" (get-tt :open-viewer)) - :on-click nav-to-viewer} - deprecated-icon/play]])) + [:> icon-button* {:variant "action" + :tooltip-placement "bottom-left" + :aria-label (tr "workspace.header.viewer" (get-tt :open-viewer)) + :icon i/play + :on-click nav-to-viewer}]])) diff --git a/frontend/src/app/main/ui/workspace/right_header.scss b/frontend/src/app/main/ui/workspace/right_header.scss index 2b338659aa..040b270490 100644 --- a/frontend/src/app/main/ui/workspace/right_header.scss +++ b/frontend/src/app/main/ui/workspace/right_header.scss @@ -4,23 +4,30 @@ // // 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 *; + +// Only for the shared menu placeholders, and the button placeholders of +// the share link until the DS has a share icon. @use "refactor/common-refactor.scss" as deprecated; .workspace-header-right { display: flex; justify-content: space-between; align-items: center; - min-width: deprecated.$s-256; - padding: deprecated.$s-8; - gap: deprecated.$s-8; - background-color: var(--panel-background-color); + min-inline-size: px2rem(256); + padding: var(--sp-s); + gap: var(--sp-m); + background-color: var(--color-background-primary); } .users-section { position: relative; - min-width: deprecated.$s-32; - max-width: deprecated.$s-72; - padding: deprecated.$s-4 deprecated.$s-6; + min-inline-size: $sz-32; + max-inline-size: px2rem(72); + padding: var(--sp-xs) px2rem(6); } .separator { @@ -28,49 +35,46 @@ } .zoom-widget { - @include deprecated.button-style; + --zoom-widget-foreground-color: var(--color-foreground-secondary); display: flex; align-items: center; justify-content: center; - height: deprecated.$s-28; - max-width: deprecated.$s-48; - width: deprecated.$s-48; - border-radius: deprecated.$br-8; + block-size: $sz-28; + max-inline-size: $sz-48; + inline-size: $sz-48; + padding: 0; + border: none; + border-radius: $br-8; + background: none; + cursor: pointer; .label { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - height: 100%; - padding: deprecated.$s-8 0; - color: var(--button-tertiary-foreground-color-rest); - } - - &:hover { - .label { - color: var(--button-tertiary-foreground-color-focus); - } + block-size: 100%; + padding: var(--sp-s) 0; + color: var(--zoom-widget-foreground-color); } + &:hover, &.selected { - .label { - color: var(--button-tertiary-foreground-color-focus); - } + --zoom-widget-foreground-color: var(--color-foreground-primary); } } .dropdown { @extend %menu-dropdown; - right: deprecated.$s-2; - top: calc(deprecated.$s-2 + deprecated.$s-48); - width: deprecated.$s-272; + inset-inline-end: px2rem(2); + inset-block-start: px2rem(50); + inline-size: px2rem(272); } .basic-zoom-bar { display: flex; justify-content: space-between; - padding: deprecated.$s-6; + padding: px2rem(6); cursor: auto; } @@ -79,21 +83,14 @@ } .zoom-text { - @include deprecated.flex-center; - - height: 100%; - min-width: deprecated.$s-48; + display: flex; + justify-content: center; + align-items: center; + block-size: 100%; + min-inline-size: $sz-48; padding: 0; - margin: 0 deprecated.$s-2; - color: var(--modal-title-foreground-color); -} - -.reset-btn { - @extend %button-tertiary; - - color: var(--button-tertiary-foreground-color-hover); - height: deprecated.$s-28; - border-radius: deprecated.$br-8; + margin: 0 px2rem(2); + color: var(--color-foreground-primary); } .zoom-option { @@ -108,137 +105,38 @@ } &:hover { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); .shortcuts { .shortcut-key { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); } } } } +// Holds the unread dot. .comments-btn { + position: relative; +} + +// Raw link until the DS has a share icon. +.share-btn { @extend %button-tertiary; - border-radius: deprecated.$br-8; + border-radius: $br-8; margin: 0; - height: deprecated.$s-28; - width: deprecated.$s-28; + inline-size: $sz-28; + block-size: $sz-28; border: none; + // The legacy share icon has no class. svg { @extend %button-icon; - stroke: var(--icon-foreground); - height: deprecated.$s-16; - width: deprecated.$s-16; - } - - &:hover { - background-color: transparent; - border: none; - } - - &.selected { - background-color: var(--button-tertiary-background-color-selected); - - svg { - stroke: var(--button-tertiary-foreground-color-active); - } - } -} - -.history-button { - @extend %button-tertiary; - - border-radius: deprecated.$br-8; - margin: 0; - height: deprecated.$s-28; - width: deprecated.$s-28; - border: none; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - height: deprecated.$s-16; - width: deprecated.$s-16; - } - - &:hover { - background-color: transparent; - border: none; - } - - &.selected { - background-color: var(--button-tertiary-background-color-selected); - - svg { - stroke: var(--button-tertiary-foreground-color-active); - } - } -} - -.persistence-status-widget { - @include deprecated.flex-center; - - width: deprecated.$s-28; - height: deprecated.$s-28; -} - -.status-icon { - @include deprecated.flex-center; - - width: deprecated.$s-24; - height: deprecated.$s-24; - margin: 0; - border-radius: deprecated.$br-circle; - - svg { - @extend %button-icon; - - stroke: var(--status-widget-icon-foreground-color); - } -} - -.pending-status { - background-color: var(--status-widget-background-color-warning); -} - -.saving-status { - background-color: var(--status-widget-background-color-pending); - - svg { - animation: spin-animation 1s infinite; - animation-timing-function: linear; - } -} - -.saved-status { - background-color: var(--status-widget-background-color-success); -} - -.error-status { - background-color: var(--status-widget-background-color-error); -} - -.share-btn, -.viewer-btn { - @extend %button-tertiary; - - border-radius: deprecated.$br-8; - margin: 0; - width: deprecated.$s-28; - height: deprecated.$s-28; - border: none; - - svg { - @extend %button-icon; - - height: deprecated.$s-16; - width: deprecated.$s-16; - stroke: var(--icon-foreground); + block-size: $sz-16; + inline-size: $sz-16; + stroke: var(--color-foreground-secondary); } &:hover { @@ -249,11 +147,13 @@ .unread { position: absolute; - width: 8px; - height: 8px; - border: 2px solid var(--color-background-tertiary); - border-radius: 50%; + inline-size: px2rem(8); + block-size: px2rem(8); + border: $b-2 solid var(--color-background-tertiary); + border-radius: $br-circle; background: var(--color-foreground-error); - top: 6px; - right: 6px; + + // Over the top right corner of the icon, 4px from the 24px button edge. + inset-block-start: var(--sp-xs); + inset-inline-end: var(--sp-xs); } From ce84fce0dcec843281f78d79b303f32d21715fe5 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 14:10:53 +0200 Subject: [PATCH 112/167] :lipstick: Remove the border from the boolean operation buttons (#12510) The boolean operation buttons at the top of the Design tab showed the 2px border of the legacy radio buttons. The design team is reworking this section; for now only the border goes away, in normal and selected state, by making the border color variables transparent there. AI-assisted-by: claude-opus-5-5 --- .../src/app/main/ui/workspace/sidebar/options/menus/bool.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss index 2a03d0fd51..afd0ca9dac 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss @@ -23,6 +23,9 @@ } .boolean-radio-btn { + --radio-btn-border-color: transparent; + --radio-btn-border-color-selected: transparent; + background-color: transparent; gap: var(--sp-xs); } From 1d54d170cfb822e076c4b9d96c5271ca26f65abe Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 14:11:15 +0200 Subject: [PATCH 113/167] :recycle: Move layers panel styles off the deprecated button styles (#12478) The layers panel already uses DS icons and buttons; its two raw buttons (the layer filter pill and the focus mode title) stay raw, since they are custom widgets. Their styles still used the deprecated %button-tag placeholder and color aliases. - Inline %button-tag in the filter pill: border, cursor, centering, and its focus and pressed states through local variables. Its hover rule set the icons to the color they already had, so it goes away. - Resolve the icon, title, pill and menu color aliases to DS tokens. The filter menu item texts are the same color in every state, so only the background changes, through a local variable. The refactor import stays for the shared %menu-dropdown placeholder. Add a visual test of the focus mode title, a filter pill and the filter menu; the panel looks the same as before, pixel for pixel. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 45 ++++++++ .../app/main/ui/workspace/sidebar/layers.scss | 101 ++++++++---------- 2 files changed, 91 insertions(+), 55 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 2d0752bbad..f324163b4a 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -418,6 +418,51 @@ test.describe("Debug shape panel", () => { }); }); +test.describe("Layers panel", () => { + test("Shows the focus mode title and the layer filters", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickLeafLayer("Board"); + await page.keyboard.press("f"); + await expect(workspace.sidebar.getByText("Focus mode")).toBeVisible(); + await page.mouse.move(640, 600); + await expect(workspace.sidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await page.keyboard.press("f"); + await expect(workspace.sidebar.getByText("Focus mode")).toBeHidden(); + await workspace.sidebar.getByRole("button", { name: "Search" }).click(); + await workspace.sidebar.getByRole("button", { name: "Filter" }).click(); + await workspace.sidebar.getByText("Boards", { exact: true }).click(); + await page.mouse.move(640, 600); + // The "Boards" filter pill. The clip leaves out the pages divider and + // the search bar, whose transitions may still be running. + const filtersClip = { x: 0, y: 340, width: 318, height: 380 }; + await expect(page).toHaveScreenshot({ + clip: filtersClip, + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await workspace.sidebar.getByRole("button", { name: "Filter" }).click(); + await page.mouse.move(640, 600); + // The filter menu open, with its option ticked. + await expect(page).toHaveScreenshot({ + clip: filtersClip, + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Guides", () => { test("Shows the advanced options of a square guide", async ({ page }) => { // A taller window keeps the whole Design tab visible: a scrolled diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.scss b/frontend/src/app/main/ui/workspace/sidebar/layers.scss index 4ff85ab137..e702e9c9fa 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.scss @@ -4,6 +4,7 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared %menu-dropdown placeholder of the filters menu. @use "refactor/common-refactor.scss" as deprecated; @use "ds/borders.scss" as *; @use "ds/mixins.scss" as *; @@ -63,7 +64,7 @@ justify-content: center; block-size: var(--sp-xxxl); padding-inline-start: var(--sp-s); - color: var(--icon-foreground); + color: var(--color-foreground-secondary); } .focus-name { @@ -71,7 +72,7 @@ @include text-ellipsis; padding-inline-end: var(--sp-xs); - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); } .focus-mode-tag-wrapper { @@ -91,18 +92,36 @@ } .layer-filter { - @extend %button-tag; + --layer-filter-background-color: var(--color-background-tertiary); + --layer-filter-border: none; + display: flex; + justify-content: center; + align-items: center; gap: px2rem(6); block-size: var(--sp-xxl); margin: var(--sp-xxs) 0; + border: var(--layer-filter-border); border-radius: $br-6; - background-color: var(--pill-background-color); + background-color: var(--layer-filter-background-color); + cursor: pointer; + + &:focus-visible { + --layer-filter-background-color: var(--color-background-secondary); + --layer-filter-border: #{$b-1} solid var(--color-accent-primary); + + outline: none; + } + + &:active { + --layer-filter-background-color: transparent; + --layer-filter-border: none; + } } .layer-filter-icon, .layer-filter-close { - color: var(--pill-foreground-color); + color: var(--color-foreground-primary); } .layer-filter-name { @@ -111,7 +130,7 @@ display: flex; align-items: center; justify-content: center; - color: var(--pill-foreground-color); + color: var(--color-foreground-primary); } .layers { @@ -213,6 +232,8 @@ inline-size: $sz-192; .filter-menu-item { + --filter-menu-item-background-color: transparent; + @include t.use-typography("body-small"); display: flex; @@ -221,60 +242,30 @@ inline-size: 100%; padding: px2rem(6); border-radius: $br-8; - - .filter-menu-item-name-wrapper { - display: flex; - align-items: center; - gap: var(--sp-s); - - .filter-menu-item-icon { - color: var(--menu-foreground-color); - } - - .filter-menu-item-name { - padding-block-start: var(--sp-xxs); - color: var(--menu-foreground-color); - } - } - - .filter-menu-item-tick { - color: var(--menu-foreground-color); - } + background-color: var(--filter-menu-item-background-color); &.selected { - background-color: var(--menu-background-color-selected); - - .filter-menu-item-name-wrapper { - .filter-menu-item-icon { - color: var(--menu-foreground-color); - } - - .filter-menu-item-name { - color: var(--menu-foreground-color); - } - } - - .filter-menu-item-tick { - color: var(--menu-foreground-color); - } + --filter-menu-item-background-color: var(--color-background-tertiary); } &:hover { - background-color: var(--menu-background-color-hover); - - .filter-menu-item-name-wrapper { - .filter-menu-item-icon { - color: var(--menu-foreground-color-hover); - } - - .filter-menu-item-name { - color: var(--menu-foreground-color-hover); - } - } - - .filter-menu-item-tick { - color: var(--menu-foreground-color-hover); - } + --filter-menu-item-background-color: var(--color-background-quaternary); } } + + .filter-menu-item-name-wrapper { + display: flex; + align-items: center; + gap: var(--sp-s); + } + + .filter-menu-item-icon, + .filter-menu-item-name, + .filter-menu-item-tick { + color: var(--color-foreground-primary); + } + + .filter-menu-item-name { + padding-block-start: var(--sp-xxs); + } } From 95bc48a08a7ccaafec5c0ab5eabab9424d20087a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 21 Oct 2026 12:09:08 +0000 Subject: [PATCH 114/167] :bug: Fix Admin Console URLs escaped twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Admin Console location built its proxy_pass target from a capture of $request_uri, which nginx escapes again when the path has escaped characters: %XX became %25XX and the query string ended up in the path, so Admin Console links returned 404. Strip the prefix with a rewrite and let proxy_pass forward the rewritten URI instead. AI-assisted-by: Claude Signed-off-by: David Barragán Merino --- .../nginx-admin-console-locations.conf.template | 11 ++++------- docker/images/files/nginx-entrypoint.sh | 13 ++++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template index 2b0a4f95b8..a2a90913b0 100644 --- a/docker/images/files/nginx-admin-console-locations.conf.template +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -1,14 +1,11 @@ -# The Admin Console is served at the root of its own server: strip the public -# path and admin-console/ from the original request URI. +# The Admin Console is served at the root of its own server. Keep proxy_pass +# without a URI: building it from $request_uri escapes the path twice. location /admin-console { - set $admin_console_uri "/"; - if ($request_uri ~ "^${PENPOT_PUBLIC_PATH_REGEX}admin-console(/.*)$") { - set $admin_console_uri $1; - } + rewrite "^/admin-console(?:/(.*))?$" /$1 break; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $http_cf_connecting_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_ADMIN_CONSOLE_URI$admin_console_uri; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI; } diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index e141db2d3a..d60a5d691f 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -53,18 +53,17 @@ update_oidc_name /var/www/app/js/config.js # Path of PENPOT_PUBLIC_URI (e.g. /penpot/ for https://example.com/penpot/). # Requests below it are rewritten to the root, so the proxy in front of this -# server forwards them unchanged and $request_uri keeps the original path. +# server forwards them unchanged. PENPOT_PUBLIC_PATH="$(sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://[^/]*||; s|[?#].*$||' <<< "${PENPOT_PUBLIC_URI:-}")" PENPOT_PUBLIC_PATH="/${PENPOT_PUBLIC_PATH#/}" export PENPOT_PUBLIC_PATH="${PENPOT_PUBLIC_PATH%/}/" -# shellcheck disable=SC2016 # literal regex metacharacters -PENPOT_PUBLIC_PATH_REGEX="$(sed -E 's/[][\.*^$(){}+?|]/\\&/g' <<< "$PENPOT_PUBLIC_PATH")" -export PENPOT_PUBLIC_PATH_REGEX - if [ "$PENPOT_PUBLIC_PATH" != "/" ]; then + # shellcheck disable=SC2016 # literal regex metacharacters + _public_path_regex="$(sed -E 's/[][\.*^$(){}+?|]/\\&/g' <<< "$PENPOT_PUBLIC_PATH")" export PENPOT_PUBLIC_PATH_DIRECTIVES="location = ${PENPOT_PUBLIC_PATH%/} { absolute_redirect off; return 301 ${PENPOT_PUBLIC_PATH}\$is_args\$args; } - rewrite ^${PENPOT_PUBLIC_PATH_REGEX}(.*)\$ /\$1 last;" + rewrite ^${_public_path_regex}(.*)\$ /\$1 last;" + unset _public_path_regex else export PENPOT_PUBLIC_PATH_DIRECTIVES="" fi @@ -81,7 +80,7 @@ envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BO if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000} - envsubst "\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_PUBLIC_PATH_REGEX" \ + envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \ < /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf else rm -f /etc/nginx/overrides/server.d/admin-console-locations.conf From d6e850e917f0c8a88880301ef1b0f905740ddab1 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 14:35:12 +0200 Subject: [PATCH 115/167] :recycle: Use DS icon buttons for the bottom palette toggles (#12443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: Use DS icon buttons for the bottom palette toggles The color palette and typographies toggles of the bottom palette were raw buttons with deprecated icons. Replace them with DS ghost icon buttons with small icons, as before, that show the open palette through aria-pressed. The layout flags are keywords, so they are coerced to a boolean for aria-pressed. Their title moves to the label, shown in the DS tooltip with the shortcut. The palette actions button spans the palette height, not a DS button size, so it stays raw; only its deprecated icon moves to the DS icon. Add a visual test that compares the palette with each toggle selected with no tolerance. AI-assisted-by: claude-opus-5-5 * :recycle: Move bottom palette styles off the deprecated styles The palette toggles are DS icon buttons, so their placeholders go away. The actions button keeps the tertiary button look through local state variables, and its icon takes the color from the button. Spacing, sizes, borders, opacity and the color aliases move to DS tokens and logical properties, except right and width of the palette, which are the transitioned properties. The grid template is split into areas, rows and columns, since Sass reads its slash as a division with a size variable. The z-index values came from the legacy global scale. The resize area, the toggles and the actions button had their own (4, 2 and 2), plus 0 overrides while collapsed, but they sit in separate grid areas and the palette content does not reach them: removing them changes no pixel with the text or color palette, the actions menu open, the 80px size, or the palette collapsed and hidden. Only the palette's own z-index stays, with a comment: it keeps the palette above the viewport overlays (z-index 1), and drops to their level while collapsed. The help button styles are not used in palette.cljs, but the external chat widget injected into the palette wrapper may use them, so they stay, inlined on DS tokens. palette.scss no longer imports refactor/. AI-assisted-by: claude-opus-5-5 * :recycle: Use a DS icon button for the bottom palette actions The palette actions button (⋮) stayed a raw button because it spans the palette height, not a DS button size. The DS icon button takes its height from --button-height, so a class now stretches it to the palette and the hand-made colors and states go away. It uses the ghost variant, like the palette toggles next to it. The button had no accessible name and did not say that it opens a menu: it now has the "Palette options" label (new en and es keys) with its tooltip, aria-expanded and aria-haspopup. The class given to its icon did not exist in palette.scss and is gone. Only the button corners change in the palette screenshots: the raw button painted its own rounded background, the ghost button is transparent at rest. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 34 +++ .../src/app/main/ui/workspace/palette.cljs | 42 ++-- .../src/app/main/ui/workspace/palette.scss | 225 ++++++++++-------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 5 files changed, 192 insertions(+), 117 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index f324163b4a..6a7b7325e9 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -586,6 +586,40 @@ test.describe("Palette", () => { ), ).toBeVisible(); }); + + test("Shows the palette toggles selected", async ({ page }) => { + const workspace = new WorkspacePage(page); + const { fileId, pageId } = await setupFileWithAssets(workspace); + + await workspace.goToWorkspace({ fileId, pageId }); + + await workspace.palette + .getByRole("button", { name: "Typographies" }) + .click(); + await expect( + workspace.palette.getByText("Source Sans Pro Regular"), + ).toBeVisible(); + await page.mouse.move(640, 300); + // The toggles are a small part of the palette: compare every pixel. + await expect(workspace.palette).toHaveScreenshot("palette-text.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await workspace.palette + .getByRole("button", { name: "Color Palette" }) + .click(); + await expect( + workspace.palette.getByText( + "There are no color styles in your library yet", + ), + ).toBeVisible(); + await page.mouse.move(640, 300); + await expect(workspace.palette).toHaveScreenshot("palette-color.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); }); test.describe("Plugins", () => { diff --git a/frontend/src/app/main/ui/workspace/palette.cljs b/frontend/src/app/main/ui/workspace/palette.cljs index 7887add48d..2f27304d97 100644 --- a/frontend/src/app/main/ui/workspace/palette.cljs +++ b/frontend/src/app/main/ui/workspace/palette.cljs @@ -18,9 +18,10 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.hooks :as h] [app.main.ui.hooks.resize :as r] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.color-palette :refer [color-palette*]] [app.main.ui.workspace.color-palette-ctx-menu :refer [color-palette-ctx-menu*]] [app.main.ui.workspace.text-palette :refer [text-palette*]] @@ -95,6 +96,11 @@ (fn [_] (swap! state* assoc :show-menu false))) + on-toggle-menu + (mf/use-fn + (fn [_] + (swap! state* update :show-menu not))) + on-select-palette (mf/use-fn (mf/deps on-select) @@ -182,27 +188,31 @@ [:ul {:class (dm/str size-classname " " (stl/css-case :palette-btn-list true :hidden-bts hide-palettes?))} [:li {:class (stl/css :palette-item)} - [:button {:title (tr "workspace.toolbar.color-palette" (get-tt :toggle-colorpalette)) - :aria-label (tr "workspace.toolbar.color-palette" (get-tt :toggle-colorpalette)) - :class (stl/css-case :palette-btn true - :selected color-palette?) - :on-click on-select-color-palette} - deprecated-icon/drop-icon]] + [:> icon-button* {:variant "ghost" + :aria-pressed (boolean color-palette?) + :icon-size "s" + :aria-label (tr "workspace.toolbar.color-palette" (get-tt :toggle-colorpalette)) + :icon i/drop + :on-click on-select-color-palette}]] [:li {:class (stl/css :palette-item)} - [:button {:title (tr "workspace.toolbar.text-palette" (get-tt :toggle-textpalette)) - :aria-label (tr "workspace.toolbar.text-palette" (get-tt :toggle-textpalette)) - :class (stl/css-case :palette-btn true - :selected text-palette?) - :on-click on-select-text-palette} - deprecated-icon/text-palette]]] + [:> icon-button* {:variant "ghost" + :aria-pressed (boolean text-palette?) + :icon-size "s" + :aria-label (tr "workspace.toolbar.text-palette" (get-tt :toggle-textpalette)) + :icon i/text-palette + :on-click on-select-text-palette}]]] (if any-palette? [:* - [:button {:class (stl/css :palette-actions) - :on-click #(swap! state* update :show-menu not)} - deprecated-icon/menu] + [:> icon-button* {:variant "ghost" + :class (stl/css :palette-actions) + :aria-label (tr "workspace.toolbar.palette-options") + :aria-expanded (boolean show-menu?) + :aria-haspopup "menu" + :icon i/menu + :on-click on-toggle-menu}] [:div {:class (stl/css :palette) :ref container} (when text-palette? diff --git a/frontend/src/app/main/ui/workspace/palette.scss b/frontend/src/app/main/ui/workspace/palette.scss index b61814d2ba..d743596d59 100644 --- a/frontend/src/app/main/ui/workspace/palette.scss +++ b/frontend/src/app/main/ui/workspace/palette.scss @@ -4,17 +4,16 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "ds/spacing.scss" as *; -@use "ds/z-index.scss" as *; +@use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; .palette-wrapper { position: absolute; - width: 100vw; - left: 0; - bottom: 0; - padding-bottom: deprecated.$s-4; + inline-size: 100vw; + inset-inline-start: 0; + inset-block-end: 0; + padding-block-end: var(--sp-xs); /** Aligns AI Chat button **/ display: flex; @@ -23,20 +22,26 @@ gap: var(--sp-s); } +// `right` and `width` stay physical: they are the transitioned properties. .palettes { - z-index: deprecated.$z-index-2; + // Above .viewport-overlays (z-index 1), which covers the whole viewport. + z-index: 2; position: relative; right: 0; grid-area: color-palette; display: grid; - grid-template: "resize resize resize" deprecated.$s-8 "buttons actions palette" 1fr / deprecated.$s-32 auto 1fr; - max-height: deprecated.$s-80; - height: var(--height); + grid-template-areas: + "resize resize resize" + "buttons actions palette"; + grid-template-rows: px2rem(8) 1fr; + grid-template-columns: $sz-32 auto 1fr; + max-block-size: px2rem(80); + block-size: var(--height); width: fit-content; - padding: deprecated.$s-0 deprecated.$s-0 deprecated.$s-8 deprecated.$s-8; - border-radius: deprecated.$br-8; - background-color: var(--palette-background-color); - border: deprecated.$s-2 solid var(--panel-border-color); + padding: 0 0 var(--sp-s) var(--sp-s); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); transition: right 0.3s, opacity 0.2s, @@ -48,23 +53,21 @@ .resize-area { grid-area: resize; - height: deprecated.$s-8; - z-index: deprecated.$z-index-4; - width: calc(100% - deprecated.$s-8); - border-radius: deprecated.$br-circle; + block-size: px2rem(8); + inline-size: calc(100% - px2rem(8)); + border-radius: $br-circle; cursor: ns-resize; - background-color: var(--palette-background-color); + background-color: var(--color-background-primary); } .palette-btn-list { grid-area: buttons; - background-color: var(--palette-background-color); - height: calc(var(--height) - deprecated.$s-16); - width: deprecated.$s-32; - margin: deprecated.$s-0; + background-color: var(--color-background-primary); + block-size: calc(var(--height) - px2rem(16)); + inline-size: $sz-32; + margin: 0; list-style: none; - z-index: deprecated.$z-index-2; - gap: deprecated.$s-2; + gap: var(--sp-xxs); &.mid-palette, &.small-palette { @@ -72,151 +75,171 @@ } .palette-item { - @include deprecated.flex-center; - - border-radius: deprecated.$br-8; - opacity: deprecated.$op-10; + display: flex; + justify-content: center; + align-items: center; + border-radius: $br-8; + opacity: 1; transition: opacity 1s ease; - - .palette-btn { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-32; - border-radius: deprecated.$br-8; - background-clip: padding-box; - padding: 0; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } - - &.selected { - @extend %button-icon-selected; - } - } } } + // DS icon button stretched to the palette height. .palette-actions { - @extend %button-tertiary; + --button-height: calc(var(--height) - #{px2rem(16)}); grid-area: actions; - height: calc(var(--height) - deprecated.$s-16); - width: deprecated.$s-32; - padding: 0; - margin-left: deprecated.$s-4; - border-radius: deprecated.$br-8; - background-color: var(--palette-background-color); - z-index: deprecated.$z-index-2; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } + margin-inline-start: var(--sp-xs); } .palette { grid-area: palette; - width: 100%; - min-width: 0; + inline-size: 100%; + min-inline-size: 0; } } .handler { - @include deprecated.button-style; - @include deprecated.flex-center; - - width: deprecated.$s-12; - height: 100%; + display: flex; + justify-content: center; + align-items: center; + border: none; + background: none; + cursor: pointer; + inline-size: $sz-12; + block-size: 100%; .handler-btn { - width: deprecated.$s-4; - height: 100%; - max-height: deprecated.$s-40; - margin: deprecated.$s-8 deprecated.$s-4; + inline-size: px2rem(4); + block-size: 100%; + max-block-size: $sz-40; + margin: var(--sp-s) var(--sp-xs); padding: 0; - border-radius: deprecated.$s-4; - background-color: var(--palette-handler-background-color); + border-radius: $br-4; + background-color: var(--color-background-quaternary); } } .mid-palette, .small-palette { - grid-template-columns: deprecated.$s-64 auto 1fr; + grid-template-columns: px2rem(64) auto 1fr; } .hidden-bts { - right: deprecated.$s-2; - z-index: deprecated.$z-index-1; - width: 22px; - grid-template-columns: deprecated.$s-8 auto 1fr; + right: px2rem(2); + + // Back to the .viewport-overlays level (z-index 1) while collapsed. + z-index: 1; + width: px2rem(22); + grid-template-columns: px2rem(8) auto 1fr; padding: 0; border-inline-start: 0; border-start-start-radius: 0; border-end-start-radius: 0; .palette-btn-list { - opacity: deprecated.$op-0; + opacity: 0; visibility: hidden; - width: 0; + inline-size: 0; .palette-item { - opacity: deprecated.$op-0; + opacity: 0; visibility: hidden; - z-index: 0; } } .resize-area { visibility: hidden; - z-index: 0; - width: 0; + inline-size: 0; } .palette-actions { visibility: hidden; - z-index: 0; } .palette { visibility: hidden; - z-index: 0; } .handler { - padding-bottom: deprecated.$s-8; + padding-block-end: var(--sp-s); } } /** AI Chat button styles **/ +// Not used in palette.cljs: the external chat widget (Inkeep, see #6509) +// injected into #palette-wrapper may use these class names, so they stay. .help-btn { + --help-btn-foreground-color: var(--color-foreground-secondary); + --help-btn-background-color: var(--color-background-tertiary); + z-index: var(--z-index-panels); flex-shrink: 0; - - @extend %button-secondary; - + display: flex; + justify-content: center; + align-items: center; inline-size: $sz-40; block-size: $sz-40; - border-radius: deprecated.$br-circle; border: none; + border-radius: $br-circle; + background-color: var(--help-btn-background-color); + color: var(--help-btn-foreground-color); + cursor: pointer; - &.selected { - @extend %button-icon-selected; + // The widget markup is external, so its icon has no known class. + svg { + stroke: var(--help-btn-foreground-color); } &:hover { - border: none; + --help-btn-foreground-color: var(--color-accent-primary); + --help-btn-background-color: var(--color-background-quaternary); + + text-decoration: none; + } + + &:active { + --help-btn-foreground-color: var(--color-accent-primary); + --help-btn-background-color: var(--color-background-secondary); + + outline: none; + border: $b-1 solid var(--color-background-quaternary); + } + + &:focus-visible { + --help-btn-foreground-color: var(--color-foreground-secondary); + --help-btn-background-color: var(--color-background-tertiary); + + outline: none; + border: $b-1 solid var(--color-accent-primary); + } + + &:global(.disabled), + &[disabled], + &:disabled { + --help-btn-foreground-color: var(--color-foreground-disabled); + --help-btn-background-color: var(--color-background-quaternary); + + border: $b-1 solid var(--color-background-quaternary); + cursor: unset; + } + + &.selected { + --help-btn-foreground-color: var(--color-accent-primary); + --help-btn-background-color: var(--color-background-quaternary); + + outline: none; } } .icon-help { - @extend %button-icon; - - stroke: var(--icon-foreground); + display: flex; + justify-content: center; + align-items: center; inline-size: var(--sp-xxl); block-size: var(--sp-xxl); + color: transparent; + fill: none; + stroke-width: 1px; + stroke: var(--color-foreground-secondary); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 9e4020a717..078f9c694b 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -9354,6 +9354,10 @@ msgstr "Move (%s)" msgid "workspace.toolbar.palette-bar" msgstr "Palette bar" +#: src/app/main/ui/workspace/palette.cljs:211 +msgid "workspace.toolbar.palette-options" +msgstr "Palette options" + #: src/app/main/ui/workspace/top_toolbar.cljs:72 msgid "workspace.toolbar.path" msgstr "Path (%s)" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 1cf8ab2e51..23f9f2f47c 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9460,6 +9460,10 @@ msgstr "Mover (%s)" msgid "workspace.toolbar.palette-bar" msgstr "Barra de paletas" +#: src/app/main/ui/workspace/palette.cljs:211 +msgid "workspace.toolbar.palette-options" +msgstr "Opciones de la paleta" + #: src/app/main/ui/workspace/top_toolbar.cljs:72 msgid "workspace.toolbar.path" msgstr "Ruta (%s)" From 338d009c1eaeec6bbdd6c39015527805f961924c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 21 Oct 2026 15:12:41 +0200 Subject: [PATCH 116/167] :bug: Fix sorting for accented teams and organizations (#12505) * :bug: Fix sorting for accented teams and organizations * :paperclip: Code review --- .../dashboard/organization_team_switch.cljs | 18 ++++++++++++++--- .../ui/organization_team_switch_test.cljs | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs b/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs index 9d99ecb3c0..62144bfbf7 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs @@ -69,6 +69,18 @@ (nil? (dm/get-in team [:organization :id])) (contains? #{"unlimited" "enterprise"} (get-subscription-type (:subscription team))))) +(def ^:private combining-marks-re (js/RegExp. "\\p{M}" "gu")) + +(defn- name-sort-key + "Key that sorts `label` alphabetically regardless of case and + accents: NFD splits an accented letter into its base letter plus a + combining mark, and dropping the marks makes \"Ábaco\" sort like + \"abaco\" instead of after \"z\"." + [label] + (-> (.normalize label "NFD") + (str/replace combining-marks-re "") + (str/lower))) + (defn sort-organization-teams "Orders the teams of a single organization for the dropdown's second column: alphabetical, with the organization's default team @@ -76,7 +88,7 @@ `:is-default` and an explicit `false` into one comparator value." [teams] (sort-by (juxt (fn [team] (boolean (:is-default team))) - (fn [team] (str/lower (:name team "")))) + (fn [team] (name-sort-key (:name team "")))) teams)) (defn sort-all-teams @@ -89,7 +101,7 @@ (sort-all-teams teams team-display-name)) ([teams display-name] (sort-by (juxt (fn [team] (boolean (:is-default team))) - (comp str/lower display-name)) + (comp name-sort-key display-name)) teams))) (defn teams-for-organization @@ -113,7 +125,7 @@ stable." [organizations] (sort-by (juxt (fn [organization] (if (nil? (:id organization)) 1 0)) - (fn [organization] (str/lower (:name organization ""))) + (fn [organization] (name-sort-key (:name organization ""))) :id) organizations)) diff --git a/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs b/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs index 6cf00fa228..6d10a260f4 100644 --- a/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs +++ b/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs @@ -46,6 +46,26 @@ (t/is (= ["org-a" "org-b" "org-c" nil] (map :id (dts/sort-organizations organizations)))))) +(t/deftest names-starting-with-an-accented-letter-sort-as-the-plain-letter + ;; A code-point comparison would push "Ábaco", "Órbita" and "Ámbar" + ;; after "z"; accented letters must sort like their base letter. + (let [organizations [{:id "org-1" :name "OrgA"} + nil + {:id "org-2" :name "Órbita"} + {:id "org-3" :name "beta"} + {:id "org-4" :name "Ábaco"}] + teams [{:id "t1" :name "zeta"} + {:id "t2" :name "Personal projects" :is-default true} + {:id "t3" :name "apricot"} + {:id "t4" :name "Ámbar"}] + display-name (fn [team] (if (:is-default team) "Personal projects" (:name team)))] + (t/is (= ["Ábaco" "beta" "Órbita" "OrgA" nil] + (map :name (dts/sort-organizations organizations)))) + (t/is (= ["Ámbar" "apricot" "zeta" "Personal projects"] + (map :name (dts/sort-organization-teams teams)))) + (t/is (= ["Ámbar" "apricot" "zeta" "Personal projects"] + (map :name (dts/sort-all-teams teams display-name)))))) + (t/deftest closed-control-line-2-matrix (t/testing "profile with organizations, destination has an organization" (t/is (= "Org A" From 05e1981accc41a52326815c8a418fc1db15c43fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 21 Oct 2026 15:30:46 +0200 Subject: [PATCH 117/167] :sparkles: Support running several exporter replicas (#12487) * :sparkles: Support running several exporter replicas * :books: Document one exporter per machine instead of compose replicas * :books: Trim exporter replica docs to the essentials * :sparkles: Cancel an exporter's jobs when it shuts down * :books: Replace exporter replica docs with a short beta note --- backend/resources/app/templates/debug.tmpl | 4 +- docs/technical-guide/configuration.md | 9 +++++ exporter/README.md | 1 + exporter/src/app/config.cljs | 8 ++++ exporter/src/app/core.cljs | 16 +++++++- exporter/src/app/jobs.cljs | 43 ++++++++++++++++----- exporter/src/app/jobs/scheduler.cljs | 1 + exporter/test/exporter_tests/jobs_test.cljs | 21 ++++++++++ 8 files changed, 90 insertions(+), 13 deletions(-) diff --git a/backend/resources/app/templates/debug.tmpl b/backend/resources/app/templates/debug.tmpl index 64ddd9d150..1498edaf87 100644 --- a/backend/resources/app/templates/debug.tmpl +++ b/backend/resources/app/templates/debug.tmpl @@ -326,6 +326,7 @@ Debug Main Page PROGRESS CMD BACKEND + REPLICA NAME CREATED ENDED @@ -339,12 +340,13 @@ Debug Main Page {{job.done}} / {{job.total}} {{job.cmd}} {{job.backend}} + {{job.replica}} {{job.name}} {{job.created-at}} {{job.ended-at}} {% empty %} -
    No export jobs.
    +
    No export jobs.
    {% endfor %} diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index bde2d9fd3d..e99e8d5cd5 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -883,6 +883,15 @@ PENPOT_INTERNAL_URI: http://penpot-frontend:8080 `http://penpot-frontend:8080` used in the docker-compose is a good default and it is recommended to keep it unchanged. +**Beta, new render engine (WebGL):** when you run more than one exporter, give +each one its own name and keep it across restarts and upgrades. An exporter +uses it to cancel only its own unfinished exports when it restarts: + +```bash +# Exporter +PENPOT_EXPORTER_REPLICA_ID: exporter-1 +``` + ### MCP The MCP server lets AI agents read and edit Penpot files. It runs as a separate diff --git a/exporter/README.md b/exporter/README.md index df4d4412db..5e3986b90c 100644 --- a/exporter/README.md +++ b/exporter/README.md @@ -88,6 +88,7 @@ section, with a `?job-id=` filter. | `PENPOT_EXPORTER_MAX_CONCURRENT_JOBS` | `4` | Admission limit | | `PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE`| `2` | Per-profile admission limit | | `PENPOT_EXPORTER_QUEUE_MAX` | `64` | Queue cap; over it, `429 :queue-full` | +| `PENPOT_EXPORTER_REPLICA_ID` | hostname | Names this replica; boot cleanup only cancels its own jobs | | `PENPOT_WASM_WORKER_POOL_MAX` | `2` | Headless render worker threads; min 1 | | `PENPOT_WASM_WORKER_POOL_MIN` | `1` | Workers kept warm; clamped to the max | | `PENPOT_WASM_WORKER_IDLE_TIMEOUT` | `300` | Silence before a worker is terminated, in seconds | diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index 0fcc5a052d..4f94887ce0 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -9,6 +9,7 @@ (:require ["node:buffer" :as buffer] ["node:crypto" :as crypto] + ["node:os" :as os] ["node:process" :as process] [app.common.data :as d] [app.common.flags :as flags] @@ -55,6 +56,7 @@ [:exporter-max-jobs-per-profile {:optional true} ::sm/int] [:exporter-queue-max {:optional true} ::sm/int] [:exporter-job-ttl {:optional true} ::sm/int] + [:exporter-replica-id {:optional true} :string] [:wasm-worker-pool-max {:optional true} ::sm/int] [:wasm-worker-pool-min {:optional true} ::sm/int] [:wasm-worker-idle-timeout {:optional true} ::sm/int] @@ -124,6 +126,12 @@ (or (c/get config :internal-uri) (c/get config :public-uri))) +(def replica-id + "Names this exporter among its siblings. It must survive a restart, since + the boot sweep uses it to find the jobs the previous run left behind." + (or (c/get config :exporter-replica-id) + (os/hostname))) + (def management-key (let [key (or (c/get config :exporter-shared-key) (let [secret-key (c/get config :secret-key) diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index a1ddab7efa..7dcf3a5b71 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -33,6 +33,7 @@ (l/info :msg "initializing" :public-uri (str (cf/get :public-uri)) :internal-uri (str (cf/get-internal-uri)) + :replica cf/replica-id :version (:full cf/version)) (l/info :msg "headless wasm export available" :wasm-dir wasm/artifact-dir @@ -86,8 +87,19 @@ (fn [cause] (js/console.error cause))) +(defonce ^:private exiting? (atom false)) + +(defn- exit! + "Cancels this process's jobs before exiting, so their records and clients + do not keep waiting on work that dies with it. A second signal exits at once." + [] + (if (compare-and-set! exiting? false true) + (->> (shutdown-step "cancel-jobs" jobs/cancel-all!) + (p/fnly (fn [_ _] (proc/exit 0)))) + (proc/exit 0))) + ;; Signals are only delivered to the main thread, and `exit` in a worker would ;; take down that worker rather than the process. (when ^boolean wt/isMainThread - (.on proc/default "SIGTERM" (fn [] (proc/exit 0))) - (.on proc/default "SIGINT" (fn [] (proc/exit 0)))) + (.on proc/default "SIGTERM" exit!) + (.on proc/default "SIGINT" exit!)) diff --git a/exporter/src/app/jobs.cljs b/exporter/src/app/jobs.cljs index e3497b2861..d555269a52 100644 --- a/exporter/src/app/jobs.cljs +++ b/exporter/src/app/jobs.cljs @@ -20,6 +20,7 @@ [app.common.logging :as l] [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.jobs.store :as store] [app.redis :as redis] [promesa.core :as p])) @@ -81,6 +82,7 @@ [{:keys [profile-id cmd backend total name resource-id]} run-fn] (let [job {:id (uuid/next) :profile-id profile-id + :replica cf/replica-id :cmd cmd :backend backend :state "queued" @@ -165,7 +167,10 @@ overwrite either." [job data] (if-let [job (live job)] - (persist-and-publish! (merge job data)) + (let [job (merge job data)] + (l/info :hint "export job" :job-id (str (:id job)) :state (:state job) + :replica (:replica job)) + (persist-and-publish! job)) (p/resolved job))) (defn start! @@ -208,7 +213,7 @@ :error (ex-message cause)})) (defn- cancel-local! - [job-id] + [job-id & {:as data}] (let [k (str job-id) rt (get @registry k) job (:job rt)] @@ -222,7 +227,7 @@ ;; (`scheduler/drop-queued!`) releases the job, and `transition!` on a ;; released job is a no-op, so a queued job would keep claiming to be ;; queued in the store and never publish its `cancelled` update. - (let [result (transition! job {:state "cancelled" :ended-at (now-ms)})] + (let [result (transition! job (merge {:state "cancelled" :ended-at (now-ms)} data))] (doseq [f (:cancel-fns rt)] (try (f) @@ -245,22 +250,40 @@ (store/request-cancel! (:id job)) job))))))) +(defn cancel-all! + "Cancels every job this process holds, on shutdown. Nothing in flight survives + the process, and clients learn it now rather than when this replica boots + again, if it ever does under the same id." + [] + (let [job-ids (keys @registry)] + (when (seq job-ids) + (l/info :hint "cancelling jobs on shutdown" :count (count job-ids))) + (->> job-ids + (map #(cancel-local! % :interrupted true)) + (p/all)))) + +(defn- abandoned? + "A job this replica's previous run left mid-flight. A sibling's jobs are its + own business: they may well be running." + [job] + (and (= cf/replica-id (:replica job)) + (not (terminal? job)))) + (defn- clean-abandoned! - "Marks every job left mid-flight by a previous process as cancelled. + "Marks every job left mid-flight by a previous run of this replica as + cancelled. A queue and its running jobs live in the memory of the process that owns them, so nothing in flight when it died can be resumed; without this the - record would keep claiming to be running until its TTL expires. - - NOTE: the store cannot tell whose jobs are whose, so with more than one - exporter behind a load balancer this would also cancel a sibling's running - jobs. Single-instance deployments only." + record would keep claiming to be running until its TTL expires. A replica + that never comes back under the same id leaves its records to the TTL." [] (->> (store/fetch-all) (p/mcat (fn [jobs] - (let [abandoned (remove terminal? jobs)] + (let [abandoned (filter abandoned? jobs)] (when (seq abandoned) (l/warn :hint "cancelling jobs abandoned by a previous process" + :replica cf/replica-id :count (count abandoned))) (->> abandoned (map (fn [job] diff --git a/exporter/src/app/jobs/scheduler.cljs b/exporter/src/app/jobs/scheduler.cljs index 320689d90d..a986019a57 100644 --- a/exporter/src/app/jobs/scheduler.cljs +++ b/exporter/src/app/jobs/scheduler.cljs @@ -8,6 +8,7 @@ "Admission control for export jobs. Limits concurrent jobs and rejects work rather than allowing an unbounded backlog. + Every limit is per replica: the queue lives in this process only. Queue order is FIFO, except jobs whose profile is already at its cap are skipped, as are headless jobs once every render worker is busy." (:require diff --git a/exporter/test/exporter_tests/jobs_test.cljs b/exporter/test/exporter_tests/jobs_test.cljs index c8baa9b9fd..a92c1c1790 100644 --- a/exporter/test/exporter_tests/jobs_test.cljs +++ b/exporter/test/exporter_tests/jobs_test.cljs @@ -9,6 +9,7 @@ reported and swallowed, so only the in-process record is exercised." (:require [app.common.uuid :as uuid] + [app.config :as cf] [app.jobs :as jobs] [cljs.test :as t :include-macros true] [promesa.core :as p])) @@ -84,3 +85,23 @@ (t/is (= "ended" (:state ended))) (t/is (nil? (jobs/lookup (:id job)))) (done))))) + +(t/deftest boot-sweep-leaves-sibling-jobs-alone + (let [abandoned? #'jobs/abandoned?] + (t/is (abandoned? {:replica cf/replica-id :state "running"})) + (t/is (abandoned? {:replica cf/replica-id :state "queued"})) + (t/is (not (abandoned? {:replica cf/replica-id :state "ended"}))) + (t/is (not (abandoned? {:replica (str cf/replica-id "-sibling") :state "running"}))))) + +(t/deftest shutdown-cancels-all-its-jobs + (t/async done + (p/let [running (create!) + queued (create!) + _ (jobs/start! running) + _ (jobs/cancel-all!)] + (doseq [job [running queued]] + (let [current (jobs/lookup (:id job))] + (t/is (= "cancelled" (:state current))) + (t/is (true? (:interrupted current)))) + (jobs/release! (:id job))) + (done)))) From 6acb360f4ad5dc24faec9d67f1fc8725456755de Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 15:59:31 +0200 Subject: [PATCH 118/167] :recycle: Use DS icons and an icon button in the undo history panel (#12472) * :recycle: Use DS icons and an icon button in the undo history panel The undo history panel (History > Actions) drew its entry type icons with the deprecated icon set, and the arrow that shows the details of an entry was a div with a click handler: no button role, no keyboard access and no accessible name. - The entry type icons use the DS icon (drop-icon is i/drop in the DS). - The arrow becomes a DS action icon button labeled "Show details", with aria-expanded for its state. It keeps its data attribute, its classes and its click handler, and it also shows on keyboard focus. The DS icon draws 12px inside a 16px box, so the entry icon gets a negative margin, and the button wrapper a negative end margin: icon, arrow and text stay where they were. The arrow turns through its own class instead of an svg rule. Add the workspace.undo.entry.show-details key (en, es) and a visual test that draws a rectangle, opens the actions list and expands the entry. AI-assisted-by: claude-opus-5-5 * :recycle: Move undo history panel styles off the deprecated styles Move history.scss to DS tokens, sizes, borders and logical properties, and drop the refactor import. The entry color aliases become local variables of the entry, set by its disabled and hover states, and the arrow opacity goes through one as well. AI-assisted-by: claude-opus-5-5 * :lipstick: Place the undo history icon and arrow without negative margins Drop the -2px margin of the entry icon and the -6px end margin of the details button wrapper. Keep text and arrow in place with token spacing instead: the text after the 16px icon box starts with --sp-xs instead of --sp-s, and the entry end padding is --sp-xxs, since the 24px button leaves 6px around its 12px arrow. The icon now sits 2px to the right, centered in its box. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 36 +++++++ .../main/ui/workspace/sidebar/history.cljs | 53 +++++----- .../main/ui/workspace/sidebar/history.scss | 98 +++++++++---------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 5 files changed, 120 insertions(+), 75 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 6a7b7325e9..92ac8dddb0 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -560,6 +560,42 @@ test.describe("Right header", () => { }); }); +test.describe("Undo history", () => { + test("Shows the actions list with an entry expanded", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); + await workspace.mockRPC( + "get-file-snapshots?file-id=*", + "workspace/versions-snapshot-1.json", + ); + await workspace.goToWorkspace(); + + await page.keyboard.press("r"); + await workspace.clickWithDragViewportAt(128, 128, 200, 100); + + await page.keyboard.press("Control+Alt+h"); + await page.getByRole("tab", { name: "Actions" }).click(); + const panel = page.getByRole("tabpanel", { name: "Actions" }); + const entry = panel.getByText("New rectangle"); + // The short id and the relative time change on every run. + const mask = [page.locator('[class*="history-entry-meta"]')]; + + // The expand arrow only shows while the entry is hovered. + await entry.hover(); + await expect(panel).toHaveScreenshot({ mask }); + + // The data attribute exists before and after the DS migration. + await panel.locator('[data-has-entry="true"]').click(); + await expect(panel.getByText("Rectangle", { exact: true })).toBeVisible(); + await entry.hover(); + await expect(panel).toHaveScreenshot({ mask }); + }); +}); + test.describe("Palette", () => { test("Shows the bottom palette expanded and collapsed", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.cljs b/frontend/src/app/main/ui/workspace/sidebar/history.cljs index 09e8e68583..293a79ce1e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/history.cljs @@ -13,9 +13,9 @@ [app.main.data.workspace.undo :as dwu] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.product.empty-state :refer [empty-state*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :refer [tr] :as i18n] [cuerdas.core :as str] @@ -155,22 +155,23 @@ :move (tr "workspace.undo.entry.move" value) (tr "workspace.undo.entry.unknown" value)))) -(defn entry->icon [{:keys [type]}] +(defn- entry->icon-id + [{:keys [type]}] (case type - :page deprecated-icon/document - :shape deprecated-icon/svg - :rect deprecated-icon/rectangle - :circle deprecated-icon/ellipse - :text deprecated-icon/text - :path deprecated-icon/path - :frame deprecated-icon/board - :group deprecated-icon/group - :color deprecated-icon/drop-icon - :typography deprecated-icon/text-palette - :component deprecated-icon/component - :media deprecated-icon/img - :image deprecated-icon/img - deprecated-icon/svg)) + :page i/document + :shape i/svg + :rect i/rectangle + :circle i/ellipse + :text i/text + :path i/path + :frame i/board + :group i/group + :color i/drop + :typography i/text-palette + :component i/component + :media i/img + :image i/img + i/svg)) (defn is-shape? [type] (contains? #{:shape :rect :circle :text :path :frame :group} type)) @@ -339,7 +340,7 @@ [:div {:class (stl/css :history-entry-summary)} [:div {:class (stl/css :history-entry-summary-icon)} - (entry->icon entry)] + [:> icon* {:icon-id (entry->icon-id entry) :size "s"}]] [:div {:class (stl/css :history-entry-summary-text)} [:div {:class (stl/css :history-entry-title)} (entry->message entry)] @@ -363,11 +364,17 @@ [:span {:class (stl/css :history-entry-author)} (tr "workspace.undo.entry.by" author)])])] (when (:detail entry) - [:div {:class (stl/css-case :history-entry-summary-button true - :button-opened @show-detail?) - :on-click toggle-show-detail - :data-has-entry (dm/str (not (nil? (:detail entry))))} - deprecated-icon/arrow])] + [:> icon-button* {:variant "action" + :class (stl/css-case :history-entry-summary-button true + :button-opened @show-detail?) + :tooltip-class (stl/css :history-entry-summary-button-wrapper) + :aria-label (tr "workspace.undo.entry.show-details") + :aria-expanded @show-detail? + :icon i/arrow + :icon-size "s" + :icon-class (stl/css :history-entry-summary-arrow) + :on-click toggle-show-detail + :data-has-entry (dm/str (not (nil? (:detail entry))))}])] (when @show-detail? [:> history-entry-details* {:entry entry}])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.scss b/frontend/src/app/main/ui/workspace/sidebar/history.scss index e7e8b1a2cd..59d56a64ad 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/history.scss @@ -4,13 +4,14 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +@use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; .history-toolbox { display: flex; flex-direction: column; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); } .history-entry-empty { @@ -22,21 +23,29 @@ } .history-entries { - height: calc(100vh - deprecated.$s-100); - padding: deprecated.$s-12; + block-size: calc(100vh - #{px2rem(100)}); + padding: var(--sp-m); overflow: hidden auto; - font-size: deprecated.$fs-12; + font-size: px2rem(12); } .history-entry { + --entry-background-color: var(--color-background-tertiary); + --entry-foreground-color: var(--color-foreground-secondary); + --entry-border-color: transparent; + --entry-arrow-opacity: 0; + display: flex; justify-content: center; flex-direction: column; - min-height: deprecated.$s-32; - margin: deprecated.$s-4; - padding: deprecated.$s-4 deprecated.$s-8; - border: deprecated.$s-2 solid transparent; - border-radius: deprecated.$s-8; + min-block-size: $sz-32; + margin: var(--sp-xs); + + // The end padding is small because the details button leaves 6px + // around its 12px arrow. + padding: var(--sp-xs) var(--sp-xxs) var(--sp-xs) var(--sp-s); + border: $b-2 solid var(--entry-border-color); + border-radius: $br-8; background-color: var(--entry-background-color); cursor: pointer; transition: border 0.2s; @@ -46,18 +55,18 @@ align-items: center; .history-entry-summary-icon { - svg { - @extend %button-icon-small; - - stroke: var(--entry-foreground-color); - } + display: flex; + color: var(--entry-foreground-color); } .history-entry-summary-text { display: flex; flex-direction: column; - gap: deprecated.$s-2; - margin: 0 deprecated.$s-8; + gap: var(--sp-xxs); + + // The DS icon draws 12px inside a 16px box: a smaller start margin + // keeps the text where it was. + margin-inline: var(--sp-xs) var(--sp-s); color: var(--color-foreground-primary); min-inline-size: 0; } @@ -73,7 +82,7 @@ // template ("hash · time by Name") survive rendering. Issue #7660. .history-entry-meta { color: var(--color-foreground-secondary); - font-size: deprecated.$fs-10; + font-size: px2rem(10); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -99,28 +108,28 @@ white-space: nowrap; } - .history-entry-summary-button { - opacity: deprecated.$op-0; - margin-left: auto; + // Tooltip wrapper of the details button, pushed to the end of the row. + .history-entry-summary-button-wrapper { + margin-inline-start: auto; + } - &.button-opened { - svg { - transform: rotate(90deg); - } + .history-entry-summary-button { + opacity: var(--entry-arrow-opacity); + + &:focus-visible { + --entry-arrow-opacity: 1; } - svg { - @extend %button-icon-small; - - stroke: var(--entry-foreground-color); + &.button-opened .history-entry-summary-arrow { + transform: rotate(90deg); } } } .history-entry-detail { display: block; - padding-top: deprecated.$s-16; - color: var(--modal-text-foreground-color); + padding-block-start: var(--sp-l); + color: var(--color-foreground-secondary); .history-entry-details-list { margin: 0; @@ -128,31 +137,16 @@ } &.disabled { - border-color: var(--entry-border-color-disabled); - background-color: var(--entry-background-color-disabled); + --entry-background-color: var(--color-background-primary); + --entry-border-color: var(--color-background-quaternary); } &.hover, &:hover { - background-color: var(--entry-background-color-hover); - color: var(--entry-foreground-color-hover); + --entry-background-color: var(--color-background-quaternary); + --entry-foreground-color: var(--color-foreground-primary); + --entry-arrow-opacity: 1; - .history-entry-summary { - .history-entry-summary-icon { - svg { - stroke: var(--entry-foreground-color-hover); - } - } - - .history-entry-summary-button { - opacity: deprecated.$op-10; - - &.button-opened { - svg { - transform: rotate(90deg); - } - } - } - } + color: var(--color-foreground-primary); } } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 078f9c694b..040339f8ef 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -9407,6 +9407,10 @@ msgstr "There are no history changes so far" msgid "workspace.undo.entry.by" msgstr "by %s" +#: src/app/main/ui/workspace/sidebar/history.cljs +msgid "workspace.undo.entry.show-details" +msgstr "Show details" + #: src/app/main/ui/workspace/sidebar/history.cljs:154 msgid "workspace.undo.entry.delete" msgstr "Deleted %s" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 23f9f2f47c..e369ed0b12 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9513,6 +9513,10 @@ msgstr "Todavía no hay cambios en el histórico" msgid "workspace.undo.entry.by" msgstr "por %s" +#: src/app/main/ui/workspace/sidebar/history.cljs +msgid "workspace.undo.entry.show-details" +msgstr "Mostrar detalles" + #: src/app/main/ui/workspace/sidebar/history.cljs:154 msgid "workspace.undo.entry.delete" msgstr "%s eliminado" From 452d751dff1d84d8e023472ac4ea29233e0dd6a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 21 Oct 2026 16:04:12 +0200 Subject: [PATCH 119/167] :wrench: Fix dashboard thumbnails crashing on deployed environments (#12465) * :bug: Pass version tag to worker so it loads the current render wasm * :bug: Keep wasm thumbnail queue alive when one render fails * :bug: Fail wasm thumbnail request when canvas context cannot init --- frontend/src/app/main/worker.cljs | 3 ++- frontend/src/app/worker/impl.cljs | 3 +++ frontend/src/app/worker/thumbnails.cljs | 15 ++++++++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/worker.cljs b/frontend/src/app/main/worker.cljs index bbfec651c6..4daaeebc36 100644 --- a/frontend/src/app/main/worker.cljs +++ b/frontend/src/app/main/worker.cljs @@ -22,7 +22,8 @@ (uw/ask! worker {:cmd :configure :config {:public-uri cf/public-uri :build-data cf/build-date - :version cf/version}}) + :version cf/version + :version-tag cf/version-tag}}) (set! instance worker))) (defn ask! diff --git a/frontend/src/app/worker/impl.cljs b/frontend/src/app/worker/impl.cljs index 5461756ff5..c7f65dc1b7 100644 --- a/frontend/src/app/worker/impl.cljs +++ b/frontend/src/app/worker/impl.cljs @@ -36,6 +36,9 @@ (when-let [version (get config :version)] (set! cf/version version)) + (when-let [version-tag (get config :version-tag)] + (set! cf/version-tag version-tag)) + (when-let [build-date (get config :build-data)] (set! cf/build-date build-date)) diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index e42a9687b4..48d35cb0c7 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -192,7 +192,7 @@ :file-id file-id :revn revn})) (rx/end! subs))))))) - (rx/end! subs)) + (rx/error! subs (ex-info "unable to init wasm canvas" {:file-id file-id}))) nil))))))) (defonce ^:private @@ -202,7 +202,10 @@ (defonce ^:private thumbnails-stream (->> thumbnails-queue - (rx/mapcat render-thumbnail-with-wasm) + (rx/mapcat (fn [{:keys [id] :as message}] + (->> (render-thumbnail-with-wasm message) + (rx/catch (fn [cause] + (rx/of {:id id :error cause})))))) (rx/share))) (defmethod impl/handler :thumbnails/generate-for-file-wasm @@ -213,8 +216,10 @@ sid (->> thumbnails-stream (rx/filter #(= id (:id %))) (rx/subs! - (fn [result] - (rx/push! subs result) - (rx/end! subs))))] + (fn [{:keys [error] :as result}] + (if error + (rx/error! subs error) + (do (rx/push! subs result) + (rx/end! subs))))))] (rx/push! thumbnails-queue (assoc message :id id)) #(rx/dispose! sid))))) From 347a52d51465b35d77b48b6d1a3fbd47c8b861f0 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 22 Oct 2026 11:07:07 +0200 Subject: [PATCH 120/167] :bug: Keep Path out of update-team-photo audit props (#12530) Nitrate encodes audit chunks as JSON and cannot serialize java.nio.file.Path; mirror update-profile-photo and replace the upload map with safe props. --- backend/src/app/rpc/commands/teams.clj | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index a39ba9572b..041516d3a9 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -1104,7 +1104,7 @@ (update-team-photo cfg (assoc params :profile-id profile-id))) (defn update-team-photo - [{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id team-id] :as params}] + [{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id team-id file] :as params}] (let [team (get-team pool :profile-id profile-id :team-id team-id) photo (profile/upload-photo cfg params)] @@ -1120,4 +1120,10 @@ {:photo-id (:id photo)} {:id team-id}) - (assoc team :photo-id (:id photo)))) + (rph/with-meta (assoc team :photo-id (:id photo)) + {::audit/replace-props + {:team-id team-id + :file-name (:filename file) + :file-size (:size file) + :file-path (str (:path file)) + :file-mtype (:mtype file)}}))) From ce5276065ecf8e861d83527a3b7561d04b5c33ff Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 22 Oct 2026 12:24:12 +0200 Subject: [PATCH 121/167] :bug: Skip unknown entries and reserve keys in shortcut settings (#12473) * :bug: Skip unknown entries when importing shortcuts The shortcuts import rejected the whole file when it held a single entry it did not know, so a profile exported from an older Penpot version, or edited by hand, could not be imported at all. The spec in #9924 asks to skip unknown keys, tell the user which ones, and apply the rest. Importing now keeps the entries that map to a known shortcut with a valid key combination and skips the rest: unknown contexts, unknown shortcut keys, and commands that are not a key combination. The import modal lists the skipped entries. The file is only rejected when it is not a map or holds nothing that can be applied. A context whose entries are all skipped is left out: an empty context clears the user's shortcuts on import, so keeping it would wipe them. valid-command? reads a command the way mousetrap does, so it accepts every default shortcut and rejects strings mousetrap could never match. AI-assisted-by: claude-opus-5-5 * :paperclip: Sort requires in shortcuts settings test cljfmt expects the namespace requires in alphabetical order. AI-assisted-by: claude-opus-5-5 * :sparkles: Reserve cancel, copy and paste shortcuts Cancel, copy and paste are no longer customizable, in the workspace and in the path editor, which shares their shortcut keys. Their key combinations (Escape, Ctrl/Cmd+C, Ctrl/Cmd+V) are now reserved: the shortcut recorder warns and blocks saving one of them for another action, and the import skips entries that use them. Reserved combinations come from the non-customizable workspace shortcuts, so marking another one as non-customizable reserves its key too. The path editor's cancel also binds V, the move tool key, so path shortcuts are left out. Combinations compare the way mousetrap matches them: aliases and modifier order do not matter. apply-custom-overrides now ignores overrides of non-customizable shortcuts. An override saved before this change would otherwise still apply, with no way to reset it from the settings page. AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/data/shortcuts.cljs | 5 +- .../main/data/workspace/path/shortcuts.cljs | 3 + .../app/main/data/workspace/shortcuts.cljs | 2 + .../settings/import_shortcuts_diff_modal.cljs | 14 +- .../settings/import_shortcuts_diff_modal.scss | 19 ++ .../src/app/main/ui/settings/shortcuts.cljs | 59 ++++- frontend/src/app/main/ui/shortcuts.cljs | 134 ++++++++++- .../ui/settings_shortcuts_test.cljs | 217 +++++++++++++++++- frontend/translations/en.po | 8 + frontend/translations/es.po | 8 + 10 files changed, 447 insertions(+), 22 deletions(-) diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 7181f5ebb3..9a045d4995 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -187,8 +187,11 @@ shortcuts (reduce-kv (fn [acc sc-key new-command] + ;; A non-customizable shortcut ignores overrides stored before it + ;; became so: the settings page no longer offers to reset them. (if (and (contains? acc sc-key) - (not (:disabled (get acc sc-key)))) + (not (:disabled (get acc sc-key))) + (not (false? (:customizable (get acc sc-key))))) (-> acc (assoc-in [sc-key :original-command] (get-in acc [sc-key :command])) (assoc-in [sc-key :command] new-command) diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index bd924df5fe..21f1c87960 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -127,6 +127,7 @@ :label (fn [] (tr "shortcuts.copy")) :command (ds/c-mod "c") :subsections [:path-editor] + :customizable false :fn #(st/emit! (drp/copy-selected-nodes))} :cut {:tooltip (ds/meta "X") @@ -139,6 +140,7 @@ :label (fn [] (tr "shortcuts.paste")) :command (ds/c-mod "v") :subsections [:path-editor] + :customizable false :fn #(st/emit! (drp/paste-nodes))} :duplicate {:tooltip (ds/meta "D") @@ -176,6 +178,7 @@ :label (fn [] (tr "shortcuts.escape")) :command ["escape" "v"] :section [:workspace] + :customizable false :fn #(st/emit! (esc-pressed))} ;; Reuses the `:start-editing` key (instead of adding "enter" to diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index bdbd3cedaf..17246e5889 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -94,6 +94,7 @@ :command (ds/c-mod "c") :subsections [:edit] :section [:workspace] + :customizable false :fn #(st/emit! (dw/copy-selected))} :copy-link {:tooltip (ds/shift (ds/alt "C")) @@ -178,6 +179,7 @@ :command "escape" :subsections [:edit] :section [:workspace] + :customizable false :fn #(st/emit! :interrupt (dwdc/clear-drawing) (dw/deselect-all true))} :find {:tooltip (ds/meta "F") :command (ds/c-mod "f") :subsections [:edit] diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs index a7462d9380..43478a33f1 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs @@ -65,7 +65,7 @@ (mf/defc import-shortcuts-diff-modal {::mf/register modal/components ::mf/register-as :import-shortcuts-diff-modal} - [{:keys [imported-shortcuts custom-shortcuts all-shortcuts-raw]}] + [{:keys [imported-shortcuts skipped-entries custom-shortcuts all-shortcuts-raw]}] (let [diff-entries (mf/with-memo [imported-shortcuts custom-shortcuts] (->> (compute-diff imported-shortcuts custom-shortcuts) @@ -145,7 +145,17 @@ :is-customized true :has-conflict? false}])]]])))]] [:div {:class (stl/css :no-changes)} - (tr "import-shortcuts.no-changes")])] + (tr "import-shortcuts.no-changes")]) + + (when (seq skipped-entries) + [:div {:class (stl/css :skipped-entries)} + [:div {:class (stl/css :modal-content-text)} + (tr "import-shortcuts.skipped-entries")] + [:ul {:class (stl/css :skipped-entries-list)} + (for [entry skipped-entries] + [:li {:key entry + :class (stl/css :skipped-entry)} + entry])]])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss index e810578008..c28d5de804 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss @@ -165,3 +165,22 @@ text-align: center; padding-block: var(--sp-xl); } + +.skipped-entries { + display: flex; + flex-direction: column; + gap: var(--sp-s); +} + +.skipped-entries-list { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.skipped-entry { + @include t.use-typography("code-font"); + + color: var(--color-foreground-secondary); + overflow-wrap: anywhere; +} diff --git a/frontend/src/app/main/ui/settings/shortcuts.cljs b/frontend/src/app/main/ui/settings/shortcuts.cljs index 2b246339b2..841841ac5b 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.cljs +++ b/frontend/src/app/main/ui/settings/shortcuts.cljs @@ -121,6 +121,53 @@ [:dashboard {:optional true} (context-schema :dashboard)] [:viewer {:optional true} (context-schema :viewer)]])) +(defn- entry-path + [& segments] + (str/join "/" (map d/name segments))) + +(defn- sanitize-context + "Keeps the entries of one imported context that map to a known shortcut key + with a valid command; every other entry goes to :skipped." + [result ctx entries known-keys] + (let [viable (reduce-kv + (fn [viable k command] + (if (and (contains? known-keys k) + (ss/valid-command? command) + (not (ss/reserved-command? command))) + (assoc viable k command) + viable)) + {} + entries) + skipped (->> (keys entries) + (remove #(contains? viable %)) + (map #(entry-path ctx %)))] + (cond-> (update result :skipped into skipped) + ;; A context left empty by skipped entries is dropped: an empty context + ;; clears the user's shortcuts on import, an explicit {} still does. + (or (seq viable) (empty? entries)) + (assoc-in [:shortcuts ctx] viable)))) + +(defn sanitize-imported-shortcuts + "Splits an imported custom-shortcuts payload into the entries Penpot can + apply and the ones it must skip: unknown contexts, unknown or + non-customizable shortcut keys, commands that are not a key combination + (see `app.main.ui.shortcuts/valid-command?`) and reserved combinations + (see `app.main.ui.shortcuts/reserved-command?`). + + Returns {:shortcuts :skipped [\"context/key\" ...]}, or + nil when the payload is not a map." + [data] + (when (map? data) + (-> (reduce-kv + (fn [result ctx entries] + (let [known-keys (get known-shortcut-keys ctx)] + (if (and (some? known-keys) (map? entries)) + (sanitize-context result ctx entries known-keys) + (update result :skipped conj (entry-path ctx))))) + {:shortcuts {} :skipped []} + data) + (update :skipped (comp vec sort))))) + (defn- flatten-humanize "Turns Malli's humanized explain output into a flat list of {:path :message} maps." ([human] (flatten-humanize [] human)) @@ -450,12 +497,16 @@ (rx/subs! (fn [content] (try - (let [shortcuts (js->clj (.parse js/JSON content) - :keywordize-keys true) - validation (validate-imported-shortcuts shortcuts)] - (if (:valid? validation) + (let [data (js->clj (.parse js/JSON content) + :keywordize-keys true) + {:keys [shortcuts skipped]} (sanitize-imported-shortcuts data)] + ;; Rejects the file only when nothing in it can be applied. + (if (and (some? shortcuts) + (or (seq shortcuts) (empty? skipped)) + (:valid? (validate-imported-shortcuts shortcuts))) (st/emit! (modal/show {:type :import-shortcuts-diff-modal :imported-shortcuts shortcuts + :skipped-entries skipped :custom-shortcuts custom-shortcuts :all-shortcuts-raw all-shortcuts-raw})) (st/emit! (ntf/error (tr "import-shortcuts.invalid-file"))))) diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 1520f3258b..30aed17a2c 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -68,6 +68,122 @@ "Meta" "meta" " " "space"}) +;; Mirrors how mousetrap reads a bind string (`_keysFromString` and +;; `_getKeyInfo` in @penpot/mousetrap), so a command is valid exactly when +;; mousetrap can match it. + +(def ^:private command-aliases + {"ctrl" "ctrl" + "control" "ctrl" + "alt" "alt" + "option" "alt" + "shift" "shift" + "meta" "meta" + "command" "meta" + "return" "enter" + "enter" "enter" + "esc" "escape" + "escape" "escape" + "del" "del" + "delete" "del" + "ins" "ins" + "insert" "ins" + "space" "space" + "plus" "+"}) + +(def ^:private command-modifiers + #{"ctrl" "alt" "shift" "meta"}) + +(def ^:private command-named-keys + (into #{} (remove command-modifiers) (vals key-name-map))) + +(defn- canonical-command-key + [token] + (cond + ;; `mod` follows the platform, like `ds/c-mod`. + (= "mod" token) + (if (cf/check-platform? :macos) "meta" "ctrl") + + (contains? command-aliases token) + (get command-aliases token) + + (= 1 (count token)) + (str/lower token) + + :else + (str/lower (get key-name-map token token)))) + +(defn- command-step-keys + "Splits one step of a command (\"ctrl+shift+z\") into canonical keys. + A lone \"+\" is the plus key, and \"++\" ends a step with it." + [step] + (if (= "+" step) + ["+"] + (->> (.split (str/replace step #"\+{2}" "+plus") "+") + (mapv canonical-command-key)))) + +(defn- final-command-key? + [k] + (or (= 1 (count k)) + (contains? command-named-keys k) + ;; Two-digit alternatives first: cljs `re-matches` does not + ;; backtrack into a longer alternative. + (some? (re-matches #"f(?:1[0-9]|2[0-4]|[1-9])" k)) + (some? (re-matches #"num[0-9]" k)))) + +(defn- valid-command-step? + "A lone modifier is a valid step: `:show-measure` binds a bare `alt`." + [step-keys] + (let [final-key (peek step-keys)] + (if (contains? command-modifiers final-key) + (= 1 (count step-keys)) + (and (every? command-modifiers (pop step-keys)) + (final-command-key? final-key))))) + +(defn valid-command? + "True when `command` is a key combination mousetrap can match: steps + separated by single spaces, each a lone modifier or modifiers joined + with \"+\" ending with a single character, a named key (`escape`, `up`, + ...), a function key (`f1`..`f24`) or a numpad digit (`num0`..`num9`). + The empty string is valid: it disables the shortcut." + [command] + (and (string? command) + (or (= "" command) + (every? (comp valid-command-step? command-step-keys) + (.split command " "))))) + +(defn- canonical-command-step + "One step in the form mousetrap matches on: canonical keys with the + modifiers sorted, so `Shift+Ctrl+Z` and `ctrl+shift+z` compare equal." + [step] + (let [step-keys (command-step-keys step) + final-key (peek step-keys)] + (str/join "+" (conj (vec (sort (pop step-keys))) final-key)))) + +(defn- reserved-command-steps + "Key combinations of the non-customizable workspace shortcuts (cancel, + copy, paste): assigning them to another action would bind one key to two + actions. The path editor's cancel also binds `v`, the move tool key, + so path shortcuts are left out." + [] + (into #{} + (comp (filter #(false? (:customizable (val %)))) + (mapcat #(let [command (:command (val %))] + (if (vector? command) command [command]))) + (mapcat #(.split % " ")) + (map canonical-command-step)) + wsc/shortcuts)) + +(defn reserved-command? + "True when any step of `command` is a combination Penpot keeps for a + non-customizable shortcut." + [command] + (and (string? command) + (not= "" command) + (let [reserved (reserved-command-steps)] + (some? (some #(contains? reserved (canonical-command-step %)) + (.split command " ")))))) + (defn- event->native "Unwraps a goog.events.BrowserEvent to the native event, since the wrapper copies `key` but not `code`." @@ -601,12 +717,16 @@ effective-section-key)) (clean-editing-state))) + reserved? (and (some? recorded-command) + (not reset-pending) + (reserved-command? recorded-command)) + save-fn (mf/use-fn - (mf/deps recorded-command conflict command effective-section-key) + (mf/deps recorded-command reserved? conflict command effective-section-key) (fn [event] (dom/prevent-default event) - (when recorded-command + (when (and recorded-command (not reserved?)) (if (deref reset-pending*) (st/emit! (customize/reset-custom-shortcut command recorded-command effective-section-key)) (st/emit! (customize/set-custom-shortcut @@ -710,9 +830,16 @@ :has-conflict? false}]]) (when (and recorded-command (not reset-pending)) - (if conflict + (cond + reserved? + [:> context-notification* {:level :warning :class (stl/css :modal-error-msg)} + (tr "shortcuts.edit-modal.reserved")] + + conflict [:> context-notification* {:level :warning :class (stl/css :modal-error-msg)} (tr "shortcuts.edit-modal.conflict" (:name conflict))] + + :else [:> context-notification* {:level :success :class (stl/css :modal-success-msg)} (tr "shortcuts.edit-modal.success")])) [:div {:class (stl/css :edit-buttons)} @@ -725,6 +852,7 @@ [:> icon-button* {:variant "primary" :aria-label (tr "labels.save") :on-click save-fn + :disabled reserved? :icon i/tick :icon-size "m"}]] [:div {:class (stl/css :confirmation-buttons)} diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs index fd82bfe17d..3ed7e035c9 100644 --- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs @@ -1,8 +1,12 @@ (ns frontend-tests.ui.settings-shortcuts-test (:require [app.config :as cf] + [app.main.data.dashboard.shortcuts :as dsc] [app.main.data.profile :as du] [app.main.data.shortcuts :as ds] + [app.main.data.viewer.shortcuts :as vsc] + [app.main.data.workspace.path.shortcuts :as psc] + [app.main.data.workspace.shortcuts :as wsc] [app.main.ui.settings.import-shortcuts-diff-modal :as diff-modal] [app.main.ui.settings.restore-shortcuts-modal :as restore-modal] [app.main.ui.settings.shortcuts :as sut] @@ -14,21 +18,21 @@ (t/deftest validate-imported-shortcuts-accepts-valid-workspace (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape "escape" + {:workspace {:draw-rect "shift+r" :increase-zoom "+" :zoom-lense-decrease "alt+z"}})))) (t/deftest validate-imported-shortcuts-accepts-optional-contexts (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape "escape"} + {:workspace {:draw-rect "shift+r"} :dashboard {:toggle-theme "alt+m"} :viewer {:next-frame "right"}})))) (t/deftest validate-imported-shortcuts-accepts-empty-string-command (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape ""}})))) + {:workspace {:draw-rect ""}})))) (t/deftest validate-imported-shortcuts-rejects-nil (let [result (sut/validate-imported-shortcuts nil)] @@ -52,19 +56,19 @@ (t/is (some #(= "workspace" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-non-string-command - (let [result (sut/validate-imported-shortcuts {:workspace {:escape 123}})] + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect 123}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) - (t/is (some #(= "workspace/escape" (:path %)) (:errors result))))) + (t/is (some #(= "workspace/draw-rect" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-vector-command - (let [result (sut/validate-imported-shortcuts {:workspace {:escape ["ctrl+e"]}})] + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect ["ctrl+e"]}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) - (t/is (some #(= "workspace/escape" (:path %)) (:errors result))))) + (t/is (some #(= "workspace/draw-rect" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-unknown-top-level-key - (let [result (sut/validate-imported-shortcuts {:workspace {:escape "escape"} + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect "shift+r"} :unknown-context {}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) @@ -87,6 +91,195 @@ (t/is (= false (:valid? (sut/validate-imported-shortcuts #{:workspace})))) (t/is (= false (:valid? (sut/validate-imported-shortcuts (fn [])))))) +(t/deftest sanitize-imported-shortcuts-skips-unknown-action-keys + (t/is (= {:shortcuts {:workspace {:draw-rect "shift+r"}} + :skipped ["workspace/not-a-real-shortcut"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r" + :not-a-real-shortcut "x"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-unknown-contexts + (t/is (= {:shortcuts {:workspace {:draw-rect "shift+r"}} + :skipped ["unknown-context"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r"} + :unknown-context {:draw-rect "shift+r"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-non-string-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect 123 + :increase-zoom ["+"] + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-invalid-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "foo+bar" + :increase-zoom "ctrl+" + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest cancel-copy-and-paste-are-not-customizable + (t/testing "in the workspace and in the path editor, which share the + shortcut keys of the workspace context" + (doseq [shortcuts [wsc/shortcuts psc/shortcuts] + k [:escape :copy :paste]] + (t/is (false? (get-in shortcuts [k :customizable])) + (str k))))) + +(t/deftest sanitize-imported-shortcuts-skips-non-customizable-keys + (t/testing "cancel, copy and paste cannot be imported, even with a free + key combination" + (t/is (= {:shortcuts {} + :skipped ["workspace/copy" "workspace/escape" "workspace/paste"]} + (sut/sanitize-imported-shortcuts + {:workspace {:escape "shift+escape" + :copy "ctrl+shift+1" + :paste "ctrl+shift+2"}}))))) + +(t/deftest apply-custom-overrides-skips-non-customizable-shortcuts + (t/testing "an override stored before a shortcut became non-customizable + is ignored, since the settings page can no longer reset it" + (let [shortcuts {:escape {:command "escape" :customizable false} + :undo {:command "ctrl+z"}} + result (ds/apply-custom-overrides shortcuts + {:workspace {:escape "shift+escape" + :undo "ctrl+u"}} + :workspace)] + (t/is (= "escape" (get-in result [:escape :command]))) + (t/is (= "ctrl+u" (get-in result [:undo :command])))))) + +(t/deftest sanitize-imported-shortcuts-skips-reserved-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "escape" + :increase-zoom "ctrl+v" + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-copy-combination + (t/is (= {:shortcuts {:workspace {:draw-rect "ctrl+alt+c"}} + :skipped ["workspace/draw-ellipse"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-ellipse "ctrl+c" + :draw-rect "ctrl+alt+c"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-non-map-context + (t/is (= {:shortcuts {:viewer {:next-frame "right"}} + :skipped ["workspace"]} + (sut/sanitize-imported-shortcuts + {:workspace "not-a-map" + :viewer {:next-frame "right"}})))) + +(t/deftest sanitize-imported-shortcuts-drops-context-without-viable-entries + (t/testing "a context whose entries are all skipped is left out, so the + import does not clear the user's shortcuts in that context" + (t/is (= {:shortcuts {} + :skipped ["viewer/not-a-real-shortcut"]} + (sut/sanitize-imported-shortcuts + {:viewer {:not-a-real-shortcut "x"}}))))) + +(t/deftest sanitize-imported-shortcuts-keeps-empty-context + (t/is (= {:shortcuts {:workspace {}} + :skipped []} + (sut/sanitize-imported-shortcuts {:workspace {}})))) + +(t/deftest sanitize-imported-shortcuts-returns-nil-for-non-maps + (t/is (nil? (sut/sanitize-imported-shortcuts nil))) + (t/is (nil? (sut/sanitize-imported-shortcuts "shortcuts"))) + (t/is (nil? (sut/sanitize-imported-shortcuts 42))) + (t/is (nil? (sut/sanitize-imported-shortcuts [])))) + +(t/deftest sanitize-imported-shortcuts-output-is-valid + (let [{:keys [shortcuts]} (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r" + :not-a-real-shortcut "x" + :increase-zoom 1} + :dashboard "not-a-map" + :viewer {:next-frame "right"} + :unknown-context {}})] + (t/is (= {:valid? true} + (sut/validate-imported-shortcuts shortcuts))))) + +(t/deftest valid-command-accepts-key-combinations + (t/are [command] (true? (ui-shortcuts/valid-command? command)) + "" + "a" + "ctrl+shift+k" + "Ctrl+s" + "command+alt+ñ" + "+" + "shift++" + "ctrl+plus" + "shift+\"" + "g v" + "escape" + "Escape" + "f1" + "f12" + "f24" + "num5" + "alt")) + +(t/deftest valid-command-rejects-other-strings + (t/are [command] (false? (ui-shortcuts/valid-command? command)) + "foo" + "foo+bar" + "a+b" + "ctrl+foo" + "ctrl+" + "ctrl+shift" + "++" + "f25" + " " + " a" + "a " + "a b" + "🙂" + nil + 123)) + +(t/deftest reserved-command-matches-non-customizable-combinations + (t/testing "cancel, copy and paste keep their keys; spelling and modifier + order do not matter, and a reserved step taints a sequence" + (with-redefs [cf/check-platform? (constantly false)] + (t/are [command] (true? (ui-shortcuts/reserved-command? command)) + "escape" + "Escape" + "esc" + "ctrl+c" + "Control+C" + "mod+c" + "ctrl+v" + "Control+V" + "mod+v" + "g escape" + "ctrl+c g")))) + +(t/deftest reserved-command-ignores-other-combinations + (with-redefs [cf/check-platform? (constantly false)] + (t/are [command] (false? (ui-shortcuts/reserved-command? command)) + "" + "v" + "c" + "shift+escape" + "ctrl+shift+v" + "ctrl+shift+c" + "ctrl+alt+c" + "shift+alt+c" + nil))) + +(t/deftest valid-command-accepts-every-default-command + (t/testing "a profile exported by Penpot always holds valid commands" + (doseq [shortcuts [psc/shortcuts wsc/shortcuts dsc/shortcuts vsc/shortcuts] + [shortcut-key {:keys [command]}] shortcuts + command (if (vector? command) command [command]) + :when (some? command)] + (t/is (ui-shortcuts/valid-command? command) + (str shortcut-key " -> " (pr-str command)))))) + (t/deftest import-custom-shortcuts-updates-multiple-contexts (let [imported {:workspace {:add-comment "alt+c"} :viewer {:select-all "ctrl+alt+a"}} @@ -173,17 +366,17 @@ (t/is (= "ctrl+shift+a" (:imported select-all-entry)))))) (t/deftest compute-diff-shows-empty-string-as-disabled-shortcut - (let [imported {:workspace {:escape ""}} + (let [imported {:workspace {:draw-rect ""}} current {} diff (diff-modal/compute-diff imported current)] (t/is (pos? (count diff))) - (let [entry (first (filter #(= :escape (:key %)) diff))] + (let [entry (first (filter #(= :draw-rect (:key %)) diff))] (t/is (= :workspace (:context entry))) (t/is (= "" (:imported entry)))))) (t/deftest compute-diff-excludes-unchanged-shortcuts - (let [imported {:workspace {:escape ""}} - current {:workspace {:escape ""}} + (let [imported {:workspace {:draw-rect ""}} + current {:workspace {:draw-rect ""}} diff (diff-modal/compute-diff imported current)] (t/is (empty? diff)))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 014f5b622b..c1699b9564 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2181,6 +2181,10 @@ msgstr "This file is not a valid shortcuts export." msgid "import-shortcuts.no-changes" msgstr "No differences found between the current and imported shortcuts." +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153 +msgid "import-shortcuts.skipped-entries" +msgstr "These entries can't be imported: they don't match any Penpot shortcut or use a reserved key combination." + #: src/app/main/ui/components/color_input.cljs:31 msgid "inspect.attributes.color" msgstr "Color" @@ -4937,6 +4941,10 @@ msgstr "Edit %s" msgid "shortcuts.edit-modal.conflict" msgstr "Already assigned to \"%s\". Saving will move it to this action." +#: src/app/main/ui/shortcuts.cljs:836 +msgid "shortcuts.edit-modal.reserved" +msgstr "This combination is reserved by Penpot and can't be assigned." + #: src/app/main/ui/shortcuts.cljs:636 msgid "shortcuts.edit-modal.reset-pending" msgstr "Saving will reset this shortcut to default:" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6eaf06da2a..b579adfd77 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2207,6 +2207,10 @@ msgstr "Este archivo no es una exportación de atajos válida." msgid "import-shortcuts.no-changes" msgstr "No se encontraron diferencias entre los atajos actuales y los importados." +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153 +msgid "import-shortcuts.skipped-entries" +msgstr "Estas entradas no se pueden importar: no corresponden a ningún atajo de Penpot o usan una combinación reservada." + #: src/app/main/ui/components/color_input.cljs:31 msgid "inspect.attributes.color" msgstr "Color" @@ -4991,6 +4995,10 @@ msgstr "Editar %s" msgid "shortcuts.edit-modal.conflict" msgstr "Ya asignado a \"%s\". Al guardar, pasará a esta acción." +#: src/app/main/ui/shortcuts.cljs:836 +msgid "shortcuts.edit-modal.reserved" +msgstr "Penpot reserva esta combinación y no se puede asignar." + #: src/app/main/ui/shortcuts.cljs:636 msgid "shortcuts.edit-modal.reset-pending" msgstr "Al guardar, este atajo volverá a su valor por defecto:" From 0505eb3b1dfca04f67d5f036650d6bb279098c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 22 Oct 2026 13:39:26 +0200 Subject: [PATCH 122/167] :bug: Fix pasted gradient fills in text editor v3 (#12516) * :bug: Keep text gradients in place when pasting at the caret Text gradients are stored as fractions of the whole text box. Paste copied those fractions unchanged, so in a box of another size the handles and colours moved away from the pasted text. Copy from the v3 editor now records where each span with a gradient or image fill starts in its text box, and the box size, in the clipboard payload. A new WASM export, text_editor_get_range_origin, gives the start of any text range. Paste at the caret finds where those spans start in the target and rewrites their gradients so the handles keep the same pixel offset from the span. Auto-sized texts use the box size they get when the editor closes. AI-assisted-by: claude-opus-5-5 * :bug: Rebuild text gradient shaders when the text box resizes Text gradient and image shaders are built into the cached Skia paragraphs against the text box size. A resize with no content change reused those paragraphs, so the colours stayed matched to the old box until a reload. The text now remembers the box size its shaders were built for and lays itself out again when that size changes and a span has a gradient or image fill. Text with only solid fills keeps its cache. AI-assisted-by: claude-opus-5-5 * :bug: Keep text gradients in place when pasting on the canvas A text selection pasted on the canvas becomes a new text whose box differs from the source, so its gradients moved like they did for caret paste. After adding the new text, lay it out, find where its gradient spans start and move their gradients to keep the same pixel offset from the span, in the same undo step. The range origin export now takes a shape id, so it works on texts that are not being edited. AI-assisted-by: claude-opus-5-5 * :bug: Keep text gradients in place when pasting copied texts at the caret Text shapes copied whole, rather than as an editor selection, reached the caret paste without any geometry, so their gradients still moved away from the pasted text. A shape copy now records where the gradient and image spans of each selected text start, and the box size, by shape id. Paste joins them in the order of the joined paragraphs, with no origin for texts copied without one. Copied texts each have their own box, so every span origin now carries its own box size instead of one box for the whole payload. AI-assisted-by: claude-opus-5-5 --- .../app/main/data/workspace/clipboard.cljs | 30 ++-- .../src/app/main/data/workspace/texts.cljs | 121 +++++++++++++--- frontend/src/app/render_wasm/text_editor.cljs | 60 ++++++++ frontend/src/app/render_wasm/text_paste.cljs | 47 ++++++ frontend/src/app/util/text/clipboard.cljs | 20 +-- .../data/workspace_texts_test.cljs | 30 ++++ .../text_editor_content_range_test.cljs | 16 +++ .../render_wasm/text_paste_test.cljs | 72 ++++++++++ .../util_text_clipboard_test.cljs | 9 ++ render-wasm/src/render/text_editor.rs | 134 ++++++++++++++++-- render-wasm/src/shapes/text.rs | 97 ++++++++++++- render-wasm/src/wasm/text_editor.rs | 54 +++++++ 12 files changed, 644 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 557747d080..4796ab969c 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -209,14 +209,18 @@ file (dsh/lookup-file state file-id) version (get file :version) - initial {:type :copied-shapes - :features features - :version version - :file-id file-id - :team-id (:current-team-id state) - :selected selected - :objects {} - :images #{}} + geometry (dwtxt/copied-text-geometry state objects selected) + + initial (cond-> {:type :copied-shapes + :features features + :version version + :file-id file-id + :team-id (:current-team-id state) + :selected selected + :objects {} + :images #{}} + (some? geometry) + (assoc :text-geometry geometry)) shapes (->> (cfh/selected-with-children objects selected) (keep (d/getf objects)))] @@ -584,6 +588,7 @@ [:objects [:map-of ::sm/uuid :map]] [:images [:set :map]] + [:text-geometry {:optional true} [:map-of ::sm/uuid :map]] [:position {:optional true} ::gpt/point]]) (def ^:private @@ -1075,9 +1080,11 @@ (ptk/reify ::paste-html-text ptk/WatchEvent (watch [_ state _] - (let [[text content] + (let [payload (when (v3-html-paste? state) + (text-clipboard/html->payload html)) + [text content] (if (v3-html-paste? state) - (if-let [payload (text-clipboard/html->payload html)] + (if (some? payload) (let [content (dwtxt/payload-content state payload)] [(txt/content->text content) content]) (when-let [fragment (some-> (text-clipboard/html->fragment html) @@ -1109,7 +1116,8 @@ (dwsh/create-and-add-shape :text x y shape (when skip-edition? {:skip-edition? true}))) (if skip-edition? - (rx/of (dwwt/resize-wasm-text-debounce id {:undo-group id + (rx/of (dwtxt/move-new-text-gradients id payload) + (dwwt/resize-wasm-text-debounce id {:undo-group id :undo-id undo-id})) (rx/of (dwu/commit-undo-transaction undo-id)))))))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 0acbe4e92f..8ac273e1d2 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -17,6 +17,7 @@ [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] [app.common.math :as mth] + [app.common.render-wasm.api.select :as wselect] [app.common.transit :as t] [app.common.types.fills :as types.fills] [app.common.types.modifiers :as ctm] @@ -1563,13 +1564,17 @@ (wasm.text-editor/text-editor-has-selection?)) (let [text (wasm.text-editor/text-editor-export-selection)] (when (seq text) - (let [content (when (features/active-feature? state "text-editor-wasm/v1-html-paste") - (-> (wasm.text-editor/text-editor-get-active-shape-id) - (wasm.text-editor/selection-content))) - payload (when (some? content) - (text-clipboard/payload->html content - (:current-file-id state) - (:current-team-id state)))] + (let [shape-id (wasm.text-editor/text-editor-get-active-shape-id) + content (when (features/active-feature? state "text-editor-wasm/v1-html-paste") + (wasm.text-editor/selection-content shape-id)) + geometry (when (some? content) + (wasm.text-editor/content-geometry shape-id content + (wasm.text-editor/selection-start))) + payload (when (some? content) + (text-clipboard/payload->html content + (:current-file-id state) + (:current-team-id state) + geometry))] {"text/plain" text "text/html" (clipboard/plain-text->html text payload)}))))) @@ -1634,25 +1639,51 @@ (when (and (map? data) (= :copied-shapes (:type data))) data)))) +(defn copied-text-geometry + "The geometry (see `text-editor/content-geometry`) of each selected text in + `objects` with gradient or image spans, by id, for a shape copy; nil when none." + [state objects selected] + (when (features/active-feature? state "text-editor-wasm/v1-html-paste") + (not-empty + (into {} + (keep (fn [id] + (let [{:keys [content] :as shape} (get objects id)] + (when (cfh/text-shape? shape) + (some->> (wasm.text-editor/content-geometry id content {:para 0 :offset 0}) + (vector id)))))) + selected)))) + +(defn- text-origins + "The span origins of `text` in `text-geometry`, nil for each span without one." + [text-geometry {:keys [id content]}] + (or (get-in text-geometry [id :origins]) + (mapv #(mapv (constantly nil) (:children %)) + (-> content :children first :children)))) + (defn- copied-shapes->payload "A text payload joining the content of the copied text shapes, top to bottom and left to right; nil when none of the copied shapes is a text." - [{:keys [objects selected file-id team-id]}] + [{:keys [objects selected file-id team-id text-geometry]}] (let [texts (->> selected (keep #(get objects %)) (filter cfh/text-shape?) (sort-by (juxt :y :x)))] (when (seq texts) - {:file-id file-id - :team-id team-id - :content (assoc-in (:content (first texts)) - [:children 0 :children] - (into [] (mapcat #(-> % :content :children first :children)) texts))}))) + (cond-> {:file-id file-id + :team-id team-id + :content (assoc-in (:content (first texts)) + [:children 0 :children] + (into [] (mapcat #(-> % :content :children first :children)) texts))} + (some? text-geometry) + (assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)}))))) (defn- penpot-paste [state payload] - {:penpot? true - :fragment (text-paste/content->fragment (payload-content state payload))}) + (let [content (payload-content state payload)] + {:penpot? true + :content content + :geometry (:geometry payload) + :fragment (text-paste/content->fragment content)})) (defn- clipboard->paste "What to paste for clipboard `html` and `text`, or nil: text and text shapes copied @@ -1676,10 +1707,63 @@ {:penpot? false :fragment fragment})))) +(defn- target-geometry + "Where the gradient spans of `pasted`, inserted at `start`, begin in `shape` with + `content`, laid over the box it gets once resized to its text; nil when none." + [{:keys [id grow-type]} content pasted start] + (when-let [geometry (wasm.text-editor/content-geometry id pasted start)] + (if (= :fixed grow-type) + geometry + ;; Auto-sized boxes only fit their text when resized (for the editor, when it + ;; closes), which drops the vertical-align offset in the measured origins. + (let [{:keys [width height]} (wasm.api/get-text-dimensions id) + fit (fn [{:keys [box] :as origin}] + (let [offset (case (some-> (:vertical-align content) name) + "center" (/ (- (:height box) height) 2) + "bottom" (- (:height box) height) + 0)] + (-> origin + (update :y - offset) + (assoc :box {:width (if (= :auto-width grow-type) width (:width box)) + :height height}))))] + (update geometry :origins (partial mapv (partial mapv #(some-> % fit)))))))) + +(defn- move-pasted-gradients + "`content`, the shape's after restyling the paste, with the pasted gradients + keeping their place on their spans. Pushes the result to WASM." + [shape content {:keys [geometry] :as paste} start] + (let [pasted (:content paste) + target (when (some? geometry) (target-geometry shape content pasted start))] + (if (some? target) + (let [content (text-paste/move-gradients content pasted start geometry target)] + (wasm.api/set-shape-text-content (:id shape) content) + content) + content))) + +(defn move-new-text-gradients + "Event moving the gradients of `payload`, just pasted on the canvas as the text + `id`, so they keep their place on their spans." + [id {:keys [geometry]}] + (ptk/reify ::move-new-text-gradients + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [content] :as shape} (dsh/lookup-shape state id) + start {:para 0 :offset 0} + target (when (and (some? shape) (some? geometry) (wasm.api/initialized?)) + (wselect/use-shape id) + (wasm.api/set-shape-text-content id content) + (target-geometry shape content content start))] + (if (some? target) + (rx/of (dwsh/update-shapes + [id] + #(update % :content text-paste/move-gradients content start geometry target) + {:attrs #{:content}})) + (rx/empty)))))) + (defn- insert-fragment "Insert the text of `fragment` at the caret and restyle it: with its own styles or overrides, else with the pending caret style. Returns the event saving it." - [{:keys [fragment penpot?]}] + [state {:keys [fragment penpot?] :as paste}] (let [shape-id (wasm.text-editor/text-editor-get-active-shape-id) start (wasm.text-editor/selection-start) styled? (or penpot? (text-paste/styled? fragment)) @@ -1696,7 +1780,8 @@ ;; Sync first so the cached content stays index-aligned with WASM. (wasm.text-editor/text-editor-sync-content) (if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start penpot?)] - (v3-update-editor-content shape-id content) + (let [shape (dsh/lookup-shape state shape-id)] + (v3-update-editor-content shape-id (move-pasted-gradients shape content paste start))) (v3-sync-editor-content))) pending? @@ -1713,7 +1798,7 @@ (watch [_ state _] (if-let [paste (when (wasm.text-editor/text-editor-has-focus?) (clipboard->paste state html text))] - (let [event (insert-fragment paste)] + (let [event (insert-fragment state paste)] (wasm.api/request-render-preserving-target "text-paste") (if (some? event) (rx/of event) (rx/empty))) (rx/empty))))) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index c7e529ec6f..4348117ed8 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -838,6 +838,66 @@ (when-not (and (= start-para end-para) (= start-offset end-offset)) (content-range content range)))))) +(defn span-ranges + "The char range of every span of `content`, a slice that starts at `start` + (`{:para :offset}`) in its shape: one vector of ranges per paragraph." + [content {:keys [para offset]}] + (into [] + (map-indexed + (fn [idx paragraph] + (let [para-idx (+ para idx) + spans (:children paragraph) + starts (reductions + (if (zero? idx) offset 0) (map (comp count :text) spans))] + (mapv (fn [span span-start] + {:start-para para-idx + :start-offset span-start + :end-para para-idx + :end-offset (+ span-start (count (:text span)))}) + spans + starts)))) + (-> content :children first :children))) + +(defn- range-origin + "Where the text in `range` starts in the text `shape-id`, shape-local, with the size + of the box its fills are laid over: {:x :y :box {:width :height}}, or nil." + [shape-id {:keys [start-para start-offset end-para end-offset]}] + (when (wasm/ready?) + (let [id (uuid/get-u32 shape-id) + byte-offset (mem/alloc 16) + f32-offset (mem/->offset-32 byte-offset) + heap (mem/get-heap-f32) + has-text? (h/call wasm/internal-module "_text_get_range_origin" + (aget id 0) (aget id 1) (aget id 2) (aget id 3) + start-para start-offset end-para end-offset byte-offset) + result (when has-text? + {:x (aget heap f32-offset) + :y (aget heap (+ f32-offset 1)) + :box {:width (aget heap (+ f32-offset 2)) + :height (aget heap (+ f32-offset 3))}})] + (mem/free) + result))) + +(defn- box-relative-fill? + [fill] + (or (some? (:fill-color-gradient fill)) + (some? (:fill-image fill)))) + +(defn content-geometry + "Where the gradient or image spans of `content`, a slice starting at `start`, begin + in the text `shape-id`, each with its box size; nil when it has none." + [shape-id content start] + (let [ranges (span-ranges content start) + origins (mapv (fn [paragraph paragraph-ranges] + (mapv (fn [span range] + (when (some box-relative-fill? (:fills span)) + (range-origin shape-id range))) + (:children paragraph) + paragraph-ranges)) + (-> content :children first :children) + ranges)] + (when (some some? (flatten origins)) + {:origins origins}))) + (defn- clean-styles "Drop nil-valued attrs (unlike the DOM path, our merge would keep them and fail the backend schema); a per-span fn is passed through untouched. A nil diff --git a/frontend/src/app/render_wasm/text_paste.cljs b/frontend/src/app/render_wasm/text_paste.cljs index dbdd6cffec..4520327ce6 100644 --- a/frontend/src/app/render_wasm/text_paste.cljs +++ b/frontend/src/app/render_wasm/text_paste.cljs @@ -126,3 +126,50 @@ (update-in content [:children 0 :children (+ (:para start) idx)] restyle attrs)) content (rest (map-indexed vector fragment))))) + +;; --- Gradients of text copied in Penpot + +(defn- move-gradient + "`gradient`, in fractions of the `from` box, in fractions of the `to` box, with its + points at the same pixel offset from the span origin." + [gradient from to] + (let [{x0 :x y0 :y {w0 :width h0 :height} :box} from + {x1 :x y1 :y {w1 :width h1 :height} :box} to + move-x #(/ (+ x1 (- (* % w0) x0)) w1) + move-y #(/ (+ y1 (- (* % h0) y0)) h1)] + (cond-> (-> gradient + (update :start-x move-x) + (update :start-y move-y) + (update :end-x move-x) + (update :end-y move-y)) + ;; A radial width is scaled by the box aspect ratio when drawn. + (= :radial (:type gradient)) + (update :width #(/ (* % w0 h1) (* h0 w1)))))) + +(defn- move-fills + [span from to] + (update span :fills + (fn [fills] + (mapv #(cond-> % + (some? (:fill-color-gradient %)) + (update :fill-color-gradient move-gradient from to)) + fills)))) + +(defn- sized? + [{{:keys [width height]} :box}] + (and (pos? width) (pos? height))) + +(defn move-gradients + "`content` with the gradients of `pasted` (the content inserted at `start`) moved + from the `source` geometry to the `target` one (see `text-editor/content-geometry`)." + [content pasted start source target] + (let [entries (map vector + (flatten (text-editor/span-ranges pasted start)) + (flatten (:origins source)) + (flatten (:origins target)))] + (reduce (fn [content [range from to]] + (if (and (some? from) (some? to) (sized? from) (sized? to)) + (text-editor/apply-styles-over-range content range #(move-fills % from to)) + content)) + content + entries))) diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index f0f76bee6b..35df340afe 100644 --- a/frontend/src/app/util/text/clipboard.cljs +++ b/frontend/src/app/util/text/clipboard.cljs @@ -291,14 +291,18 @@ (.decode (js/TextDecoder.)))) (defn payload->html - "Hidden markup carrying `content`, copied from `file-id` in `team-id`." - [content file-id team-id] - (let [payload {:type :copied-text - :version 1 - :file-id file-id - :team-id team-id - :content content}] - (str "
    "))) + "Hidden markup carrying `content`, copied from `file-id` in `team-id`, and the + `geometry` of its spans in their text box (see `text-editor/content-geometry`)." + ([content file-id team-id] + (payload->html content file-id team-id nil)) + ([content file-id team-id geometry] + (let [payload (cond-> {:type :copied-text + :version 1 + :file-id file-id + :team-id team-id + :content content} + (some? geometry) (assoc :geometry geometry))] + (str "")))) (defn html->payload "The Penpot payload in `html`, or nil when it has none." diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 9fe41428e7..eac8693cff 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -626,3 +626,33 @@ result (dwt/merge-live-text-dimensions :auto-width selrect {:width 0 :height 0})] (t/is (= 50 (:width result))) (t/is (= 25 (:height result)))))) + +;; --------------------------------------------------------------------------- +;; copied-shapes->payload +;; --------------------------------------------------------------------------- + +(defn- copied-text + [id y & spans] + {:id id + :type :text + :x 0 + :y y + :content {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children (mapv #(hash-map :text %) spans)}]}]}}) + +(t/deftest copied-shapes-payload-joins-text-geometry + (let [top-id (uuid/next) + bottom-id (uuid/next) + origin {:x 4 :y 2 :box {:width 100 :height 20}} + data {:file-id (uuid/next) + :selected #{bottom-id top-id} + :objects {top-id (copied-text top-id 0 "a" "b") + bottom-id (copied-text bottom-id 50 "c")}}] + (t/testing "the origins follow the joined paragraphs, nil for texts without geometry" + (t/is (= {:origins [[nil nil] [origin]]} + (:geometry (#'dwt/copied-shapes->payload + (assoc data :text-geometry {bottom-id {:origins [[origin]]}})))))) + (t/testing "a copy without geometry gives a payload without it" + (t/is (not (contains? (#'dwt/copied-shapes->payload data) :geometry)))))) diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs index 675b1ea18a..6b6445f362 100644 --- a/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs @@ -54,3 +54,19 @@ (text-editor/content-range (content (paragraph {} (span "a😀b" bold))) {:start-para 0 :start-offset 1 :end-para 0 :end-offset 4}))))) + +(t/deftest span-ranges + (let [slice (text-editor/content-range source {:start-para 0 :start-offset 3 + :end-para 1 :end-offset 5})] + (t/testing "the first paragraph's spans start at the slice start; later ones at 0" + (t/is (= [[{:start-para 0 :start-offset 3 :end-para 0 :end-offset 6} + {:start-para 0 :start-offset 6 :end-para 0 :end-offset 10} + {:start-para 0 :start-offset 10 :end-para 0 :end-offset 14}] + [{:start-para 1 :start-offset 0 :end-para 1 :end-offset 3} + {:start-para 1 :start-offset 3 :end-para 1 :end-offset 5}]] + (text-editor/span-ranges slice {:para 0 :offset 3}))))) + + (t/testing "offsets count UTF-16 units like WASM's" + (t/is (= [[{:start-para 2 :start-offset 1 :end-para 2 :end-offset 4}]] + (text-editor/span-ranges (content (paragraph {} (span "😀b" bold))) + {:para 2 :offset 1}))))) diff --git a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs index 795309aff3..c9bc145647 100644 --- a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs @@ -190,3 +190,75 @@ (t/is (= [[(span "A") (assoc brand :text "x")] [(assoc brand :text "y") (span "B")]] (spans-of result)))))) + +;; --- Gradients + +(defn- gradient-fill [gradient] + {:fills [{:fill-color-gradient (merge {:type :linear :width 1 :stops []} gradient)}]}) + +(def ^:private source-box {:width 200 :height 40}) + +(def ^:private target-box {:width 100 :height 80}) + +(def ^:private source-geometry + {:origins [[{:x 50 :y 0 :box source-box}]]}) + +(def ^:private target-geometry + {:origins [[{:x 10 :y 40 :box target-box}]]}) + +(defn- pasted-gradient [content] + (-> (spans-of content) first second :fills first :fill-color-gradient)) + +(t/deftest gradients-keep-their-offset-from-the-span + (let [linear (gradient-fill {:start-x 0.25 :start-y 0 :end-x 0.75 :end-y 1}) + pasted (content [(span "HELLO" linear)]) + target (content [(span "abc") (span "HELLO" linear)]) + result (text-paste/move-gradients target pasted {:para 0 :offset 3} + source-geometry target-geometry)] + (t/testing "points move into the target box at the same pixel offsets" + (t/is (= {:start-x 0.1 :start-y 0.5 :end-x 1.1 :end-y 1} + (select-keys (pasted-gradient result) [:start-x :start-y :end-x :end-y])))) + (t/testing "a linear width stays as it was" + (t/is (= 1 (:width (pasted-gradient result))))) + (t/testing "text before the paste keeps its fills" + (t/is (= (span "abc") (-> (spans-of result) first first)))))) + +(t/deftest radial-width-follows-the-aspect-ratio + (let [radial (gradient-fill {:type :radial :start-x 0.5 :start-y 0.5 :end-x 0.5 :end-y 1}) + pasted (content [(span "HELLO" radial)]) + target (content [(span "abc") (span "HELLO" radial)]) + result (text-paste/move-gradients target pasted {:para 0 :offset 3} + source-geometry target-geometry)] + ;; 200x40 is 5:1 and 100x80 is 5:4, so the width grows 4 times. + (t/is (= 4 (:width (pasted-gradient result)))))) + +(t/deftest spans-without-geometry-are-left-alone + (let [red {:fills [{:fill-color "#ff0000" :fill-opacity 1}]} + linear (gradient-fill {:start-x 0 :start-y 0 :end-x 1 :end-y 1}) + pasted (content [(span "red" red) (span "grad" linear)]) + target (content [(span "red" red) (span "grad" linear)])] + (t/testing "a span with no origin keeps its gradient" + (t/is (= target (text-paste/move-gradients + target pasted {:para 0 :offset 0} + {:origins [[nil nil]]} + {:origins [[nil nil]]})))) + (t/testing "an empty box leaves the gradients as they were" + (t/is (= target (text-paste/move-gradients + target pasted {:para 0 :offset 0} + {:origins [[nil {:x 0 :y 0 :box source-box}]]} + {:origins [[nil {:x 0 :y 0 :box {:width 0 :height 80}}]]})))))) + +(t/deftest each-span-moves-from-its-own-box + ;; Copied text shapes are joined into one paste, each span keeping its box. + (let [linear (gradient-fill {:start-x 0.5 :start-y 0 :end-x 0.5 :end-y 1}) + pasted (content [(span "a" linear)] [(span "b" linear)]) + source {:origins [[{:x 0 :y 0 :box {:width 100 :height 20}}] + [{:x 0 :y 0 :box {:width 200 :height 40}}]]} + target {:origins [[{:x 0 :y 0 :box {:width 400 :height 80}}] + [{:x 0 :y 40 :box {:width 400 :height 80}}]]} + result (text-paste/move-gradients pasted pasted {:para 0 :offset 0} source target) + points #(select-keys (-> % :fills first :fill-color-gradient) + [:start-x :start-y :end-x :end-y])] + (t/is (= [{:start-x 0.125 :start-y 0 :end-x 0.125 :end-y 0.25} + {:start-x 0.25 :start-y 0.5 :end-x 0.25 :end-y 1}] + (map (comp points first) (spans-of result)))))) diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs index c0bcdbd07f..e5534a1517 100644 --- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -213,6 +213,15 @@ (:content (clipboard/html->payload (str "" safari "Hello"))))))))) +(t/deftest payload-geometry-round-trip + (let [content (text-content gradient-span) + geometry {:origins [[{:x 12.5 :y 0.2 :box {:width 200 :height 40}}]]} + hidden (clipboard/payload->html content file-id team-id geometry)] + (t/testing "the payload keeps where the copied spans sat in their text box" + (t/is (= geometry (:geometry (clipboard/html->payload hidden))))) + (t/testing "the content stays as copied" + (t/is (= content (:content (clipboard/html->payload hidden))))))) + (t/deftest html-without-payload (t/testing "HTML without a payload, or with a broken one, gives nil" (t/is (nil? (clipboard/html->payload "

    Hello

    "))) diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 7adfa5d692..fdf9adb037 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -3,7 +3,7 @@ use crate::shapes::{vertical_align_offset, Shape, TextContent, Type}; use crate::state::{TextEditorState, TextSelection}; use crate::view::Viewbox; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; -use skia_safe::{BlendMode, Canvas, Color, Paint, Rect}; +use skia_safe::{BlendMode, Canvas, Color, Paint, Point, Rect}; pub fn render_overlay( canvas: &Canvas, @@ -228,10 +228,40 @@ fn calculate_selection_rects( text_content: &TextContent, shape: &Shape, ) -> Vec { - let mut rects = Vec::new(); - let start = selection.start(); let end = selection.end(); + range_rects( + (start.paragraph, start.offset), + (end.paragraph, end.offset), + text_content, + shape, + ) +} + +/// Top-left corner of the first line box of the text between `start` and `end` +/// (paragraph, char offset), shape-local; `None` when the range covers no text. +pub(crate) fn range_origin( + start: (usize, usize), + end: (usize, usize), + text_content: &TextContent, + shape: &Shape, +) -> Option { + range_rects(start, end, text_content, shape) + .first() + .map(|rect| Point::new(rect.left(), rect.top())) +} + +/// Line boxes, shape-local, of the text between `start` and `end` (paragraph, +/// char offset). +fn range_rects( + start: (usize, usize), + end: (usize, usize), + text_content: &TextContent, + shape: &Shape, +) -> Vec { + let mut rects = Vec::new(); + let (start_paragraph, start_offset) = start; + let (end_paragraph, end_offset) = end; let paragraphs = text_content.paragraphs(); let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); @@ -242,7 +272,7 @@ fn calculate_selection_rects( let para_height = laid_out_para.height(); // Check if this paragraph is in selection range - if para_idx < start.paragraph || para_idx > end.paragraph { + if para_idx < start_paragraph || para_idx > end_paragraph { y_offset += para_height; continue; } @@ -255,14 +285,14 @@ fn calculate_selection_rects( .map(|span| span.text.chars().count()) .sum(); - let range_start = if para_idx == start.paragraph { - start.offset + let range_start = if para_idx == start_paragraph { + start_offset } else { 0 }; - let range_end = if para_idx == end.paragraph { - end.offset + let range_end = if para_idx == end_paragraph { + end_offset } else { para_char_count }; @@ -291,3 +321,91 @@ fn calculate_selection_rects( rects } + +#[cfg(test)] +mod tests { + use super::*; + use crate::globals::TestRenderResourcesGuard; + use crate::render::RenderResources; + use crate::shapes::{ + FontFamily, FontStyle, GrowType, Paragraph, TextAlign, TextDirection, TextSpan, + }; + use crate::uuid::Uuid; + + /// A laid-out fixed text, `width` wide, with a single span of `text`. + fn text_shape(text: &str, width: f32) -> Shape { + let span = TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + 14.0, + 1.2, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![], + ); + let mut content = + TextContent::new(Rect::from_xywh(0.0, 0.0, width, 100.0), GrowType::Fixed); + content.add_paragraph(Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + vec![span], + )); + + let mut shape = Shape::new(Uuid::nil()); + shape.set_shape_type(Type::Text(content)); + shape.set_selrect(0.0, 0.0, width, 100.0); + shape + } + + fn origin_of(shape: &Shape, start: usize, end: usize) -> Option { + let Type::Text(content) = &shape.shape_type else { + unreachable!() + }; + range_origin((0, start), (0, end), content, shape) + } + + #[test] + fn range_origin_is_where_the_text_starts() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let shape = text_shape("hello world", 500.0); + + let hello = origin_of(&shape, 0, 5).expect("hello has text"); + let world = origin_of(&shape, 6, 11).expect("world has text"); + + assert!(hello.x.abs() < 0.01, "{hello:?}"); + assert!((0.0..1.0).contains(&hello.y), "{hello:?}"); + assert!(world.x > hello.x, "{world:?}"); + assert!((world.y - hello.y).abs() < 0.01, "{world:?}"); + } + + #[test] + fn range_origin_of_wrapped_text_is_on_its_first_line() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + // Too narrow for both words on one line. + let shape = text_shape("hello world", 45.0); + + let world = origin_of(&shape, 6, 11).expect("world has text"); + + assert!(world.x.abs() < 0.01, "{world:?}"); + assert!(world.y > 10.0, "{world:?}"); + } + + #[test] + fn range_origin_is_none_for_an_empty_range() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let shape = text_shape("hello", 500.0); + + assert!(origin_of(&shape, 2, 2).is_none()); + } +} diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 9a9212ddd2..49b8fc4861 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -424,6 +424,9 @@ pub struct TextContent { /// into cached Skia paragraphs. Kept across move clones so paint can /// translate glyphs + shaders together. See `cached_layout_paint_offset`. layout_paint_origin: Option, + /// Box size the gradient and image shaders were built for. They are relative + /// to the whole box, so a new size must rebuild them. + layout_paint_size: Option<(f32, f32)>, } impl PartialEq for TextContent { @@ -448,6 +451,7 @@ impl TextContent { layout_version: 0, layout_width: None, layout_paint_origin: None, + layout_paint_size: None, } } @@ -464,6 +468,7 @@ impl TextContent { layout_version: 0, layout_width: None, layout_paint_origin: None, + layout_paint_size: None, } } @@ -1114,7 +1119,28 @@ impl TextContent { if self.layout.needs_update() || self.layout_version != self.content_version { return false; } - self.layout_matches_paint_container(shape) + self.layout_matches_paint_container(shape) && self.paint_size_matches(&shape.selrect()) + } + + /// True when some span has a fill laid over the whole box (gradient or image). + fn has_box_relative_fills(&self) -> bool { + self.paragraphs.iter().any(|paragraph| { + paragraph.children().iter().any(|span| { + span.fills + .iter() + .any(|fill| !matches!(fill, shapes::Fill::Solid(_))) + }) + }) + } + + /// True when the cached fill shaders fit a box of `selrect`'s size. + fn paint_size_matches(&self, selrect: &Rect) -> bool { + if !self.has_box_relative_fills() { + return true; + } + self.layout_paint_size.is_some_and(|(width, height)| { + (width - selrect.width()).abs() < 0.01 && (height - selrect.height()).abs() < 0.01 + }) } pub(crate) fn layout_cache_versions_match(&self) -> bool { @@ -1156,11 +1182,13 @@ impl TextContent { // Paragraph paints (incl. absolute image/gradient shaders) were built // against `self.bounds()` in `paragraph_builder_group_from_text`. self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y())); + self.layout_paint_size = Some((self.bounds.width(), self.bounds.height())); } pub fn force_next_layout_update(&mut self) { self.layout_width = None; self.layout_paint_origin = None; + self.layout_paint_size = None; self.layout.cached_extrect.set(None); // Bump the content version so update_layout can't early-return: auto-width // shapes always match their container and clearing the cache above doesn't @@ -1182,6 +1210,7 @@ impl TextContent { if !self.layout.needs_update() && self.layout_version == self.content_version && layout_matches_container + && self.paint_size_matches(&selrect) { return self.size; } @@ -1351,6 +1380,7 @@ impl Default for TextContent { layout_version: 0, layout_width: None, layout_paint_origin: None, + layout_paint_size: None, } } } @@ -1994,6 +2024,8 @@ pub fn calculate_position_data( #[cfg(test)] mod tests { use super::*; + use crate::globals::TestRenderResourcesGuard; + use crate::render::RenderResources; #[test] fn vertical_align_top_keeps_the_content_at_the_origin() { @@ -2345,6 +2377,69 @@ mod tests { ); } + fn gradient_fill() -> shapes::Fill { + shapes::Fill::LinearGradient(shapes::Gradient::new( + (0.5, 0.0), + (0.5, 1.0), + 255, + 1.0, + &[(skia::Color::RED, 0.0), (skia::Color::BLUE, 1.0)], + )) + } + + fn solid_fill() -> shapes::Fill { + shapes::Fill::Solid(shapes::SolidColor(skia::Color::RED)) + } + + /// A laid-out text shape at 200x100 whose only span has `fills`. + fn laid_out_text_shape(grow_type: GrowType, fills: Vec) -> Shape { + let mut paragraph = test_paragraph(&["hello"]); + paragraph.children_mut()[0].fills = fills; + let mut content = TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), grow_type); + content.add_paragraph(paragraph); + + let mut shape = Shape::new(Uuid::nil()); + shape.set_shape_type(shapes::Type::Text(content)); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + shape + } + + /// Whether resizing `shape` to `width`x`height`, with no content change, + /// rebuilt its cached Skia paragraphs. + fn resize_rebuilds_paragraphs(shape: &mut Shape, width: f32, height: f32) -> bool { + let before = Rc::clone(&shape.get_text_content().layout.paragraphs); + assert!(!before.is_empty(), "the shape should be laid out"); + shape.set_selrect(0.0, 0.0, width, height); + !Rc::ptr_eq(&before, &shape.get_text_content().layout.paragraphs) + } + + // Gradient and image shaders are built into the cached paragraphs against + // the box size, so a new box size must rebuild them. + + #[test] + fn resizing_a_fixed_text_height_rebuilds_box_relative_fills() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let mut shape = laid_out_text_shape(GrowType::Fixed, vec![gradient_fill()]); + assert!(resize_rebuilds_paragraphs(&mut shape, 200.0, 300.0)); + } + + #[test] + fn resizing_an_auto_width_text_rebuilds_box_relative_fills() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let mut shape = laid_out_text_shape(GrowType::AutoWidth, vec![gradient_fill()]); + assert!(resize_rebuilds_paragraphs(&mut shape, 400.0, 300.0)); + } + + #[test] + fn resizing_a_fixed_text_height_keeps_the_cache_for_solid_fills() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let mut shape = laid_out_text_shape(GrowType::Fixed, vec![solid_fill()]); + assert!(!resize_rebuilds_paragraphs(&mut shape, 200.0, 300.0)); + } + #[test] fn cached_layout_paint_offset_tracks_selrect_move() { let mut content = sample_text_content(); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 43725727f1..9853876249 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -3,6 +3,7 @@ use macros::{wasm_error, ToJs}; use crate::globals::{get_render_state, get_text_editor_state}; use crate::math::{Matrix, Point}; use crate::mem; +use crate::render::text_editor::range_origin; use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::state::{State, TextEditorEvent, TextEditorState}; use crate::utils::uuid_from_u32_quartet; @@ -1032,3 +1033,56 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool { true }) } + +/// Writes where the text between the UTF-16 offsets starts in the text shape +/// (x, y, shape-local) and its box size (width, height); false when no text. +#[no_mangle] +#[wasm_error] +#[allow(clippy::too_many_arguments)] +pub extern "C" fn text_get_range_origin( + a: u32, + b: u32, + c: u32, + d: u32, + start_paragraph: u32, + start_offset: u32, + end_paragraph: u32, + end_offset: u32, + buffer_ptr: *mut f32, +) -> Result { + with_state!(state, { + let Some(shape) = state.shapes.get(&uuid_from_u32_quartet(a, b, c, d)) else { + return Ok(false); + }; + let Type::Text(text_content) = &shape.shape_type else { + return Ok(false); + }; + + let paragraphs = text_content.paragraphs(); + let to_char = |paragraph: u32, offset: u32| { + let paragraph = paragraph as usize; + let offset = paragraphs + .get(paragraph) + .map(|para| para.utf16_offset_to_char(offset as usize)) + .unwrap_or(offset as usize); + (paragraph, offset) + }; + let Some(origin) = range_origin( + to_char(start_paragraph, start_offset), + to_char(end_paragraph, end_offset), + text_content, + shape, + ) else { + return Ok(false); + }; + let selrect = shape.selrect(); + + unsafe { + *buffer_ptr = origin.x; + *buffer_ptr.add(1) = origin.y; + *buffer_ptr.add(2) = selrect.width(); + *buffer_ptr.add(3) = selrect.height(); + } + Ok(true) + }) +} From f8f1a668f7533545ae581f6531678e0c90dbc645 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 22 Oct 2026 15:29:37 +0200 Subject: [PATCH 123/167] :recycle: Use skip-if-active for cron no-overlap in backend worker (#12412) The cron tick kept its own active-job count beside the shared submit option. Delegate the no-overlap check to ::jobs/skip-if-active so both paths share one queue-qualified check served by the partial index. A tick now skips only when a live job sits on the :cron queue, and a skip records both the cron counter and the generic skipped counter. Closes #12411 AI-assisted-by: muse-spark-1.3-contributor --- backend/src/app/worker/cron.clj | 73 ++++++++----------- .../test/backend_tests/worker_cron_test.clj | 48 ++++++++++++ 2 files changed, 79 insertions(+), 42 deletions(-) diff --git a/backend/src/app/worker/cron.clj b/backend/src/app/worker/cron.clj index 4e797006dd..0105d9d57e 100644 --- a/backend/src/app/worker/cron.clj +++ b/backend/src/app/worker/cron.clj @@ -12,8 +12,8 @@ detection, retries and telemetry as for every other job. To keep the no-overlap semantics (one entry is not re-fired while the previous instance lives), the scheduler only submits when there is no active - instance (`new`/`scheduled`/`running`/`retry` with the same `name` - and `label` of the entry). The `FOR UPDATE SKIP LOCKED` claim on the + instance (`new`/`scheduled`/`running`/`retry` with the same `name`, + `queue` and `label` of the entry). The `FOR UPDATE SKIP LOCKED` claim on the scheduled_task row already serializes the tick between nodes." (:require [app.common.data :as d] @@ -41,18 +41,6 @@ on conflict (id) do nothing") -;; The tenant filter is what makes the no-overlap check per instance: the -;; job this count decides about is submitted to this tenant's queue, so -;; counting other tenants' active jobs would let one instance skip a tick -;; for work that is none of its business. -(def ^:private sql:count-active-jobs - "SELECT count(*) AS n - FROM job - WHERE name = ? - AND label = ? - AND tenant = ? - AND status IN ('new','scheduled','running','retry')") - (defn- synchronize-cron-entries [{:keys [::db/conn ::entries]}] (doseq [{:keys [id cron]} entries] @@ -70,14 +58,16 @@ (defn submit-cron-job "Submit the system job for the entry to the `:cron` queue. Uses the entry id as the job label (stable per entry) and returns the created - job-id. No-overlap pre-check lives in the caller." + job-id, or nil when an active instance (`new`/`scheduled`/`running`/ + `retry` with the same name, queue and label) already exists." [cfg {:keys [job props id]}] (jobs/submit cfg - {::jobs/name job - ::jobs/params (or props {}) ;; entries don't carry props; default to empty map - ::jobs/queue :cron - ::jobs/label (name id)})) + {::jobs/name job + ::jobs/params (or props {}) ;; entries don't carry props; default to empty map + ::jobs/queue :cron + ::jobs/label (name id) + ::jobs/skip-if-active true})) (declare ^:private schedule-cron-task) @@ -100,29 +90,28 @@ {:id id} {::db/return-keys false}) - ;; The count query is not locked, so a job could - ;; transition from running to completed between the - ;; count and the submit. This is acceptable: the - ;; next tick will submit if needed. The FOR UPDATE - ;; SKIP LOCKED on the scheduled_task row prevents - ;; race conditions between nodes. - (let [tenant (cf/get :tenant) - row (db/exec-one! conn [sql:count-active-jobs (d/name job) (str id) tenant]) - active (or (get row :n) 0)] - (if (pos? active) - (do - (db/after-commit #(metrics/record-cron cfg :skipped :active)) - (l/dbg :hint "skip" - :reason "scheduling, active instance exists" - :id id :job (d/name job))) - (let [job-id (submit-cron-job cfg entry) - elapsed (ct/format-duration (tpoint))] - (db/after-commit #(metrics/record-cron cfg :submitted :none)) - (l/dbg :hint "submit" - :id id - :job (d/name job) - :job-id (str job-id) - :elapsed elapsed))))))) + ;; The no-overlap check lives in the submit itself + ;; (`::jobs/skip-if-active`): the check and the + ;; insert share this transaction, so a job could + ;; still transition from running to completed + ;; between the check and the insert. This is + ;; acceptable: the next tick will submit if + ;; needed. The FOR UPDATE SKIP LOCKED on the + ;; scheduled_task row prevents race conditions + ;; between nodes. + (if-let [job-id (submit-cron-job cfg entry)] + (let [elapsed (ct/format-duration (tpoint))] + (db/after-commit #(metrics/record-cron cfg :submitted :none)) + (l/dbg :hint "submit" + :id id + :job (d/name job) + :job-id (str job-id) + :elapsed elapsed)) + (do + (db/after-commit #(metrics/record-cron cfg :skipped :active)) + (l/dbg :hint "skip" + :reason "scheduling, active instance exists" + :id id :job (d/name job))))))) (catch InterruptedException _ (metrics/record-cron cfg :error :interrupted) diff --git a/backend/test/backend_tests/worker_cron_test.clj b/backend/test/backend_tests/worker_cron_test.clj index d8ab47209a..e0797ab4b0 100644 --- a/backend/test/backend_tests/worker_cron_test.clj +++ b/backend/test/backend_tests/worker_cron_test.clj @@ -199,3 +199,51 @@ (t/testing "and the other tenant's row is untouched" (t/is (= "running" (:status (th/db-get :job {:name "session-gc" :tenant "other-tenant"}))))))) + +(t/deftest cron-tick-is-not-blocked-by-active-job-on-another-queue + (let [cfg (make-cfg) + entry {:id "session-gc-other-queue" + :job :session-gc + :cron (ucron/cron "0 0 0 * * ?")}] + + (th/db-insert! :scheduled-task {:id "session-gc-other-queue" + :cron-expr "0 0 * * *"}) + ;; A live job with the same name, label and tenant, but sitting on + ;; another queue. The cron no-overlap check is qualified by queue, + ;; so it is none of this tick's business. + (th/db-insert! :job {:id (uuid/next) + :name "session-gc" + :tenant (cf/get :tenant) + :queue "default" + :label "session-gc-other-queue" + :status "running" + :params (db/json {}) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + + (.join ^Thread (#'cron/execute-cron-task cfg entry)) + + (t/testing "the tick still submits its own job on the cron queue" + (t/is (= 1 (count-jobs cfg :name "session-gc" + :queue "cron" + :label "session-gc-other-queue")))) + (t/testing "and the row on the other queue is untouched" + (t/is (= "running" (:status (th/db-get :job {:name "session-gc" + :queue "default" + :label "session-gc-other-queue"}))))))) + +(t/deftest cron-submit-skips-when-active-instance-on-cron-queue + (let [cfg (make-cfg) + entry (first (get-cron-entries))] + + (let [job-id (cron/submit-cron-job cfg entry)] + (t/is (some? job-id)) + + (t/testing "a second submit while the first is live returns nil" + (t/is (nil? (cron/submit-cron-job cfg entry)))) + + (t/testing "no second row was inserted" + (t/is (= 1 (count-jobs cfg :name "session-gc" + :queue "cron" + :label "session-gc-no-props"))))))) From ba78ccb8c9c82a2aeb2de3e8863ef375584740a7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 22 Oct 2026 15:31:16 +0200 Subject: [PATCH 124/167] :sparkles: Harden file data offload job with lock, summary and metrics (#12403) Take the file row with FOR UPDATE SKIP LOCKED when the offload job starts and skip the run when the row is locked or gone, so the job never runs while the file is being edited. Every live hot-storage row of the file moves to cold storage inside one all-or-nothing transaction, and the handler answers a summary map with the offloaded rows, bytes and per-type stats. Add bounded metrics for the job: rows and bytes counters labeled by row type on successful runs only, and a timing histogram with the ok, skip and error outcomes. The job definition now requires metrics, the recorders live in app.jobs.metrics, and the existing ephemeral job timing histogram pins its buckets. Cover the job with a new test namespace: the happy path, the locked row, missing and deleted files, the rows and bytes summary with its no-op rerun, the whole-file abort on storage failure, the metric definitions and emissions, and the submit-time params validation. An end-to-end suite reads, snapshots, writes, exports, imports and deletes offloaded files; concurrency tests pin the stale-revn skip, the blocked-edit retry and the reclaimable orphans of a partial failure. The file-gc scheduler test pins one job per eligible file, and the scheduler private helper drops its bang. Document the lock invariant, the deliberate no-pacing decision and the metric label sets in the backend storage memory and the configuration guide. Related to #12398. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/backend/storage.md | 5 + backend/src/app/jobs/metrics.clj | 40 ++ backend/src/app/main.clj | 21 +- backend/src/app/tasks/file_gc_scheduler.clj | 4 +- backend/src/app/tasks/offload_file_data.clj | 82 ++- .../backend_tests/file_gc_scheduler_test.clj | 15 + .../backend_tests/offload_file_data_test.clj | 611 ++++++++++++++++++ docs/technical-guide/configuration.md | 2 + 8 files changed, 766 insertions(+), 14 deletions(-) create mode 100644 backend/test/backend_tests/offload_file_data_test.clj diff --git a/.serena/memories/backend/storage.md b/.serena/memories/backend/storage.md index 189029aa0f..529fcd1cc8 100644 --- a/.serena/memories/backend/storage.md +++ b/.serena/memories/backend/storage.md @@ -125,6 +125,11 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + ` - The `file_data.metadata.storage-ref-id` value points to the storage object. - `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row. - File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata. +- Tiered offload (`:tiered-file-data-storage` flag + `db` backend): `file-gc` submits an `:offload-file-data` job (priority 10, delay 1000, same params) only when it actually processed the file; the handler takes the `file` row with `FOR UPDATE SKIP LOCKED` and skips (`{:skipped 1}`, no error) when the row is locked or gone, then moves every live `file_data` row of that file with `backend = 'db'` to `backend = 'storage'` via `fdata/upsert!` (one `put-object!` in bucket `file-data` per row, `data` becomes nil) inside a single all-or-nothing transaction per file. The skip only serializes because every `file_data` writer holds at least `FOR SHARE` on the `file` row. A storage failure aborts only that file and bubbles for a runner retry; orphans go to `storage-gc-touched`. A second run is a no-op because the query only matches `backend = 'db'`. +- Offload pacing: none by deliberate decision. Sleeps only make sense when batching multiple transactions; the per-file transaction is short by construction and the scheduler run takes minutes per production metrics, so neither loop sleeps. `jobs/heartbeat` (pool-based liveness, ~1 write/s) stays on both paths. +- Offload metrics (bounded labels only, never ids or error text): `penpot_file_data_offload_rows_total{type}` and `penpot_file_data_offload_bytes_total{type}` (`type`: `main`/`snapshot`/`fragment`, successful runs only) plus `penpot_file_data_offload_timing_ms{result}` (`result`: `ok`/`skip`/`error`). Helpers live in `app.jobs.metrics`; the job-def requires `::mtx/metrics`. +- Writes always resolve the backend through `fdata/default-backend` (`cf/get :file-data-backend`), so post-offload edits land back in hot (`db`) storage without extra code; reads stay transparent through `fdata/resolve-file-data` + `decode-file-data` + `realize`. +- Deleting an offloaded row touches its storage object first (`objects-gc/delete-file-data-chunk` via `fdata/process-metadata`), so `storage-gc-touched` reclaims the blob with the usual delay; restoring a file unmarks the referenced objects. ## Metrics diff --git a/backend/src/app/jobs/metrics.clj b/backend/src/app/jobs/metrics.clj index 5ba7df2864..ab1941b1b7 100644 --- a/backend/src/app/jobs/metrics.clj +++ b/backend/src/app/jobs/metrics.clj @@ -176,6 +176,46 @@ (record-value metrics :jobs-request-timing [outcome] (max 0 (long millis))))) +(defn- offload-result-label + "Coerce an offload outcome to its label: lowercase, with `failed` + only when there is no outcome at all. Values come from the fixed + call sites of the offload handler (`ok`, `skip`, `error`), never + from params, IDs or exception text." + [result] + (str/lower (mtx/label result "failed"))) + +(defn- offload-type-label + "Coerce a file data type to its label: lowercase, with `other` only + when there is no type at all. Values are the fixed `file_data.type` + values (`main`, `snapshot`, `fragment`)." + [type] + (str/lower (mtx/label type "other"))) + +(defn record-offload-rows + "Count file data rows moved by the offload job, broken down by row + type. Only successful runs emit rows, so there is no result label." + [cfg type amount] + (record-count (mtx/instance cfg) :file-data-offload-rows + [(offload-type-label type)] + amount)) + +(defn record-offload-bytes + "Count file data bytes moved by the offload job, broken down by row + type. Only successful runs emit bytes, so there is no result label." + [cfg type amount] + (record-count (mtx/instance cfg) :file-data-offload-bytes + [(offload-type-label type)] + amount)) + +(defn record-offload-duration + "Time one offload run from lock to summary (milliseconds). Recorded + for every outcome (`ok`, `skip`, `error`), so its count is the run + total per outcome." + [cfg result millis] + (record-value (mtx/instance cfg) :file-data-offload-timing + [(offload-result-label result)] + (max 0 (long millis)))) + (def ^:private backlog-statuses ["new" "scheduled" "running" "retry" "completed" "failed" "cancelled" "aborted"]) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 7476f9bfb2..2f119a769f 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -389,7 +389,26 @@ {::mdef/name "penpot_audit_ingest_events_total" ::mdef/help "Audit log frontend events ingested, by kind and outcome." ::mdef/labels ["kind" "outcome"] - ::mdef/type :counter}}) + ::mdef/type :counter} + + :file-data-offload-rows + {::mdef/name "penpot_file_data_offload_rows_total" + ::mdef/help "File data rows moved from hot to cold storage by the offload job." + ::mdef/labels ["type"] + ::mdef/type :counter} + + :file-data-offload-bytes + {::mdef/name "penpot_file_data_offload_bytes_total" + ::mdef/help "File data bytes moved from hot to cold storage by the offload job." + ::mdef/labels ["type"] + ::mdef/type :counter} + + :file-data-offload-timing + {::mdef/name "penpot_file_data_offload_timing_ms" + ::mdef/help "Time spent offloading one file from hot to cold storage (milliseconds)." + ::mdef/labels ["result"] + ::mdef/type :histogram + ::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000 300000]}}) (def system-config {::db/pool diff --git a/backend/src/app/tasks/file_gc_scheduler.clj b/backend/src/app/tasks/file_gc_scheduler.clj index 7eaa3801cc..4ac26acf6e 100644 --- a/backend/src/app/tasks/file_gc_scheduler.clj +++ b/backend/src/app/tasks/file_gc_scheduler.clj @@ -29,7 +29,7 @@ FOR UPDATE OF f SKIP LOCKED") -(defn- schedule! +(defn- schedule-files [{:keys [::db/conn] :as cfg} threshold] (let [total (reduce (fn [total {:keys [id modified-at revn]}] (let [params {:file-id id :revn revn}] @@ -83,4 +83,4 @@ (ct/in-past))] (-> cfg (assoc ::db/rollback (:rollback? params)) - (db/tx-run! schedule! threshold)))) + (db/tx-run! schedule-files threshold)))) diff --git a/backend/src/app/tasks/offload_file_data.clj b/backend/src/app/tasks/offload_file_data.clj index fae8b2d75d..e8e3eb12ff 100644 --- a/backend/src/app/tasks/offload_file_data.clj +++ b/backend/src/app/tasks/offload_file_data.clj @@ -10,9 +10,12 @@ (:require [app.common.logging :as l] [app.common.schema :as sm] + [app.common.time :as ct] [app.db :as db] [app.features.fdata :as fdata] [app.jobs :as jobs] + [app.jobs.metrics :as metrics] + [app.metrics :as mtx] [app.storage :as sto] [integrant.core :as ig])) @@ -23,27 +26,83 @@ AND fd.backend = 'db' AND fd.deleted_at IS NULL") +(def ^:private sql:lock-file + ;; NOTE: SKIP LOCKED only serializes against writers that also lock + ;; the file row. Every file_data writer must therefore hold at least + ;; FOR SHARE on it (the update RPC via lock-for-share?, file-gc via + ;; FOR UPDATE); a writer skipping that lock would reopen the + ;; lost-update race this lock closes. + "SELECT id FROM file WHERE id = ? AND deleted_at IS NULL FOR UPDATE SKIP LOCKED") + (defn- offload-file-data - [cfg {:keys [id file-id type] :as fdata}] + "Offload one hot-storage row to cold storage. Returns the row type + and the migrated bytes for the caller to aggregate." + [cfg {:keys [id file-id type data] :as fdata}] (jobs/heartbeat cfg) (fdata/upsert! cfg (assoc fdata :backend "storage")) - (l/trc :file-id (str file-id) - :id (str id) - :type type)) + (let [size (if (bytes? data) (alength ^bytes data) 0)] + (l/trc :hint "file data row offloaded" + :file-id (str file-id) + :id (str id) + :type type + :bytes size) + {:type type + :size size})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HANDLER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- offload-file-rows + "Offload every live hot-storage row of one file inside the caller + transaction. Takes the file row lock first and skips when the row + is locked or gone. Returns a summary map with the offloaded rows, + bytes, per-type stats and the skipped flag." + [{:keys [::db/conn] :as cfg} file-id] + (if (db/exec-one! conn [sql:lock-file file-id]) + (let [{:keys [rows bytes by-type]} + (reduce (fn [stats fdata] + (let [{:keys [type size]} (offload-file-data cfg fdata)] + (-> stats + (update :rows inc) + (update :bytes + size) + (update-in [:by-type type] + (fn [{r :rows b :bytes :or {r 0 b 0}}] + {:rows (inc r) :bytes (+ b size)}))))) + {:rows 0 :bytes 0 :by-type {}} + (db/plan conn [sql:get-file-data file-id]))] + (l/trc :hint "file data offloaded" + :file-id (str file-id) + :offloaded rows + :bytes bytes) + {:offloaded rows :bytes bytes :by-type by-type :skipped 0}) + (do + (l/dbg :hint "skip offload, file is locked or gone" + :file-id (str file-id)) + {:offloaded 0 :bytes 0 :by-type {} :skipped 1}))) + (defn execute-offload-file-data "Plain job handler: offload the file data rows of one file." [cfg params] - (let [file-id (:file-id params)] - (-> cfg - (assoc ::db/rollback (:rollback? params)) - (db/tx-run! (fn [{:keys [::db/conn] :as cfg}] - (run! (partial offload-file-data cfg) - (db/plan conn [sql:get-file-data file-id]))))))) + (let [file-id (:file-id params) + tpoint (ct/tpoint)] + (try + (let [summary (-> cfg + (assoc ::db/rollback (:rollback? params)) + (db/tx-run! offload-file-rows file-id))] + (if (pos? (:skipped summary)) + (do + (metrics/record-offload-duration cfg :skip (inst-ms (tpoint))) + summary) + (do + (doseq [[type {:keys [rows bytes]}] (:by-type summary)] + (metrics/record-offload-rows cfg type rows) + (metrics/record-offload-bytes cfg type bytes)) + (metrics/record-offload-duration cfg :ok (inst-ms (tpoint))) + summary))) + (catch Throwable cause + (metrics/record-offload-duration cfg :error (inst-ms (tpoint))) + (throw cause))))) (def schema:offload-file-data-params [:map @@ -52,7 +111,8 @@ (defmethod ig/assert-key ::job-def [_ params] (assert (db/pool? (::db/pool params)) "expected a valid database pool") - (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided")) + (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided") + (assert (some? (::mtx/metrics params)) "expected metrics to be provided")) (defmethod ig/init-key ::job-def [_ cfg] diff --git a/backend/test/backend_tests/file_gc_scheduler_test.clj b/backend/test/backend_tests/file_gc_scheduler_test.clj index 0edd217acb..a8702d44ef 100644 --- a/backend/test/backend_tests/file_gc_scheduler_test.clj +++ b/backend/test/backend_tests/file_gc_scheduler_test.clj @@ -6,6 +6,7 @@ (ns backend-tests.file-gc-scheduler-test (:require + [app.common.time :as ct] [app.jobs :as jobs] [backend-tests.helpers :as th] [clojure.test :as t])) @@ -26,3 +27,17 @@ (t/is (thrown-with-msg? Exception #"check error" (jobs/submit cfg {::jobs/name :file-gc-scheduler ::jobs/params {:min-age {:bogus true}}})))))) + +(t/deftest scheduler-submits-one-job-per-eligible-file + (let [profile (th/create-profile* 1)] + (doseq [i [1 2 3]] + (let [file (th/create-file* i {:profile-id (:id profile) + :project-id (:default-project-id profile)})] + ;; Make the files deterministically eligible: pending trim and + ;; modified long enough ago. + (th/db-update! :file {:has-media-trimmed false + :modified-at (ct/in-past "2h")} + {:id (:id file)}))) + (let [result (th/run-task! :file-gc-scheduler {:min-age "1h"})] + (t/is (= 3 (:processed result))) + (t/is (= 3 (count (th/db-query :job {:name "file-gc"}))))))) diff --git a/backend/test/backend_tests/offload_file_data_test.clj b/backend/test/backend_tests/offload_file_data_test.clj new file mode 100644 index 0000000000..f4c99c3310 --- /dev/null +++ b/backend/test/backend_tests/offload_file_data_test.clj @@ -0,0 +1,611 @@ +;; 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.offload-file-data-test + (:require + [app.binfile.common :as bfc] + [app.binfile.v3 :as v3] + [app.common.features :as cfeat] + [app.common.time :as ct] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.features.fdata :as fdata] + [app.jobs :as jobs] + [app.jobs.metrics :as metrics] + [app.main :as main] + [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] + [app.rpc :as-alias rpc] + [app.storage :as sto] + [app.storage.tmp :as tmp] + [app.tasks.offload-file-data :as offload] + [backend-tests.helpers :as th] + [clojure.test :as t] + [datoteka.io :as io] + [integrant.core :as ig]) + (:import + io.prometheus.client.Counter + io.prometheus.client.Counter$Child)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- db-rows + [file-id] + (th/db-query :file-data {:file-id file-id})) + +(defn- create-file + [i profile] + (th/create-file* i {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})) + +(t/deftest offloads-file-data-to-storage + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + before (db-rows (:id file))] + + (t/is (pos? (count before))) + (t/is (every? #(= "db" (:backend %)) before)) + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= (count before) (:offloaded result))) + (t/is (zero? (:skipped result)))) + + (let [after (db-rows (:id file))] + (t/is (= (count before) (count after))) + (t/is (every? #(= "storage" (:backend %)) after)) + (t/is (every? #(nil? (:data %)) after))))) + +(t/deftest skips-file-locked-by-another-transaction + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + before (db-rows (:id file)) + locked (promise) + release (promise) + holder (future + (db/tx-run! th/*system* + (fn [{:keys [::db/conn]}] + (db/exec-one! conn ["SELECT id FROM file WHERE id = ? FOR UPDATE" (:id file)]) + (deliver locked true) + @release + :held)))] + + (t/is (= true (deref locked 10000 ::timeout))) + + (try + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 1 (:skipped result)))) + (finally + (deliver release true) + (deref holder 10000 ::timeout))) + + (let [after (db-rows (:id file))] + (t/is (= (count before) (count after))) + (t/is (every? #(= "db" (:backend %)) after))))) + +(t/deftest skips-missing-and-deleted-files + (let [result (th/run-task! :offload-file-data {:file-id (uuid/next)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 1 (:skipped result)))) + + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + _ (th/db-update! :file {:deleted-at (ct/now)} {:id (:id file)}) + before (db-rows (:id file)) + result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 1 (:skipped result))) + (let [after (db-rows (:id file))] + (t/is (= (count before) (count after))) + (t/is (every? #(= "db" (:backend %)) after))))) + +(defn- row-bytes + [{:keys [data]}] + (if (bytes? data) + (alength ^bytes data) + 0)) + +(t/deftest summary-reports-rows-and-bytes + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + before (db-rows (:id file)) + expected-bytes (reduce + 0 (map row-bytes before))] + + (t/is (pos? (count before))) + (t/is (every? some? (map :data before))) + (t/is (pos? expected-bytes)) + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= (count before) (:offloaded result))) + (t/is (= expected-bytes (:bytes result))) + (t/is (zero? (:skipped result)))) + + ;; A second run is a no-op: nothing left on the hot storage. + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 0 (:bytes result))) + (t/is (zero? (:skipped result)))))) + +(t/deftest storage-failure-aborts-only-that-file + (let [profile (th/create-profile* 1) + file-a (create-file 1 profile) + file-b (create-file 2 profile) + real-put @#'sto/put-object! + failing (fn [storage params] + (if (= (:file-id params) (:id file-a)) + (throw (ex-info "storage is down" {:file-id (:id file-a)})) + (real-put storage params)))] + + ;; The failing file aborts as a whole: every row stays on hot storage. + (with-redefs [sto/put-object! failing] + (t/is (thrown? Exception + (th/run-task! :offload-file-data {:file-id (:id file-a)})))) + + (let [rows-a (db-rows (:id file-a))] + (t/is (pos? (count rows-a))) + (t/is (every? #(= "db" (:backend %)) rows-a)) + (t/is (every? some? (map :data rows-a)))) + + ;; The other file is independent and offloads while storage is down for A. + (with-redefs [sto/put-object! failing] + (let [result (th/run-task! :offload-file-data {:file-id (:id file-b)})] + (t/is (pos? (:offloaded result))) + (t/is (pos? (:bytes result))) + (t/is (zero? (:skipped result))))) + + (t/is (every? #(= "storage" (:backend %)) (db-rows (:id file-b)))) + + ;; Once storage recovers, a retry converges with no leftovers on hot. + (let [result (th/run-task! :offload-file-data {:file-id (:id file-a)})] + (t/is (pos? (:offloaded result))) + (t/is (pos? (:bytes result))) + (t/is (zero? (:skipped result)))) + + (let [rows-a (db-rows (:id file-a))] + (t/is (every? #(= "storage" (:backend %)) rows-a)) + (t/is (every? #(nil? (:data %)) rows-a))))) + +(defn- make-metrics + [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics + [:file-data-offload-rows + :file-data-offload-bytes + :file-data-offload-timing])})) + +(defn- offload-cfg + [metrics] + (-> th/*system* + (assoc ::mtx/metrics metrics) + (assoc ::sto/storage (assoc (::sto/storage th/*system*) + ::mtx/metrics metrics)))) + +(defn- counter-value + [metrics id labels] + (let [collector (mtx/get-collector metrics id) + instance (::mdef/instance collector) + child (.labels ^Counter instance (into-array String labels))] + (.get ^Counter$Child child))) + +(def ^:private known-types + ["main" "snapshot" "fragment"]) + +(defn- total-counter + [metrics id] + (reduce + 0 (map #(counter-value metrics id [%]) known-types))) + +(t/deftest metric-definitions-are-pinned + (let [defs main/default-metrics] + (t/is (= "penpot_file_data_offload_rows_total" + (get-in defs [:file-data-offload-rows ::mdef/name]))) + (t/is (= ["type"] + (get-in defs [:file-data-offload-rows ::mdef/labels]))) + (t/is (= :counter + (get-in defs [:file-data-offload-rows ::mdef/type]))) + (t/is (= "penpot_file_data_offload_bytes_total" + (get-in defs [:file-data-offload-bytes ::mdef/name]))) + (t/is (= ["type"] + (get-in defs [:file-data-offload-bytes ::mdef/labels]))) + (t/is (= :counter + (get-in defs [:file-data-offload-bytes ::mdef/type]))) + (t/is (= "penpot_file_data_offload_timing_ms" + (get-in defs [:file-data-offload-timing ::mdef/name]))) + (t/is (= ["result"] + (get-in defs [:file-data-offload-timing ::mdef/labels]))) + (t/is (= :histogram + (get-in defs [:file-data-offload-timing ::mdef/type]))) + (t/is (= [1 10 50 100 500 1000 5000 10000 30000 60000 300000] + (get-in defs [:file-data-offload-timing ::mdef/buckets]))))) + +(t/deftest success-emits-rows-and-bytes + (let [metrics (make-metrics) + profile (th/create-profile* 1) + file (create-file 1 profile) + result (offload/execute-offload-file-data (offload-cfg metrics) + {:file-id (:id file)})] + (t/is (pos? (:offloaded result))) + (t/is (= (double (:offloaded result)) + (total-counter metrics :file-data-offload-rows))) + (t/is (= (double (:bytes result)) + (total-counter metrics :file-data-offload-bytes))) + (t/is (pos? (counter-value metrics :file-data-offload-rows ["main"]))) + (t/is (pos? (total-counter metrics :file-data-offload-rows))))) + +(t/deftest skip-records-skip-and-nothing-else + (let [durations (volatile! []) + rows (volatile! []) + bytes (volatile! [])] + (with-redefs [metrics/record-offload-duration (fn [_ result _] (vswap! durations conj result) nil) + metrics/record-offload-rows (fn [_ type amount] (vswap! rows conj [type amount]) nil) + metrics/record-offload-bytes (fn [_ type amount] (vswap! bytes conj [type amount]) nil)] + (let [result (offload/execute-offload-file-data th/*system* {:file-id (uuid/next)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 1 (:skipped result))))) + (t/is (= [:skip] @durations)) + (t/is (empty? @rows)) + (t/is (empty? @bytes)))) + +(t/deftest error-records-error-and-rethrows + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + durations (volatile! []) + real-put @#'sto/put-object!] + (with-redefs [sto/put-object! (fn [_ _] (throw (ex-info "storage is down" {}))) + metrics/record-offload-duration (fn [_ result _] (vswap! durations conj result) nil)] + (t/is (thrown? Exception + (offload/execute-offload-file-data th/*system* {:file-id (:id file)})))) + (t/is (= [:error] @durations)) + ;; Nothing was migrated: the whole file aborted. + (t/is (every? #(= "db" (:backend %)) (db-rows (:id file)))))) + +(t/deftest missing-metrics-raises + (let [params {::db/pool th/*pool* + ::sto/storage (::sto/storage th/*system*)}] + (t/is (thrown? AssertionError + (ig/assert-key :app.tasks.offload-file-data/job-def params))) + (t/is (nil? (ig/assert-key :app.tasks.offload-file-data/job-def + (assoc params ::mtx/metrics (:app.metrics/metrics th/*system*))))))) + +(t/deftest submit-validates-params-upfront + (let [cfg th/*system*] + (t/testing "valid params submit a durable job" + (t/is (uuid? (jobs/submit cfg {::jobs/name :offload-file-data + ::jobs/params {:file-id (uuid/next)}})))) + (t/testing "invalid params fail at submit instead of burning retries" + (t/is (thrown? Exception + (jobs/submit cfg {::jobs/name :offload-file-data + ::jobs/params {}}))) + (t/is (thrown? Exception + (jobs/submit cfg {::jobs/name :offload-file-data + ::jobs/params {:file-id "not-a-uuid"}})))))) + +(t/deftest file-gc-without-flag-does-not-enqueue-offload + (t/is (not (contains? cf/flags :tiered-file-data-storage))) + (let [profile (th/create-profile* 1) + file (create-file 1 profile)] + (t/is (true? (th/run-task! :file-gc {:file-id (:id file)}))) + (th/run-pending-jobs) + (t/testing "no offload job was enqueued" + (t/is (empty? (th/db-query :job {:name "offload-file-data"})))) + (t/testing "every row stays on hot storage" + (let [rows (db-rows (:id file))] + (t/is (pos? (count rows))) + (t/is (every? #(= "db" (:backend %)) rows)) + (t/is (every? some? (map :data rows))))))) + +(defn- update-file! + [& {:keys [profile-id file-id changes revn] :or {revn 0}}] + (let [params {::th/type :update-file + ::rpc/profile-id profile-id + :id file-id + :session-id (uuid/random) + :revn revn + :vern 0 + :features cfeat/supported-features + :changes changes} + out (th/command! params)] + (t/is (nil? (:error out))) + (:result out))) + +(defn- add-page-with-rect + [profile file page-id shape-id] + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + [{:type :add-page + :name "test" + :id page-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- get-file-data + [profile file-id] + (let [out (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id file-id})] + (t/is (nil? (:error out))) + (get-in out [:result :data]))) + +(t/deftest offloaded-file-reads-identically + (let [profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-id (uuid/next) + shape-id (uuid/next)] + (add-page-with-rect profile file page-id shape-id) + + (let [before-bfc (:data (bfc/get-file th/*system* (:id file) {:realize? true})) + before-rpc (get-file-data profile (:id file))] + + (t/is (some? (get-in before-bfc [:pages-index page-id :objects shape-id]))) + (t/is (= before-bfc before-rpc)) + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (pos? (:offloaded result))) + (t/is (every? #(= "storage" (:backend %)) (remove :deleted-at (db-rows (:id file)))))) + + (t/testing "direct read resolves cold storage transparently" + (t/is (= before-bfc + (:data (bfc/get-file th/*system* (:id file) {:realize? true}))))) + + (t/testing "rpc read resolves cold storage transparently" + (t/is (= before-rpc (get-file-data profile (:id file)))))))) + +(t/deftest snapshot-survives-offload + (let [profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-id (uuid/next) + shape-id (uuid/next)] + (add-page-with-rect profile file page-id shape-id) + + (let [out (th/command! {::th/type :create-file-snapshot + ::rpc/profile-id (:id profile) + :file-id (:id file) + :label "before-offload"})] + (t/is (nil? (:error out))) + (t/is (uuid? (get-in out [:result :id])))) + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (pos? (:offloaded result)))) + + (t/testing "live snapshot rows moved to cold storage" + (let [snapshots (th/db-query :file-data {:file-id (:id file) + :type "snapshot" + :deleted-at nil})] + (t/is (pos? (count snapshots))) + (t/is (every? #(= "storage" (:backend %)) snapshots)) + (t/is (every? #(nil? (:data %)) snapshots)))) + + (t/testing "snapshots are still listed" + (let [out (th/command! {::th/type :get-file-snapshots + ::rpc/profile-id (:id profile) + :file-id (:id file)})] + (t/is (nil? (:error out))) + (t/is (some #(= "before-offload" (:label %)) (:result out))))) + + (t/testing "snapshot restores from cold storage" + (let [snap-id (->> (:result (th/command! {::th/type :get-file-snapshots + ::rpc/profile-id (:id profile) + :file-id (:id file)})) + (filter #(= "before-offload" (:label %))) + first + :id) + out (th/command! {::th/type :restore-file-snapshot + ::rpc/profile-id (:id profile) + :file-id (:id file) + :id snap-id})] + (t/is (some? snap-id)) + (t/is (nil? (:error out))) + (t/is (some? (get-in (get-file-data profile (:id file)) + [:pages-index page-id :objects shape-id]))))))) + +(t/deftest write-after-offload-returns-to-hot-storage + (let [profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-id (uuid/next) + shape-id (uuid/next)] + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (pos? (:offloaded result)))) + + (let [main (th/db-get :file-data {:file-id (:id file) :type "main"})] + (t/is (= "storage" (:backend main))) + (t/is (nil? (:data main)))) + + (add-page-with-rect profile file page-id shape-id) + + (t/testing "the new main row is back on hot storage" + (let [main (th/db-get :file-data {:file-id (:id file) :type "main"})] + (t/is (= "db" (:backend main))) + (t/is (bytes? (:data main))))) + + (t/testing "the written content reads back" + (t/is (some? (get-in (get-file-data profile (:id file)) + [:pages-index page-id :objects shape-id])))))) + +(t/deftest export-import-roundtrip-after-offload + (let [profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-id (uuid/next) + shape-id (uuid/next)] + (add-page-with-rect profile file page-id shape-id) + + (let [before (:data (bfc/get-file th/*system* (:id file) {:realize? true})) + _ (th/run-task! :offload-file-data {:file-id (:id file)}) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries)) + (io/output-stream output)) + + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + imported (:data (bfc/get-file th/*system* (first (:file-ids result)) + {:realize? true}))] + (t/is (= 1 (count (:file-ids result)))) + (t/is (= (set (keys (:pages-index before))) + (set (keys (:pages-index imported))))) + (t/is (= (get-in before [:pages-index page-id :name]) + (get-in imported [:pages-index page-id :name]))) + (t/is (some? (get-in imported [:pages-index page-id :objects shape-id]))))))) + +(t/deftest deleted-offloaded-file-is-fully-purged + (let [storage (sto/resolve th/*system*) + profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-id (uuid/next)] + + (add-page-with-rect profile file page-id (uuid/next)) + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (pos? (:offloaded result)))) + + (let [ref-ids (into #{} + (comp (map :metadata) + (map fdata/decode-metadata) + (keep :storage-ref-id)) + (db-rows (:id file)))] + (t/is (pos? (count ref-ids))) + (t/is (every? some? (map #(sto/get-object storage %) ref-ids))) + + (let [out (th/command! {::th/type :delete-file + ::rpc/profile-id (:id profile) + :id (:id file)})] + (t/is (nil? (:error out)))) + (th/run-pending-jobs) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] + (th/run-task! :objects-gc {}))] + (t/is (pos? (:processed res)))) + + (t/testing "every file data row is gone" + (t/is (empty? (db-rows (:id file))))) + + ;; The touches above landed 8 days in the future, so the touched + ;; collector needs a clock past them plus its deletion delay. + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 9}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (pos? (:delete res)))) + + (t/testing "every referenced storage object is reclaimed" + (let [objects (mapv #(sto/get-object storage %) ref-ids)] + (t/is (every? nil? objects))))))) + +(t/deftest file-gc-with-stale-revn-enqueues-nothing + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + before (db-rows (:id file)) + stale (inc (or (:revn file) 0))] + (t/is (not (th/run-task! :file-gc {:file-id (:id file) :revn stale}))) + (th/run-pending-jobs) + (t/testing "no offload job was enqueued" + (t/is (empty? (th/db-query :job {:name "offload-file-data"})))) + (t/testing "every row stays on hot storage" + (let [after (db-rows (:id file))] + (t/is (= (count before) (count after))) + (t/is (every? #(= "db" (:backend %)) after)))))) + +(t/deftest concurrent-edit-and-offload-stay-coherent + (let [profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-1 (uuid/next) + shape-1 (uuid/next) + page-2 (uuid/next) + shape-2 (uuid/next)] + (add-page-with-rect profile file page-1 shape-1) + + (let [locked (promise) + release (promise) + holder (future + (db/tx-run! th/*system* + (fn [{:keys [::db/conn]}] + (db/exec-one! conn ["SELECT id FROM file WHERE id = ? FOR UPDATE" (:id file)]) + (deliver locked true) + @release + :held))) + _ (t/is (= true (deref locked 10000 ::timeout))) + updater (future + (add-page-with-rect profile file page-2 shape-2))] + + (try + ;; Give the updater time to block on the held row lock. + (th/sleep 500) + (t/is (not (realized? updater))) + + (t/testing "offload skips while the row is locked" + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 1 (:skipped result))))) + (finally + (deliver release true))) + + (t/is (not= ::timeout (deref holder 10000 ::timeout))) + (t/is (not= ::timeout (deref updater 30000 ::timeout)))) + + (t/testing "the blocked edit won as a whole" + (t/is (some? (get-in (get-file-data profile (:id file)) + [:pages-index page-2 :objects shape-2])))) + + (t/testing "a later offload migrates the new bytes, never stale ones" + (let [current (get-file-data profile (:id file)) + result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (pos? (:offloaded result))) + (t/is (= current (get-file-data profile (:id file)))))))) + +(t/deftest partial-failure-leaves-reclaimable-orphans + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + _ (t/is (>= (count (db-rows (:id file))) 2)) + calls (volatile! 0) + orphans (volatile! []) + real-put @#'sto/put-object! + mock (fn [storage params] + (if (= (:file-id params) (:id file)) + (let [n (vswap! calls inc)] + (if (= 1 n) + (let [object (real-put storage params)] + (vswap! orphans conj (:id object)) + object) + (throw (ex-info "storage is down" {})))) + (real-put storage params)))] + + (with-redefs [sto/put-object! mock] + (t/is (thrown? Exception + (th/run-task! :offload-file-data {:file-id (:id file)})))) + + (t/testing "the aborted file keeps every row on hot storage" + (let [rows (db-rows (:id file))] + (t/is (every? #(= "db" (:backend %)) rows)) + (t/is (every? some? (map :data rows))))) + + (t/testing "the orphaned blob is reclaimed once old enough" + (t/is (= 1 (count @orphans))) + (let [storage (sto/resolve th/*system*)] + (t/is (some? (sto/get-object storage (first @orphans)))) + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (pos? (:delete res)))) + (t/is (nil? (sto/get-object storage (first @orphans)))))))) diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index b239d53551..aa4593fa61 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -828,6 +828,8 @@ PENPOT_FLAGS: [...] enable-tiered-file-data-storage PENPOT_FILE_DATA_BACKEND: db ``` +Once enabled, the offload job moves one file per run inside a single transaction: it locks the file row (skipping the run while the file is being edited) and migrates every live data row to the objects storage. You can follow it with `penpot_file_data_offload_rows_total` and `penpot_file_data_offload_bytes_total` (label `type="main|snapshot|fragment"`) and `penpot_file_data_offload_timing_ms` (label `result="ok|skip|error"`). + ### Autosave By default, Penpot stores manually saved versions indefinitely; these can be found in the History tab and can be renamed, restored, deleted, etc. Additionally, the default behavior of on-premise instances is to not keep automatic version history. This automatic behavior can be modified and adapted to each on-premise installation with the corresponding configuration. From 9aa78a536b35e8c7832e99d760b3aed024ffc162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Schr=C3=B6dl?= Date: Thu, 22 Oct 2026 17:32:52 +0200 Subject: [PATCH 125/167] :sparkles: Resolve tokens with the tokenscript-interpreter package (#12348) Replace the @penpot/tokenscript npm package (the TypeScript interpreter) with penpot/tokenscript-interpreter, a Clojure and ClojureScript interpreter that now lives in its own repository and compiles into the frontend build. The frontend adapter (app.main.data.tokenscript) resolves tokens with it behind the existing :tokenscript flag and maps its results to the values and errors the workspace uses: - Token values are validated by the interpreter's token types, passed by their DTCG names: `1foo` is not a dimension, a number has no unit, an opacity is between 0 and 1. Penpot only adds its own limits (font weight names, text decoration keywords). - Division by zero, incompatible units, unknown functions and function arguments out of range get messages of their own, and token pills show the message of their error. - Font family names are quoted before resolving, as in the adapter this replaces (#11750): `Red Hat` stays one name. The token form's value input passed the token name to the tokenscript resolver in place of the typed value, and no name at all for a new token. It now resolves the typed value under the form's token name, as the StyleDictionary path does. StyleDictionary stays the default until the interpreter is stable. frontend/deps.edn pins the interpreter by tag and commit, v0.1.0. Signed-off-by: Florian Schroedl --- common/src/app/common/types/token.cljc | 8 + frontend/deps.edn | 5 + frontend/package.json | 1 - frontend/packages/tokenscript/.gitignore | 3 - frontend/packages/tokenscript/index.js | 2 - frontend/packages/tokenscript/package.json | 13 - frontend/packages/tokenscript/schemas.js | 1631 ----------------- frontend/pnpm-lock.yaml | 68 - .../src/app/main/data/style_dictionary.cljs | 9 +- frontend/src/app/main/data/tokenscript.cljs | 484 +++-- .../data/workspace/tokens/application.cljs | 3 - .../app/main/data/workspace/tokens/color.cljs | 11 +- .../main/data/workspace/tokens/errors.cljs | 16 + .../main/data/workspace/tokens/format.cljs | 36 +- .../data/workspace/tokens/propagation.cljs | 5 +- .../forms/controls/color_input.cljs | 5 +- .../management/forms/controls/combobox.cljs | 5 +- .../forms/controls/fonts_combobox.cljs | 5 +- .../management/forms/controls/input.cljs | 32 +- .../tokens/management/forms/validators.cljs | 5 +- .../tokens/management/token_pill.cljs | 26 +- frontend/src/app/plugins/tokens.cljs | 41 +- frontend/test/frontend_tests/runner.cljs | 2 + .../tokens/tokenscript_test.cljs | 184 ++ frontend/translations/en.po | 16 + frontend/translations/es.po | 16 + 26 files changed, 619 insertions(+), 2013 deletions(-) delete mode 100644 frontend/packages/tokenscript/.gitignore delete mode 100644 frontend/packages/tokenscript/index.js delete mode 100644 frontend/packages/tokenscript/package.json delete mode 100644 frontend/packages/tokenscript/schemas.js create mode 100644 frontend/test/frontend_tests/tokens/tokenscript_test.cljs diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index fd7a96e55f..704fc55083 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -664,6 +664,14 @@ (when (contains? text-decoration-values normalized-value) normalized-value))) +(def text-case-values #{"none" "uppercase" "lowercase" "capitalize"}) + +(defn number-out-of-bounds? + "Whether a resolved numeric token value is outside the safe integer + range." + [n] + (or (>= n sm/max-safe-int) (<= n sm/min-safe-int))) + (def font-weight-aliases {"100" #{"thin" "hairline"}, "200" #{"ultra light" "extralight" "extraleicht" "extra-light" "ultra-light" "ultralight" "extra light"}, diff --git a/frontend/deps.edn b/frontend/deps.edn index c5184e43ae..7e19542857 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -30,6 +30,11 @@ :git/sha "d726717" :git/url "https://github.com/funcool/rumext.git"} + penpot/tokenscript-interpreter + {:git/tag "v0.1.0" + :git/sha "224547d" + :git/url "https://github.com/penpot/tokenscript-interpreter.git"} + instaparse/instaparse {:mvn/version "1.5.0"} garden/garden {:git/url "https://github.com/noprompt/garden" :git/sha "05590ecb5f6fa670856f3d1ab400aa4961047480"} diff --git a/frontend/package.json b/frontend/package.json index e470580a87..f8adeafd03 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,7 +56,6 @@ "@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime", "@penpot/svgo": "penpot/svgo#3.3.0", "@penpot/text-editor": "link:text-editor", - "@penpot/tokenscript": "link:packages/tokenscript", "@penpot/ua-parser": "penpot/ua-parser#1.0.0", "@playwright/test": "1.63.0", "@storybook/addon-docs": "10.6.1", diff --git a/frontend/packages/tokenscript/.gitignore b/frontend/packages/tokenscript/.gitignore deleted file mode 100644 index 0db2a5c1d2..0000000000 --- a/frontend/packages/tokenscript/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -*.sublime-workspace -/.yarn \ No newline at end of file diff --git a/frontend/packages/tokenscript/index.js b/frontend/packages/tokenscript/index.js deleted file mode 100644 index 4d7deb9d72..0000000000 --- a/frontend/packages/tokenscript/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./schemas.js"; -export * from "@tokens-studio/tokenscript-interpreter"; diff --git a/frontend/packages/tokenscript/package.json b/frontend/packages/tokenscript/package.json deleted file mode 100644 index 8cafca6749..0000000000 --- a/frontend/packages/tokenscript/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@penpot/tokenscript", - "version": "1.0.0", - "description": "", - "main": "index.js", - "type": "module", - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", - "author": "Andrey Antukh", - "license": "MPL-2.0", - "dependencies": { - "@tokens-studio/tokenscript-interpreter": "^0.26.0" - } -} diff --git a/frontend/packages/tokenscript/schemas.js b/frontend/packages/tokenscript/schemas.js deleted file mode 100644 index 75ead35ce7..0000000000 --- a/frontend/packages/tokenscript/schemas.js +++ /dev/null @@ -1,1631 +0,0 @@ -// Auto-generated by @tokens-studio/tokenscript-schemas -// Version: @tokens-studio/tokenscript-schemas@v0.4.0 -// GitHub: https://github.com/tokens-studio/tokenscript-schemas -// Command: npx @tokens-studio/tokenscript-schemas bundle preset:css preset:cssColors --output ./schemas.js -// Generated: 2026-02-11T08:46:40.467Z - -import { Config } from "@tokens-studio/tokenscript-interpreter"; - -export const SCHEMAS = [ - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/", - schema: { - "name": "Hex", - "type": "color", - "description": "A color in hex format, e.g. #ff0000", - "schema": { - "type": "object", - "properties": { - "value": { - "type": "string" - } - } - }, - "initializers": [ - { - "title": "Hex Color Initializer", - "keyword": "hex", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "variable c: Color.Hex;\nc.value = {input};\nreturn c;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB (0-1) to Hex format", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to Hex Conversion\n// Converts sRGB (0-1) to hexadecimal string format\n//\n// Examples:\n// sRGB(1, 0, 0) → #ff0000\n// sRGB(0, 1, 0.5) → #00ff80\n\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red channel\nvalue = round({input}.r * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green channel\nvalue = round({input}.g * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue channel\nvalue = round({input}.b * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/", - "target": "$self", - "description": "Converts Display P3 to Hex format (clamps to sRGB gamut)", - "lossless": false, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Display P3 to Hex Conversion\n// Converts P3 (0-1) to hexadecimal string format\n// Note: P3 colors may be out of sRGB gamut, values are clamped to 0-1\n//\n// Examples:\n// P3(1, 0, 0) → #ff0000\n// P3(0, 1, 0.5) → #00ff80\n\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red channel (clamp P3 to sRGB range)\nvalue = {input}.r;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green channel\nvalue = {input}.g;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue channel\nvalue = {input}.b;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - "target": "$self", - "description": "Converts HSL to Hex format", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL to Hex Conversion\n// Converts HSL to hexadecimal string format\n// Reference: Standard HSL to RGB algorithm\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Hex string #rrggbb\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// RGB values (default to achromatic)\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation\nif (s > 0) [\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n\n variable p: Number = 2 * l - q;\n\n // Red (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n\n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n\n // Green (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n\n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n\n // Blue (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n\n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Convert RGB to hex\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red\nvalue = round(r * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green\nvalue = round(g * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue\nvalue = round(b * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "target": "$self", - "description": "Converts OKLCH to Hex format (clamps to sRGB gamut)", - "lossless": false, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH to Hex Conversion\n// Converts OKLCH perceptual color to hexadecimal string format\n// Path: OKLCH → OKLab → XYZ-D65 → Linear sRGB → sRGB → Hex\n//\n// Input: Color.OKLCH with l (0-1), c, h (0-360)\n// Output: Hex string #rrggbb\n\n// Get input OKLCH values\nvariable ok_l: Number = {input}.l;\nvariable ok_c: Number = {input}.c;\nvariable ok_h: Number = {input}.h;\n\n// === Step 1: OKLCH to OKLab (polar to cartesian) ===\nvariable pi: Number = pi();\nvariable deg_to_rad: Number = pi / 180;\nvariable h_rad: Number = ok_h * deg_to_rad;\n\nvariable lab_a: Number = ok_c * cos(h_rad);\nvariable lab_b: Number = ok_c * sin(h_rad);\n\n// === Step 2: OKLab to XYZ-D65 ===\n// Inverse Lab-to-LMS matrix\nvariable lms_l: Number = 1.0 * ok_l + 0.3963377773761749 * lab_a + 0.2158037573099136 * lab_b;\nvariable lms_m: Number = 1.0 * ok_l + -0.1055613458156586 * lab_a + -0.0638541728258133 * lab_b;\nvariable lms_s: Number = 1.0 * ok_l + -0.0894841775298119 * lab_a + -1.2914855480194092 * lab_b;\n\n// Cube the values (inverse of cube root)\nvariable lms_l_cubed: Number = lms_l * lms_l * lms_l;\nvariable lms_m_cubed: Number = lms_m * lms_m * lms_m;\nvariable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n\n// Inverse LMS-to-XYZ matrix\nvariable xyz_x: Number = 1.2268798758459243 * lms_l_cubed + -0.5578149944602171 * lms_m_cubed + 0.2813910456659647 * lms_s_cubed;\nvariable xyz_y: Number = -0.0405757452148008 * lms_l_cubed + 1.1122868032803170 * lms_m_cubed + -0.0717110580655164 * lms_s_cubed;\nvariable xyz_z: Number = -0.0763729366746601 * lms_l_cubed + -0.4214933324022432 * lms_m_cubed + 1.5869240198367816 * lms_s_cubed;\n\n// === Step 3: XYZ-D65 to Linear sRGB ===\nvariable linear_r: Number = 3.2409699419045226 * xyz_x + -1.537383177570094 * xyz_y + -0.4986107602930034 * xyz_z;\nvariable linear_g: Number = -0.9692436362808796 * xyz_x + 1.8759675015077202 * xyz_y + 0.04155505740717559 * xyz_z;\nvariable linear_b: Number = 0.05563007969699366 * xyz_x + -0.20397695888897652 * xyz_y + 1.0569715142428786 * xyz_z;\n\n// === Step 4: Linear sRGB to sRGB (gamma correction) ===\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exp: Number = 0.416666666666667;\n\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n if (linear_r > 0) [\n srgb_r = gamma_scale * pow(linear_r, gamma_exp) - gamma_offset;\n ] else [\n srgb_r = 0;\n ];\n];\n\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n if (linear_g > 0) [\n srgb_g = gamma_scale * pow(linear_g, gamma_exp) - gamma_offset;\n ] else [\n srgb_g = 0;\n ];\n];\n\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n if (linear_b > 0) [\n srgb_b = gamma_scale * pow(linear_b, gamma_exp) - gamma_offset;\n ] else [\n srgb_b = 0;\n ];\n];\n\n// === Step 5: sRGB to Hex ===\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red (clamp to 0-1)\nvalue = srgb_r;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green\nvalue = srgb_g;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue\nvalue = srgb_b;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - schema: { - "name": "SRGB", - "type": "color", - "description": "sRGB color space with normalized 0-1 range. The standard color space for web and displays.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Red channel (0-1)" - }, - "g": { - "type": "number", - "description": "Green channel (0-1)" - }, - "b": { - "type": "number", - "description": "Blue channel (0-1)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "sRGB Color Initializer", - "keyword": "srgb", - "description": "Creates an sRGB color from normalized 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB Color Initializer\n// Creates an sRGB color from normalized 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values in 0-1 range\n\nvariable color_values: List = {input};\nvariable output: Color.SRGB;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/", - "target": "$self", - "description": "Converts RGB (0-255) to sRGB (0-1) by normalizing", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB to sRGB Conversion\n// Converts RGB (0-255) to sRGB (0-1) by normalizing\n// Input: Color.Rgb with r, g, b in 0-255 range\n// Output: Color.SRGB with r, g, b in 0-1 range\n// Lossless: Yes (simple division)\n\nvariable r_normalized: Number = {input}.r / 255;\nvariable g_normalized: Number = {input}.g / 255;\nvariable b_normalized: Number = {input}.b / 255;\n\nvariable output: Color.SRGB;\noutput.r = r_normalized;\noutput.g = g_normalized;\noutput.b = b_normalized;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - "target": "$self", - "description": "Converts HSL to sRGB", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL to sRGB Conversion\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/hsl.js\n//\n// Algorithm:\n// 1. If saturation is 0, it's achromatic: R=G=B=L\n// 2. Otherwise use the HSL to RGB formula:\n// - Calculate intermediate values based on L\n// - Use hue to determine RGB components\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Color.SRGB with r, g, b in 0-1 range\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// Output values\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation (not achromatic)\nif (s > 0) [\n // Calculate intermediate value\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n \n variable p: Number = 2 * l - q;\n \n // Helper function logic inlined for R (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n \n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for G (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n \n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for B (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n \n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = r;\noutput.g = g;\noutput.b = b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - "target": "$self", - "description": "Converts Linear sRGB to sRGB by applying gamma correction", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB to sRGB Conversion\n// Applies gamma correction (transfer function)\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Algorithm:\n// if linear ≤ 0.0031308: srgb = linear * 12.92\n// else: srgb = 1.055 * linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearSRGB with r, g, b in linear 0-1 range\n// Output: Color.SRGB with r, g, b in gamma-corrected 0-1 range\n\n// Gamma correction constants (IEC 61966-2-1)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exponent: Number = 0.416666666666667;\n\n// Get input linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n srgb_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n srgb_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n srgb_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = srgb_r;\noutput.g = srgb_g;\noutput.b = srgb_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/", - schema: { - "name": "Rgb", - "type": "color", - "description": "RGB color", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number" - }, - "g": { - "type": "number" - }, - "b": { - "type": "number" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "function", - "keyword": "rgb", - "description": "Creates an RGB color", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB Color Initializer\n// Creates an RGB color from r, g, b values (0-255 range)\n//\n// Usage: rgb(255, 128, 64) → Color.Rgb { r: 255, g: 128, b: 64 }\n// rgb(255, 128, 64, 0.5) → Color.Rgb { r: 255, g: 128, b: 64, alpha: 0.5 }\n//\n// Input: List of 3 or 4 numbers [r, g, b] or [r, g, b, alpha]\n// Output: Color.Rgb\n\nvariable color_parts: List = {input}; \n\nvariable output: Color.Rgb;\noutput.r = color_parts.get(0);\noutput.g = color_parts.get(1);\noutput.b = color_parts.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_parts.length() > 3) [\n output.alpha = color_parts.get(3);\n];\n\nreturn output;" - } - }, - { - "title": "function", - "keyword": "rgba", - "description": "Creates an RGB color with alpha", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGBA Color Initializer\n// Creates an RGB color with alpha from r, g, b, a values\n//\n// Usage: rgba(255, 128, 64, 0.5) → Color.Rgb { r: 255, g: 128, b: 64, alpha: 0.5 }\n//\n// Input: List of 4 numbers [r, g, b, alpha]\n// Output: Color.Rgb with alpha\n\nvariable color_parts: List = {input}; \n\nvariable output: Color.Rgb;\noutput.r = color_parts.get(0);\noutput.g = color_parts.get(1);\noutput.b = color_parts.get(2);\noutput.alpha = color_parts.get(3);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/", - "target": "$self", - "description": "Converts HEX to RGB", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Hex to RGB Conversion\n// Converts hexadecimal color string to RGB (0-255 range)\n//\n// Supports both shorthand (#RGB) and standard (#RRGGBB) formats:\n// #f00 → RGB(255, 0, 0)\n// #ff0000 → RGB(255, 0, 0)\n//\n// Algorithm:\n// 1. Remove '#' prefix and split into characters\n// 2. For shorthand: duplicate each digit (f → ff)\n// 3. Parse hex pairs to decimal values\n//\n// Input: Color.Hex (e.g., #ff5733)\n// Output: Color.Rgb with r, g, b in 0-255 range\n\n// Split hex string on '#' and get the color value\nvariable color_parts: List = {input}.to_string().split('#'); \nvariable color: List = color_parts.get(1).split(); \nvariable length: Number = color.length(); \n\n// Initialize RGB values\nvariable rgb: List = 0, 0, 0; \n\n// Handle shorthand (#RGB) vs standard (#RRGGBB) format\nif(length == 3) [ \n // Shorthand: duplicate each digit\n rgb.update(0, parse_int(color.get(0).concat(color.get(0)), 16)); \n rgb.update(1, parse_int(color.get(1).concat(color.get(1)), 16)); \n rgb.update(2, parse_int(color.get(2).concat(color.get(2)), 16)); \n] else [ \n // Standard: pair adjacent digits\n rgb.update(0, parse_int(color.get(0).concat(color.get(1)), 16)); \n rgb.update(1, parse_int(color.get(2).concat(color.get(3)), 16)); \n rgb.update(2, parse_int(color.get(4).concat(color.get(5)), 16)); \n]; \n\n// Create output color\nvariable output: Color.Rgb; \noutput.r = rgb.get(0); \noutput.g = rgb.get(1); \noutput.b = rgb.get(2); \n\nreturn output;" - } - }, - { - "source": "$self", - "target": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/", - "description": "Converts RGB to HEX", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB to Hex Conversion\n// Converts RGB (0-255) color to hexadecimal string format\n//\n// Examples:\n// RGB(255, 0, 0) → #ff0000\n// RGB(0, 255, 128) → #00ff80\n//\n// Algorithm:\n// 1. Round each channel to nearest integer\n// 2. Convert to base-16 string\n// 3. Pad single digits with leading zero\n// 4. Concatenate with '#' prefix\n//\n// Input: Color.Rgb with r, g, b in 0-255 range\n// Output: String (hex format #rrggbb)\n\nvariable rgba: List = {input}.r, {input}.g, {input}.b;\nvariable hex: String = \"#\";\nvariable i: Number = 0;\nvariable value: Number = 0;\n\n// Convert each RGB channel to hex\nwhile( i < min(rgba.length(), 3)) [\n value = round(rgba.get(i));\n if(value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n ] else [\n hex = hex.concat(value.to_string(16));\n ];\n i = i + 1;\n];\n\nif (rgba.length() == 4) [\n value = rgba.get(3) * 255; // Convert alpha to 0-255 range\n if(value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n ] else [\n hex = hex.concat(value.to_string(16));\n ];\n];\n\nreturn hex;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - schema: { - "name": "HSL", - "type": "color", - "description": "HSL color space - Hue, Saturation, Lightness. Popular for color pickers and CSS.", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - }, - "s": { - "type": "number", - "description": "Saturation (0-1)" - }, - "l": { - "type": "number", - "description": "Lightness (0-1)" - } - }, - "required": [ - "h", - "s", - "l" - ], - "order": [ - "h", - "s", - "l" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "HSL Color Initializer", - "keyword": "hsl", - "description": "Creates an HSL color from H, S, L values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL Color Initializer\n// Creates an HSL color from H, S, L values\n// Input: List of [h, s, l] or [h, s, l, alpha] values\n\nvariable hsl_values: List = {input};\nvariable output: Color.HSL;\n\noutput.h = hsl_values.get(0);\noutput.s = hsl_values.get(1);\noutput.l = hsl_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (hsl_values.length() > 3) [\n output.alpha = hsl_values.get(3);\n];\n\nreturn output;" - } - }, - { - "title": "HSLA Color Initializer", - "keyword": "hsla", - "description": "Creates an HSL color with alpha from H, S, L, A values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSLA Color Initializer\n// Creates an HSL color with alpha from H, S, L, A values\n//\n// Usage: hsla(180, 0.5, 0.5, 0.8) → Color.HSL { h: 180, s: 0.5, l: 0.5, alpha: 0.8 }\n//\n// Input: List of 4 numbers [h, s, l, alpha]\n// Output: Color.HSL with alpha\n\nvariable hsl_values: List = {input};\nvariable output: Color.HSL;\n\noutput.h = hsl_values.get(0);\noutput.s = hsl_values.get(1);\noutput.l = hsl_values.get(2);\noutput.alpha = hsl_values.get(3);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB to HSL", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to HSL Conversion\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/hsl.js\n//\n// Algorithm:\n// 1. Find max and min of R, G, B\n// 2. L = (max + min) / 2\n// 3. If max == min, S = 0 (achromatic)\n// 4. Else S = (max - min) / (1 - |2L - 1|)\n// 5. H depends on which channel is max\n//\n// Input: Color.SRGB with r, g, b in 0-1 range\n// Output: Color.HSL with h (0-360), s (0-1), l (0-1)\n\n// Get input sRGB values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Find max and min\nvariable max_val: Number = r;\nif (g > max_val) [\n max_val = g;\n];\nif (b > max_val) [\n max_val = b;\n];\n\nvariable min_val: Number = r;\nif (g < min_val) [\n min_val = g;\n];\nif (b < min_val) [\n min_val = b;\n];\n\n// Calculate lightness\nvariable l: Number = (max_val + min_val) / 2;\n\n// Calculate saturation and hue\nvariable s: Number = 0;\nvariable h: Number = 0;\nvariable delta: Number = max_val - min_val;\n\nif (delta > 0) [\n // Not achromatic\n \n // Saturation formula\n variable abs_2l_minus_1: Number = 2 * l - 1;\n if (abs_2l_minus_1 < 0) [\n abs_2l_minus_1 = 0 - abs_2l_minus_1;\n ];\n s = delta / (1 - abs_2l_minus_1);\n \n // Hue calculation depends on which channel is max\n if (max_val == r) [\n h = ((g - b) / delta);\n if (g < b) [\n h = h + 6;\n ];\n ] else [\n if (max_val == g) [\n h = (b - r) / delta + 2;\n ] else [\n h = (r - g) / delta + 4;\n ];\n ];\n \n // Convert to degrees\n h = h * 60;\n];\n\n// Normalize hue to 0-360\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.HSL;\noutput.h = h;\noutput.s = s;\noutput.l = l;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - schema: { - "name": "LinearSRGB", - "type": "color", - "description": "Linear sRGB color space (gamma-decoded). Used for matrix transformations to XYZ and other linear operations.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Linear red channel (0-1)" - }, - "g": { - "type": "number", - "description": "Linear green channel (0-1)" - }, - "b": { - "type": "number", - "description": "Linear blue channel (0-1)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Linear sRGB Color Initializer", - "keyword": "linearsrgb", - "description": "Creates a linear sRGB color from linear 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB Color Initializer\n// Creates a linear sRGB color from linear 0-1 values\n// Input: List of [r, g, b] linear values\n\nvariable color_values: List = {input};\nvariable output: Color.LinearSRGB;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB to Linear sRGB by removing gamma correction (IEC 61966-2-1)", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to Linear sRGB Conversion\n// Removes gamma correction (inverse transfer function)\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Algorithm:\n// if srgb ≤ 0.04045: linear = srgb / 12.92\n// else: linear = ((srgb + 0.055) / 1.055) ^ 2.4\n//\n// Input: Color.SRGB with r, g, b in 0-1 range\n// Output: Color.LinearSRGB with r, g, b in linear 0-1 range\n\n// Gamma correction constants (IEC 61966-2-1)\nvariable threshold: Number = 0.04045;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exponent: Number = 2.4;\n\n// Get input sRGB values\nvariable srgb_r: Number = {input}.r;\nvariable srgb_g: Number = {input}.g;\nvariable srgb_b: Number = {input}.b;\n\n// Convert red channel\nvariable linear_r: Number = 0;\nif (srgb_r <= threshold) [\n linear_r = srgb_r / linear_scale;\n] else [\n linear_r = pow((srgb_r + gamma_offset) / gamma_scale, gamma_exponent);\n];\n\n// Convert green channel\nvariable linear_g: Number = 0;\nif (srgb_g <= threshold) [\n linear_g = srgb_g / linear_scale;\n] else [\n linear_g = pow((srgb_g + gamma_offset) / gamma_scale, gamma_exponent);\n];\n\n// Convert blue channel\nvariable linear_b: Number = 0;\nif (srgb_b <= threshold) [\n linear_b = srgb_b / linear_scale;\n] else [\n linear_b = pow((srgb_b + gamma_offset) / gamma_scale, gamma_exponent);\n];\n\n// Create output\nvariable output: Color.LinearSRGB;\noutput.r = linear_r;\noutput.g = linear_g;\noutput.b = linear_b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to Linear sRGB using inverse transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to Linear sRGB Conversion\n// Reference: ColorJS exact matrix values (extracted via unit vector conversion)\n//\n// Matrix (XYZ D65 to Linear sRGB):\n// [ 3.2409699419045226, -1.537383177570094, -0.4986107602930034 ]\n// [-0.9692436362808796, 1.8759675015077202, 0.04155505740717559]\n// [ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786]\n//\n// Input: Color.XYZD65 with x, y, z tristimulus values\n// Output: Color.LinearSRGB with r, g, b in linear 0-1 range\n\n// Get input XYZ values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// Apply inverse matrix transformation (ColorJS exact values)\n// Row 1: R\nvariable linear_r: Number = 3.2409699419045226 * x + -1.537383177570094 * y + -0.4986107602930034 * z;\n\n// Row 2: G\nvariable linear_g: Number = -0.9692436362808796 * x + 1.8759675015077202 * y + 0.04155505740717559 * z;\n\n// Row 3: B\nvariable linear_b: Number = 0.05563007969699366 * x + -0.20397695888897652 * y + 1.0569715142428786 * z;\n\n// Create output\nvariable output: Color.LinearSRGB;\noutput.r = linear_r;\noutput.g = linear_g;\noutput.b = linear_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - schema: { - "name": "XYZD65", - "type": "color", - "description": "CIE XYZ color space with D65 white point. The primary connection hub for color space conversions.", - "schema": { - "type": "object", - "properties": { - "x": { - "type": "number", - "description": "X tristimulus value" - }, - "y": { - "type": "number", - "description": "Y tristimulus value (luminance)" - }, - "z": { - "type": "number", - "description": "Z tristimulus value" - } - }, - "required": [ - "x", - "y", - "z" - ], - "order": [ - "x", - "y", - "z" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "XYZ-D65 Color Initializer", - "keyword": "xyzd65", - "description": "Creates an XYZ-D65 color from tristimulus values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 Color Initializer\n// Creates an XYZ-D65 color from tristimulus values\n// Input: List of [x, y, z] tristimulus values\n\nvariable xyz_values: List = {input};\nvariable output: Color.XYZD65;\n\noutput.x = xyz_values.get(0);\noutput.y = xyz_values.get(1);\noutput.z = xyz_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - "target": "$self", - "description": "Converts Linear sRGB to XYZ-D65 using the sRGB transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB to XYZ-D65 Conversion\n// Uses the sRGB primaries transformation matrix\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Matrix (Linear RGB → XYZ-D65):\n// [X] [0.4123908 0.3575843 0.1804808] [R]\n// [Y] = [0.2126390 0.7151687 0.0721923] [G]\n// [Z] [0.0193308 0.1191948 0.9505322] [B]\n//\n// Input: Color.LinearSRGB with r, g, b in linear 0-1 range\n// Output: Color.XYZD65 with x, y, z tristimulus values\n\n// Get input linear sRGB values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Matrix multiplication: M × [R, G, B]ᵀ\n// Row 1: X = 0.4123908 * R + 0.3575843 * G + 0.1804808 * B\nvariable x: Number = r * 0.41239079926595934 + g * 0.357584339383878 + b * 0.1804807884018343;\n\n// Row 2: Y = 0.2126390 * R + 0.7151687 * G + 0.0721923 * B\nvariable y: Number = r * 0.21263900587151027 + g * 0.715168678767756 + b * 0.07219231536073371;\n\n// Row 3: Z = 0.0193308 * R + 0.1191948 * G + 0.9505322 * B\nvariable z: Number = r * 0.01933081871559182 + g * 0.11919477979462598 + b * 0.9505321522496607;\n\n// Create output\nvariable output: Color.XYZD65;\noutput.x = x;\noutput.y = y;\noutput.z = z;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/", - "target": "$self", - "description": "Converts Linear P3 to XYZ-D65 using the P3 transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear P3 to XYZ-D65 Conversion\n// Converts linear Display-P3 RGB to CIE XYZ (D65)\n// Reference: CSS Color Level 4 specification\n//\n// Uses the P3 to XYZ-D65 transformation matrix\n// Matrix values from ColorJS / CSS Color Level 4\n//\n// Input: Color.LinearP3 with linear r, g, b values\n// Output: Color.XYZD65 with x, y, z tristimulus values\n\n// Get linear P3 values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Linear P3 to XYZ-D65 matrix\n// Row 1\nvariable m00: Number = 0.4865709486482162;\nvariable m01: Number = 0.26566769316909306;\nvariable m02: Number = 0.1982172852343625;\n\n// Row 2\nvariable m10: Number = 0.2289745640697488;\nvariable m11: Number = 0.6917385218365064;\nvariable m12: Number = 0.079286914093745;\n\n// Row 3\nvariable m20: Number = 0.0;\nvariable m21: Number = 0.04511338185890264;\nvariable m22: Number = 1.043944368900976;\n\n// Matrix multiplication: [x, y, z] = M × [r, g, b]\nvariable x: Number = m00 * r + m01 * g + m02 * b;\nvariable y: Number = m10 * r + m11 * g + m12 * b;\nvariable z: Number = m20 * r + m21 * g + m22 * b;\n\n// Create output\nvariable output: Color.XYZD65;\noutput.x = x;\noutput.y = y;\noutput.z = z;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to XYZ-D65 using inverse LMS transformation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to XYZ-D65 Conversion\n// Reference: ColorJS oklab.js (inverse transformation)\n//\n// Algorithm:\n// 1. Apply inverse Lab-to-LMS matrix to get LMS cone responses\n// 2. Cube the LMS values (inverse of cube root)\n// 3. Apply inverse LMS-to-XYZ matrix\n//\n// Input: Color.OKLab with l, a, b perceptual coordinates\n// Output: Color.XYZD65 with x, y, z tristimulus values\n\n// Get input OKLab values\nvariable ok_l: Number = {input}.l;\nvariable ok_a: Number = {input}.a;\nvariable ok_b: Number = {input}.b;\n\n// Inverse LMStoLab_M matrix (Lab to LMS')\n// These are the inverse of the matrix used in from-xyz-d65.tokenscript\nvariable lms_l: Number = 1.0 * ok_l + 0.3963377773761749 * ok_a + 0.2158037573099136 * ok_b;\nvariable lms_m: Number = 1.0 * ok_l + -0.1055613458156586 * ok_a + -0.0638541728258133 * ok_b;\nvariable lms_s: Number = 1.0 * ok_l + -0.0894841775298119 * ok_a + -1.2914855480194092 * ok_b;\n\n// Cube the values (inverse of cube root)\nvariable lms_l_cubed: Number = lms_l * lms_l * lms_l;\nvariable lms_m_cubed: Number = lms_m * lms_m * lms_m;\nvariable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n\n// Inverse XYZtoLMS_M matrix (LMS to XYZ)\n// From ColorJS oklab.js\nvariable x: Number = 1.2268798758459243 * lms_l_cubed + -0.5578149944602171 * lms_m_cubed + 0.2813910456659647 * lms_s_cubed;\nvariable y: Number = -0.0405757452148008 * lms_l_cubed + 1.1122868032803170 * lms_m_cubed + -0.0717110580655164 * lms_s_cubed;\nvariable z: Number = -0.0763729366746601 * lms_l_cubed + -0.4214933324022432 * lms_m_cubed + 1.5869240198367816 * lms_s_cubed;\n\n// Create output\nvariable output: Color.XYZD65;\noutput.x = x;\noutput.y = y;\noutput.z = z;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/", - schema: { - "name": "LinearP3", - "type": "color", - "description": "Linear Display-P3 color space (gamma-decoded). Used for matrix transformations to XYZ.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Linear red channel (0-1, can exceed for HDR)" - }, - "g": { - "type": "number", - "description": "Linear green channel (0-1, can exceed for HDR)" - }, - "b": { - "type": "number", - "description": "Linear blue channel (0-1, can exceed for HDR)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Linear P3 Color Initializer", - "keyword": "linearp3", - "description": "Creates a linear Display-P3 color from linear 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear P3 Color Initializer\n// Creates a linear Display-P3 color from linear 0-1 values\n// Input: List of [r, g, b] linear values\n\nvariable color_values: List = {input};\nvariable output: Color.LinearP3;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to Linear P3 using the P3 transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to Linear P3 Conversion\n// Converts CIE XYZ (D65) to linear Display-P3 RGB\n// Reference: CSS Color Level 4 specification\n//\n// Uses the inverse of the P3 to XYZ-D65 matrix\n// Matrix values from ColorJS / CSS Color Level 4\n//\n// Input: Color.XYZD65 with x, y, z tristimulus values\n// Output: Color.LinearP3 with linear r, g, b values (may be outside 0-1 for out-of-gamut)\n\n// Get XYZ values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// XYZ to Linear P3 matrix (inverse of P3 to XYZ)\n// Row 1\nvariable m00: Number = 2.4934969119414254;\nvariable m01: Number = -0.9313836179191239;\nvariable m02: Number = -0.40271078445071684;\n\n// Row 2\nvariable m10: Number = -0.8294889695615747;\nvariable m11: Number = 1.7626640603183463;\nvariable m12: Number = 0.023624685841943577;\n\n// Row 3\nvariable m20: Number = 0.03584583024378447;\nvariable m21: Number = -0.07617238926804182;\nvariable m22: Number = 0.9568845240076872;\n\n// Matrix multiplication: [r, g, b] = M × [x, y, z]\nvariable linear_r: Number = m00 * x + m01 * y + m02 * z;\nvariable linear_g: Number = m10 * x + m11 * y + m12 * z;\nvariable linear_b: Number = m20 * x + m21 * y + m22 * z;\n\n// Create output (note: values may be outside 0-1 for out-of-gamut colors)\nvariable output: Color.LinearP3;\noutput.r = linear_r;\noutput.g = linear_g;\noutput.b = linear_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - schema: { - "name": "OKLab", - "type": "color", - "description": "OKLab perceptually uniform color space by Björn Ottosson. L is lightness (0-1), a is green-red axis, b is blue-yellow axis.", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-1)" - }, - "a": { - "type": "number", - "description": "Green-red axis (typically -0.4 to 0.4)" - }, - "b": { - "type": "number", - "description": "Blue-yellow axis (typically -0.4 to 0.4)" - } - }, - "required": [ - "l", - "a", - "b" - ], - "order": [ - "l", - "a", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKLab Color Initializer", - "keyword": "oklab", - "description": "Creates an OKLab color from L, a, b values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab Color Initializer\n// Creates an OKLab color from L, a, b values\n// Input: List of [l, a, b] or [l, a, b, alpha] values\n\nvariable lab_values: List = {input};\nvariable output: Color.OKLab;\n\noutput.l = lab_values.get(0);\noutput.a = lab_values.get(1);\noutput.b = lab_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lab_values.length() > 3) [\n output.alpha = lab_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to OKLab using Björn Ottosson's algorithm", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to OKLab Conversion\n// Uses ColorJS-compatible matrices (recalculated for consistent D65 white)\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/oklab.js\n//\n// Algorithm:\n// 1. XYZ-D65 → LMS (M1 matrix)\n// 2. LMS → LMS' (cube root transformation)\n// 3. LMS' → OKLab (M2 matrix)\n//\n// Input: Color.XYZD65 with x, y, z tristimulus\n// Output: Color.OKLab with l, a, b coordinates\n\n// Get input XYZ-D65 values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// Step 1: XYZ-D65 → LMS cone response\n// ColorJS XYZtoLMS_M matrix (recalculated for consistent reference white)\nvariable lms_l: Number = x * 0.8190224379967030 + y * 0.3619062600528904 + z * -0.1288737815209879;\nvariable lms_m: Number = x * 0.0329836539323885 + y * 0.9292868615863434 + z * 0.0361446663506424;\nvariable lms_s: Number = x * 0.0481771893596242 + y * 0.2642395317527308 + z * 0.6335478284694309;\n\n// Step 2: Apply cube root (γ = 1/3)\n// Note: Using pow with 1/3 exponent (cbrt equivalent for positive values)\nvariable cube_root_exp: Number = 0.3333333333333333;\nvariable lms_l_prime: Number = pow(lms_l, cube_root_exp);\nvariable lms_m_prime: Number = pow(lms_m, cube_root_exp);\nvariable lms_s_prime: Number = pow(lms_s, cube_root_exp);\n\n// Step 3: LMS' → OKLab\n// ColorJS LMStoLab_M matrix\nvariable oklab_l: Number = lms_l_prime * 0.2104542683093140 + lms_m_prime * 0.7936177747023054 + lms_s_prime * -0.0040720430116193;\nvariable oklab_a: Number = lms_l_prime * 1.9779985324311684 + lms_m_prime * -2.4285922420485799 + lms_s_prime * 0.4505937096174110;\nvariable oklab_b: Number = lms_l_prime * 0.0259040424655478 + lms_m_prime * 0.7827717124575296 + lms_s_prime * -0.8086757549230774;\n\n// Create output\nvariable output: Color.OKLab;\noutput.l = oklab_l;\noutput.a = oklab_a;\noutput.b = oklab_b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "target": "$self", - "description": "Converts OKLCH to OKLab (polar to cartesian)", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH to OKLab Conversion\n// Converts polar (C, H) back to cartesian (a, b) coordinates\n// Reference: Björn Ottosson's OKLab specification\n//\n// Algorithm:\n// L stays the same (lightness)\n// a = C * cos(H * π/180)\n// b = C * sin(H * π/180)\n//\n// Input: Color.OKLCH with l, c, h coordinates\n// Output: Color.OKLab with l, a, b coordinates\n\n// Get input OKLCH values\nvariable l: Number = {input}.l;\nvariable c: Number = {input}.c;\nvariable h: Number = {input}.h;\n\n// Constants\nvariable pi: Number = pi();\nvariable deg_to_rad: Number = pi / 180;\n\n// Convert hue to radians\nvariable h_rad: Number = h * deg_to_rad;\n\n// Convert polar to cartesian\nvariable a: Number = c * cos(h_rad);\nvariable b: Number = c * sin(h_rad);\n\n// Create output\nvariable output: Color.OKLab;\noutput.l = l;\noutput.a = a;\noutput.b = b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsl-color/0/", - "target": "$self", - "description": "Converts OKHSL to OKLab", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSL to OKLab Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsl.js\n//\n// Converts OKHSL back to OKLab using:\n// 1. Inverse toe function to recover OKLab lightness\n// 2. Cusp finding to determine max chroma at this hue\n// 3. Saturation denormalization to get actual chroma\n//\n// Input: Color.OKHSL with h (0-360), s (0-1), l (0-1)\n// Output: Color.OKLab with l (0-1), a, b coordinates\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable l: Number = input.l;\n\n// Native constants\nvariable pi_val: Number = pi();\n\n// Toe function constants\nvariable toe_k1: Number = 0.206;\nvariable toe_k2: Number = 0.03;\nvariable toe_k3: Number = 1.17009708737864;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Apply inverse toe function to get OKLab lightness\n// toe_inv(x) = (x^2 + k1*x) / (k3 * (x + k2))\n// ═══════════════════════════════════════════════════════════════════════════\nvariable lab_l: Number = l;\nif (l > 0.0001 && l < 0.9999) [\n lab_l = (l * l + toe_k1 * l) / (toe_k3 * (l + toe_k2));\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Handle achromatic case\n// ═══════════════════════════════════════════════════════════════════════════\nvariable lab_a: Number = 0;\nvariable lab_b: Number = 0;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 3: Compute hue direction\n// ═══════════════════════════════════════════════════════════════════════════\nvariable h_rad: Number = h * pi_val / 180;\nvariable a_: Number = cos(h_rad);\nvariable b_: Number = sin(h_rad);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Pre-compute LMS coefficients (used in steps 4 and 5)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable kl: Number = 0.3963377774 * a_ + 0.2158037573 * b_;\nvariable km: Number = -0.1055613458 * a_ - 0.0638541728 * b_;\nvariable ks: Number = -0.0894841775 * a_ - 1.2914855480 * b_;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 4: Find maximum saturation at this hue (same as forward conversion)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s_max: Number = 0;\n\n// Variables for LMS intermediate calculations (reused in step 5)\nvariable l_lms: Number = 0;\nvariable m_lms: Number = 0;\nvariable s_lms: Number = 0;\n\nif (s >= 0.0001) [\n variable k0: Number = 0;\n variable k1_coef: Number = 0;\n variable k2_coef: Number = 0;\n variable k3_coef: Number = 0;\n variable k4_coef: Number = 0;\n variable wl: Number = 0;\n variable wm: Number = 0;\n variable ws: Number = 0;\n \n variable test_r: Number = -1.88170328 * a_ - 0.80936493 * b_;\n variable test_g: Number = 1.81444104 * a_ - 1.19445276 * b_;\n \n if (test_r > 1) [\n k0 = 1.19086277;\n k1_coef = 1.76576728;\n k2_coef = 0.59662641;\n k3_coef = 0.75515197;\n k4_coef = 0.56771245;\n wl = 4.0767416621;\n wm = -3.3077115913;\n ws = 0.2309699292;\n ] else [\n if (test_g > 1) [\n k0 = 0.73956515;\n k1_coef = -0.45954404;\n k2_coef = 0.08285427;\n k3_coef = 0.12541073;\n k4_coef = -0.14503204;\n wl = -1.2684380046;\n wm = 2.6097574011;\n ws = -0.3413193965;\n ] else [\n k0 = 1.35733652;\n k1_coef = -0.00915799;\n k2_coef = -1.15130210;\n k3_coef = -0.50559606;\n k4_coef = 0.00692167;\n wl = -0.0041960863;\n wm = -0.7034186147;\n ws = 1.7076147010;\n ];\n ];\n \n s_max = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(denom) > 0.000001) [\n s_max = s_max - f * f1 / denom;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 5: Find cusp\n// ═══════════════════════════════════════════════════════════════════════════\nvariable l_cusp: Number = 1;\nvariable c_cusp: Number = 0;\n\nif (s >= 0.0001 && s_max > 0) [\n // Recompute LMS values with refined s_max (kl, km, ks already computed)\n variable l_cusp_temp: Number = 1 + s_max * kl;\n variable m_cusp_temp: Number = 1 + s_max * km;\n variable s_cusp_temp: Number = 1 + s_max * ks;\n \n l_lms = l_cusp_temp * l_cusp_temp * l_cusp_temp;\n m_lms = m_cusp_temp * m_cusp_temp * m_cusp_temp;\n s_lms = s_cusp_temp * s_cusp_temp * s_cusp_temp;\n \n variable r_lin: Number = 4.0767416621 * l_lms - 3.3077115913 * m_lms + 0.2309699292 * s_lms;\n variable g_lin: Number = -1.2684380046 * l_lms + 2.6097574011 * m_lms - 0.3413193965 * s_lms;\n variable b_lin: Number = -0.0041960863 * l_lms - 0.7034186147 * m_lms + 1.7076147010 * s_lms;\n \n variable max_rgb: Number = r_lin;\n if (g_lin > max_rgb) [ max_rgb = g_lin; ];\n if (b_lin > max_rgb) [ max_rgb = b_lin; ];\n \n if (max_rgb > 0) [\n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 6: Compute chroma from saturation\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = 0;\n\nif (s >= 0.0001 && l_cusp > 0.0001 && c_cusp > 0.0001) [\n variable s_t_cusp: Number = c_cusp / l_cusp;\n \n // Denormalize saturation to get chroma\n c = s * lab_l * s_t_cusp;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 7: Convert polar to cartesian\n// ═══════════════════════════════════════════════════════════════════════════\nif (s >= 0.0001) [\n lab_a = c * a_;\n lab_b = c * b_;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKLab;\noutput.l = lab_l;\noutput.a = lab_a;\noutput.b = lab_b;\noutput" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsv-color/0/", - "target": "$self", - "description": "Converts OKHSV to OKLab", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSV to OKLab Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsv.js\n//\n// Converts OKHSV back to OKLab using:\n// 1. Cusp finding to determine gamut boundary at this hue\n// 2. V and S denormalization to get L and C\n//\n// Input: Color.OKHSV with h (0-360), s (0-1), v (0-1)\n// Output: Color.OKLab with l (0-1), a, b coordinates\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable v: Number = input.v;\n\n// Native constants\nvariable pi_val: Number = pi();\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Handle edge cases\n// ═══════════════════════════════════════════════════════════════════════════\nvariable lab_l: Number = v;\nvariable lab_a: Number = 0;\nvariable lab_b: Number = 0;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Compute hue direction\n// ═══════════════════════════════════════════════════════════════════════════\nvariable h_rad: Number = h * pi_val / 180;\nvariable a_: Number = cos(h_rad);\nvariable b_: Number = sin(h_rad);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Pre-compute LMS coefficients (used in steps 3 and 4)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable kl: Number = 0.3963377774 * a_ + 0.2158037573 * b_;\nvariable km: Number = -0.1055613458 * a_ - 0.0638541728 * b_;\nvariable ks: Number = -0.0894841775 * a_ - 1.2914855480 * b_;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 3: Find maximum saturation at this hue\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s_max: Number = 0;\n\n// Variables for LMS intermediate calculations (reused in step 4)\nvariable l_lms: Number = 0;\nvariable m_lms: Number = 0;\nvariable s_lms: Number = 0;\n\nif (s >= 0.0001 && v >= 0.0001) [\n variable k0: Number = 0;\n variable k1_coef: Number = 0;\n variable k2_coef: Number = 0;\n variable k3_coef: Number = 0;\n variable k4_coef: Number = 0;\n variable wl: Number = 0;\n variable wm: Number = 0;\n variable ws: Number = 0;\n \n variable test_r: Number = -1.88170328 * a_ - 0.80936493 * b_;\n variable test_g: Number = 1.81444104 * a_ - 1.19445276 * b_;\n \n if (test_r > 1) [\n k0 = 1.19086277;\n k1_coef = 1.76576728;\n k2_coef = 0.59662641;\n k3_coef = 0.75515197;\n k4_coef = 0.56771245;\n wl = 4.0767416621;\n wm = -3.3077115913;\n ws = 0.2309699292;\n ] else [\n if (test_g > 1) [\n k0 = 0.73956515;\n k1_coef = -0.45954404;\n k2_coef = 0.08285427;\n k3_coef = 0.12541073;\n k4_coef = -0.14503204;\n wl = -1.2684380046;\n wm = 2.6097574011;\n ws = -0.3413193965;\n ] else [\n k0 = 1.35733652;\n k1_coef = -0.00915799;\n k2_coef = -1.15130210;\n k3_coef = -0.50559606;\n k4_coef = 0.00692167;\n wl = -0.0041960863;\n wm = -0.7034186147;\n ws = 1.7076147010;\n ];\n ];\n \n s_max = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(denom) > 0.000001) [\n s_max = s_max - f * f1 / denom;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 4: Find cusp\n// ═══════════════════════════════════════════════════════════════════════════\nvariable l_cusp: Number = 1;\nvariable c_cusp: Number = 0;\n\nif (s >= 0.0001 && v >= 0.0001 && s_max > 0) [\n // Recompute LMS values with refined s_max (kl, km, ks already computed)\n variable l_cusp_temp: Number = 1 + s_max * kl;\n variable m_cusp_temp: Number = 1 + s_max * km;\n variable s_cusp_temp: Number = 1 + s_max * ks;\n \n l_lms = l_cusp_temp * l_cusp_temp * l_cusp_temp;\n m_lms = m_cusp_temp * m_cusp_temp * m_cusp_temp;\n s_lms = s_cusp_temp * s_cusp_temp * s_cusp_temp;\n \n variable r_lin: Number = 4.0767416621 * l_lms - 3.3077115913 * m_lms + 0.2309699292 * s_lms;\n variable g_lin: Number = -1.2684380046 * l_lms + 2.6097574011 * m_lms - 0.3413193965 * s_lms;\n variable b_lin: Number = -0.0041960863 * l_lms - 0.7034186147 * m_lms + 1.7076147010 * s_lms;\n \n variable max_rgb: Number = r_lin;\n if (g_lin > max_rgb) [ max_rgb = g_lin; ];\n if (b_lin > max_rgb) [ max_rgb = b_lin; ];\n \n if (max_rgb > 0) [\n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 5: Compute L and C from V and S\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = 0;\n\nif (s >= 0.0001 && v >= 0.0001 && l_cusp > 0.0001 && c_cusp > 0.0001) [\n variable s_t_cusp: Number = c_cusp / l_cusp;\n variable t_t_cusp: Number = c_cusp / (1 - l_cusp + 0.0001);\n \n // Compute max chroma at this V\n variable c_max_at_v: Number = 0;\n \n if (v <= l_cusp) [\n c_max_at_v = v * s_t_cusp;\n ] else [\n c_max_at_v = (1 - v) * t_t_cusp;\n ];\n \n // Compute actual chroma\n c = s * c_max_at_v;\n \n // Compute L from V and C\n // Reverse of: V = L + C / s_t_cusp\n lab_l = v - c / (s_t_cusp + 0.0001);\n if (lab_l < 0) [ lab_l = 0; ];\n if (lab_l > 1) [ lab_l = 1; ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 6: Convert polar to cartesian\n// ═══════════════════════════════════════════════════════════════════════════\nif (s >= 0.0001 && v >= 0.0001) [\n lab_a = c * a_;\n lab_b = c * b_;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKLab;\noutput.l = lab_l;\noutput.a = lab_a;\noutput.b = lab_b;\noutput" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - schema: { - "name": "OKLCH", - "type": "color", - "description": "OKLCH color space - the polar form of OKLab. L is lightness (0-1), C is chroma, H is hue angle (0-360).", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-1)" - }, - "c": { - "type": "number", - "description": "Chroma (0 to ~0.4 for sRGB gamut)" - }, - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - } - }, - "required": [ - "l", - "c", - "h" - ], - "order": [ - "l", - "c", - "h" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKLCH Color Initializer", - "keyword": "oklch", - "description": "Creates an OKLCH color from L, C, H values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH Color Initializer\n// Creates an OKLCH color from L, C, H values\n// Input: List of [l, c, h] or [l, c, h, alpha] values\n\nvariable lch_values: List = {input};\nvariable output: Color.OKLCH;\n\noutput.l = lch_values.get(0);\noutput.c = lch_values.get(1);\noutput.h = lch_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lch_values.length() > 3) [\n output.alpha = lch_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to OKLCH using Cartesian to polar transformation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to OKLCH Conversion\n// Converts Cartesian (a, b) to polar (C, H) coordinates\n// Reference: Björn Ottosson's OKLab specification\n//\n// Algorithm:\n// L stays the same (lightness)\n// C = sqrt(a² + b²) (chroma - distance from neutral axis)\n// H = atan2(b, a) * 180/π (hue angle in degrees)\n//\n// Input: Color.OKLab with l, a, b coordinates\n// Output: Color.OKLCH with l, c, h coordinates\n\n// Get input OKLab values\nvariable l: Number = {input}.l;\nvariable a: Number = {input}.a;\nvariable b: Number = {input}.b;\n\n// Constants\nvariable pi: Number = pi();\nvariable rad_to_deg: Number = 180 / pi;\n\n// Calculate chroma (distance from neutral axis)\nvariable c: Number = sqrt(a * a + b * b);\n\n// Calculate hue angle using atan2\n// atan2(y, x) returns angle in radians from -π to π\nvariable h_rad: Number = atan2(b, a);\nvariable h: Number = h_rad * rad_to_deg;\n\n// Normalize hue to 0-360 range\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.OKLCH;\noutput.l = l;\noutput.c = c;\noutput.h = h;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsl-color/0/", - schema: { - "name": "OKHSL", - "type": "color", - "description": "OKHSL color space by Björn Ottosson - a perceptually uniform HSL based on OKLab. H is hue (0-360), S is saturation (0-1, normalized to sRGB gamut), L is lightness (0-1). Reference: https://bottosson.github.io/posts/colorpicker/", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees), same as OKLCH hue" - }, - "s": { - "type": "number", - "description": "Saturation (0-1), normalized relative to sRGB gamut boundary" - }, - "l": { - "type": "number", - "description": "Lightness (0-1), perceptually uniform with toe function applied" - } - }, - "required": [ - "h", - "s", - "l" - ], - "order": [ - "h", - "s", - "l" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKHSL Color Initializer", - "keyword": "okhsl", - "description": "Creates an OKHSL color from H, S, L values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSL Color Initializer\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n//\n// Creates an OKHSL color from hue, saturation, and lightness values.\n// OKHSL is a perceptually uniform version of HSL built on OKLab.\n//\n// Parameters:\n// - h: Hue angle (0-360 degrees), same as OKLCH hue\n// - s: Saturation (0-1), normalized to sRGB gamut boundary\n// - l: Lightness (0-1), perceptually uniform with toe function\n// - alpha: Optional alpha channel (0-1)\n//\n// Input: Object with h, s, l, and optional alpha properties\n// Output: Color.OKHSL\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable l: Number = input.l;\n\nvariable output: Color.OKHSL;\noutput.h = h;\noutput.s = s;\noutput.l = l;\n\n// Set alpha if provided\nif (input.alpha != null) [\n output.alpha = input.alpha;\n];\n\noutput" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to OKHSL using Ottosson's algorithm with Halley's method refinement for gamut boundary", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to OKHSL Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsl.js\n// Reference: https://bottosson.github.io/posts/oklab/ (original OKLab paper)\n//\n// OKHSL provides a perceptually uniform HSL with proper saturation scaling.\n// The algorithm uses three key chroma values (C_0, C_mid, C_max) and\n// performs piecewise interpolation to map chroma to saturation.\n//\n// This implementation includes the FULL findGamutIntersection algorithm\n// with Halley's method refinement for accurate gamut boundary detection.\n//\n// Input: Color.OKLab with l (0-1), a, b coordinates\n// Output: Color.OKHSL with h (0-360), s (0-1), l (0-1)\n\nvariable lab_l: Number = input.l;\nvariable lab_a: Number = input.a;\nvariable lab_b: Number = input.b;\n\n// Native constants\nvariable pi_val: Number = pi();\nvariable float_max: Number = 999999999;\n\n// Toe function constants\n// K3 = (1 + K1) / (1 + K2)\nvariable toe_k1: Number = 0.206;\nvariable toe_k2: Number = 0.03;\nvariable toe_k3: Number = 1.17009708737864;\n\n// LMS to OKLab matrix coefficients (LabtoLMS_M columns 1,2)\nvariable lab_lms_kl_a: Number = 0.3963377774;\nvariable lab_lms_kl_b: Number = 0.2158037573;\nvariable lab_lms_km_a: Number = -0.1055613458;\nvariable lab_lms_km_b: Number = -0.0638541728;\nvariable lab_lms_ks_a: Number = -0.0894841775;\nvariable lab_lms_ks_b: Number = -1.2914855480;\n\n// LMS to sRGB-linear matrix\nvariable lms_r0: Number = 4.0767416360759583;\nvariable lms_r1: Number = -3.3077115392580629;\nvariable lms_r2: Number = 0.2309699031821043;\nvariable lms_g0: Number = -1.2684379732850315;\nvariable lms_g1: Number = 2.6097573492876882;\nvariable lms_g2: Number = -0.3413193760026570;\nvariable lms_b0: Number = -0.0041960761386756;\nvariable lms_b1: Number = -0.7034186179359362;\nvariable lms_b2: Number = 1.7076146940746117;\n\n// RGB limit coefficients for determining which channel clips first\nvariable red_limit_a: Number = -1.8817031;\nvariable red_limit_b: Number = -0.80936501;\nvariable green_limit_a: Number = 1.8144408;\nvariable green_limit_b: Number = -1.19445267;\n\n// Red Kn coefficients\nvariable red_k0: Number = 1.19086277;\nvariable red_k1: Number = 1.76576728;\nvariable red_k2: Number = 0.59662641;\nvariable red_k3: Number = 0.75515197;\nvariable red_k4: Number = 0.56771245;\n\n// Green Kn coefficients\nvariable green_k0: Number = 0.73956515;\nvariable green_k1: Number = -0.45954404;\nvariable green_k2: Number = 0.08285427;\nvariable green_k3: Number = 0.12541073;\nvariable green_k4: Number = -0.14503204;\n\n// Blue Kn coefficients\nvariable blue_k0: Number = 1.35733652;\nvariable blue_k1: Number = -0.00915799;\nvariable blue_k2: Number = -1.1513021;\nvariable blue_k3: Number = -0.50559606;\nvariable blue_k4: Number = 0.00692167;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Convert to polar coordinates and apply toe function\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = sqrt(lab_a * lab_a + lab_b * lab_b);\n\n// Apply toe function: toe(x) = 0.5 * (k3*x - k1 + sqrt((k3*x - k1)^2 + 4*k2*k3*x))\nvariable l: Number = lab_l;\nif (lab_l > 0.0001 && lab_l < 0.9999) [\n variable term: Number = toe_k3 * lab_l - toe_k1;\n l = 0.5 * (term + sqrt(term * term + 4 * toe_k2 * toe_k3 * lab_l));\n];\n\n// Compute hue using atan2(-b, -a) + 0.5 (ColorJS convention)\nvariable h: Number = 0;\nif (c > 0.00001) [\n variable h_normalized: Number = 0.5 + atan2(-lab_b, -lab_a) / (2 * pi_val);\n h = h_normalized * 360;\n if (h < 0) [ h = h + 360; ];\n if (h >= 360) [ h = h - 360; ];\n];\n\n// Initialize saturation\nvariable s: Number = 0;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Check if chromatic (non-achromatic, non-edge)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable is_chromatic: Number = 1;\nif (c < 0.00001) [ is_chromatic = 0; ];\nif (l < 0.0001) [ is_chromatic = 0; ];\nif (l > 0.9999) [ is_chromatic = 0; ];\n\nif (is_chromatic > 0.5) [\n // ═══════════════════════════════════════════════════════════════════════\n // Step 3: Normalize hue direction (a_, b_)\n // ═══════════════════════════════════════════════════════════════════════\n variable a_: Number = lab_a / c;\n variable b_: Number = lab_b / c;\n \n // Pre-compute LMS coefficients\n variable kl: Number = lab_lms_kl_a * a_ + lab_lms_kl_b * b_;\n variable km: Number = lab_lms_km_a * a_ + lab_lms_km_b * b_;\n variable ks: Number = lab_lms_ks_a * a_ + lab_lms_ks_b * b_;\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 4: computeMaxSaturation - Find S_max for this hue\n // ═══════════════════════════════════════════════════════════════════════\n variable test_red: Number = red_limit_a * a_ + red_limit_b * b_;\n variable test_green: Number = green_limit_a * a_ + green_limit_b * b_;\n \n variable k0: Number = blue_k0;\n variable k1_coef: Number = blue_k1;\n variable k2_coef: Number = blue_k2;\n variable k3_coef: Number = blue_k3;\n variable k4_coef: Number = blue_k4;\n variable wl: Number = lms_b0;\n variable wm: Number = lms_b1;\n variable ws: Number = lms_b2;\n \n if (test_red > 1) [\n k0 = red_k0; k1_coef = red_k1; k2_coef = red_k2; k3_coef = red_k3; k4_coef = red_k4;\n wl = lms_r0; wm = lms_r1; ws = lms_r2;\n ] else [\n if (test_green > 1) [\n k0 = green_k0; k1_coef = green_k1; k2_coef = green_k2; k3_coef = green_k3; k4_coef = green_k4;\n wl = lms_g0; wm = lms_g1; ws = lms_g2;\n ];\n ];\n \n // Polynomial approximation\n variable s_max: Number = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement for s_max\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable halley_denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(halley_denom) > 0.000001) [\n s_max = s_max - f * f1 / halley_denom;\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 5: findCusp - Get L_cusp and C_cusp\n // ═══════════════════════════════════════════════════════════════════════\n variable l_cusp: Number = 1;\n variable c_cusp: Number = 0;\n \n if (s_max > 0) [\n variable cusp_l_: Number = 1 + s_max * kl;\n variable cusp_m_: Number = 1 + s_max * km;\n variable cusp_s_: Number = 1 + s_max * ks;\n \n variable cusp_l: Number = cusp_l_ * cusp_l_ * cusp_l_;\n variable cusp_m: Number = cusp_m_ * cusp_m_ * cusp_m_;\n variable cusp_s: Number = cusp_s_ * cusp_s_ * cusp_s_;\n \n variable cusp_r: Number = lms_r0 * cusp_l + lms_r1 * cusp_m + lms_r2 * cusp_s;\n variable cusp_g: Number = lms_g0 * cusp_l + lms_g1 * cusp_m + lms_g2 * cusp_s;\n variable cusp_b: Number = lms_b0 * cusp_l + lms_b1 * cusp_m + lms_b2 * cusp_s;\n \n variable max_rgb: Number = cusp_r;\n if (cusp_g > max_rgb) [ max_rgb = cusp_g; ];\n if (cusp_b > max_rgb) [ max_rgb = cusp_b; ];\n if (max_rgb < 0.0001) [ max_rgb = 0.0001; ];\n \n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 6: findGamutIntersection - Find C_max at current L\n // This is the FULL algorithm with Halley's method for upper half\n //\n // Finds intersection of line: L = L0*(1-t) + t*L1, C = t*C1\n // where L0=L1=lab_l and C1=1 (seeking max chroma at this L)\n // ═══════════════════════════════════════════════════════════════════════\n variable c_max: Number = 0;\n \n if (l_cusp > 0.0001 && c_cusp > 0.0001) [\n // Parameters for gamut intersection: l1=lab_l, c1=1, l0=lab_l\n variable l1: Number = lab_l;\n variable c1: Number = 1;\n variable l0: Number = lab_l;\n \n // Check which half: (l1 - l0) * c_cusp - (l_cusp - l0) * c1\n // Since l1 = l0 = lab_l, this simplifies to: -(l_cusp - lab_l) * 1 = lab_l - l_cusp\n variable half_test: Number = lab_l - l_cusp;\n variable t_intersect: Number = 0;\n \n if (half_test <= 0) [\n // Lower half (below cusp) - simple triangle intersection\n // t = (c_cusp * l0) / (c1 * l_cusp + c_cusp * (l0 - l1))\n // Since l0 = l1, denominator = c1 * l_cusp = l_cusp\n variable lower_denom: Number = l_cusp;\n if (abs(lower_denom) > 0.00001) [\n t_intersect = (c_cusp * lab_l) / lower_denom;\n ];\n ] else [\n // Upper half (above cusp) - triangle + Halley's method\n // First: triangle intersection\n // t = (c_cusp * (l0 - 1)) / (c1 * (l_cusp - 1) + c_cusp * (l0 - l1))\n // Since l0 = l1 = lab_l: t = (c_cusp * (lab_l - 1)) / (l_cusp - 1)\n variable upper_denom: Number = l_cusp - 1;\n if (abs(upper_denom) > 0.00001) [\n t_intersect = c_cusp * (lab_l - 1) / upper_denom;\n ];\n \n // Halley's method refinement for upper half\n // dl = l1 - l0 = 0, dc = c1 = 1\n variable dl: Number = 0;\n variable dc: Number = 1;\n \n variable ldt_: Number = dl + dc * kl;\n variable mdt_: Number = dl + dc * km;\n variable sdt_: Number = dl + dc * ks;\n \n // Compute L and C at current t\n variable L_at_t: Number = l0 * (1 - t_intersect) + t_intersect * l1;\n variable C_at_t: Number = t_intersect * c1;\n \n // LMS values at (L, C)\n variable l_at: Number = L_at_t + C_at_t * kl;\n variable m_at: Number = L_at_t + C_at_t * km;\n variable s_at: Number = L_at_t + C_at_t * ks;\n \n variable l_lms: Number = l_at * l_at * l_at;\n variable m_lms: Number = m_at * m_at * m_at;\n variable s_lms: Number = s_at * s_at * s_at;\n \n // First derivatives\n variable ldt: Number = 3 * ldt_ * l_at * l_at;\n variable mdt: Number = 3 * mdt_ * m_at * m_at;\n variable sdt: Number = 3 * sdt_ * s_at * s_at;\n \n // Second derivatives\n variable ldt2: Number = 6 * ldt_ * ldt_ * l_at;\n variable mdt2: Number = 6 * mdt_ * mdt_ * m_at;\n variable sdt2: Number = 6 * sdt_ * sdt_ * s_at;\n \n // Red channel Halley step\n variable r_val: Number = lms_r0 * l_lms + lms_r1 * m_lms + lms_r2 * s_lms - 1;\n variable r1: Number = lms_r0 * ldt + lms_r1 * mdt + lms_r2 * sdt;\n variable r2: Number = lms_r0 * ldt2 + lms_r1 * mdt2 + lms_r2 * sdt2;\n variable r_denom: Number = r1 * r1 - 0.5 * r_val * r2;\n variable ur: Number = 0;\n variable tr: Number = float_max;\n if (abs(r_denom) > 0.000001) [\n ur = r1 / r_denom;\n if (ur >= 0) [ tr = -r_val * ur; ];\n ];\n \n // Green channel Halley step\n variable g_val: Number = lms_g0 * l_lms + lms_g1 * m_lms + lms_g2 * s_lms - 1;\n variable g1: Number = lms_g0 * ldt + lms_g1 * mdt + lms_g2 * sdt;\n variable g2: Number = lms_g0 * ldt2 + lms_g1 * mdt2 + lms_g2 * sdt2;\n variable g_denom: Number = g1 * g1 - 0.5 * g_val * g2;\n variable ug: Number = 0;\n variable tg: Number = float_max;\n if (abs(g_denom) > 0.000001) [\n ug = g1 / g_denom;\n if (ug >= 0) [ tg = -g_val * ug; ];\n ];\n \n // Blue channel Halley step\n variable b_val: Number = lms_b0 * l_lms + lms_b1 * m_lms + lms_b2 * s_lms - 1;\n variable b1: Number = lms_b0 * ldt + lms_b1 * mdt + lms_b2 * sdt;\n variable b2: Number = lms_b0 * ldt2 + lms_b1 * mdt2 + lms_b2 * sdt2;\n variable b_denom: Number = b1 * b1 - 0.5 * b_val * b2;\n variable ub: Number = 0;\n variable tb: Number = float_max;\n if (abs(b_denom) > 0.000001) [\n ub = b1 / b_denom;\n if (ub >= 0) [ tb = -b_val * ub; ];\n ];\n \n // Take minimum of the three corrections\n variable t_correction: Number = tr;\n if (tg < t_correction) [ t_correction = tg; ];\n if (tb < t_correction) [ t_correction = tb; ];\n if (t_correction < float_max) [\n t_intersect = t_intersect + t_correction;\n ];\n ];\n \n // C_max = t * c1 = t (since c1 = 1)\n c_max = t_intersect;\n if (c_max < 0) [ c_max = 0; ];\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 7: getStMid - Polynomial approximation for mid-saturation ST\n // ═══════════════════════════════════════════════════════════════════════\n variable st_mid_s: Number = 0.11516993 + 1 / (\n 7.44778970 + 4.15901240 * b_ +\n a_ * (-2.19557347 + 1.75198401 * b_ +\n a_ * (-2.13704948 - 10.02301043 * b_ +\n a_ * (-4.24894561 + 5.38770819 * b_ + 4.69891013 * a_))));\n \n variable st_mid_t: Number = 0.11239642 + 1 / (\n 1.61320320 - 0.68124379 * b_ +\n a_ * (0.40370612 + 0.90148123 * b_ +\n a_ * (-0.27087943 + 0.61223990 * b_ +\n a_ * (0.00299215 - 0.45399568 * b_ - 0.14661872 * a_))));\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 8: getCs - Compute C_0, C_mid, C_max with scale factor k\n // ═══════════════════════════════════════════════════════════════════════\n variable st_max_s: Number = c_cusp / (l_cusp + 0.0001);\n variable st_max_t: Number = c_cusp / (1 - l_cusp + 0.0001);\n \n // Scale factor k = c_max / min(L * st_max_s, (1-L) * st_max_t)\n variable min_st: Number = lab_l * st_max_s;\n variable min_st_t: Number = (1 - lab_l) * st_max_t;\n if (min_st_t < min_st) [ min_st = min_st_t; ];\n \n variable k_factor: Number = 1;\n if (min_st > 0.0001 && c_max > 0.0001) [\n k_factor = c_max / min_st;\n ];\n \n // C_mid = 0.9 * k * sqrt(sqrt(1 / (1/ca^4 + 1/cb^4)))\n variable ca: Number = lab_l * st_mid_s;\n variable cb: Number = (1 - lab_l) * st_mid_t;\n variable ca4: Number = ca * ca * ca * ca;\n variable cb4: Number = cb * cb * cb * cb;\n variable c_mid: Number = 0;\n if (ca4 > 0.0000001 && cb4 > 0.0000001) [\n c_mid = 0.9 * k_factor * sqrt(sqrt(1 / (1 / ca4 + 1 / cb4)));\n ];\n \n // C_0 using average ST values (0.4, 0.8)\n variable ca0: Number = lab_l * 0.4;\n variable cb0: Number = (1 - lab_l) * 0.8;\n variable ca0_sq: Number = ca0 * ca0;\n variable cb0_sq: Number = cb0 * cb0;\n variable c_0: Number = 0;\n if (ca0_sq > 0.0000001 && cb0_sq > 0.0000001) [\n c_0 = sqrt(1 / (1 / ca0_sq + 1 / cb0_sq));\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 9: Compute saturation using piecewise interpolation\n // ═══════════════════════════════════════════════════════════════════════\n variable mid: Number = 0.8;\n \n if (c < c_mid && c_mid > 0.0001 && c_0 > 0.0001) [\n // Below mid-point: s = t * 0.8 where t = c / (k1 + k2*c)\n variable k1_low: Number = mid * c_0;\n variable k2_low: Number = 1 - k1_low / c_mid;\n variable t_low: Number = c / (k1_low + k2_low * c + 0.0001);\n s = t_low * mid;\n ] else [\n if (c_mid > 0.0001 && c_0 > 0.0001 && c_max > c_mid) [\n // Above mid-point: s = 0.8 + 0.2 * t where t = (c - c_mid) / (k1 + k2*(c - c_mid))\n variable mid_inv: Number = 1.25;\n variable k0_high: Number = c_mid;\n variable k1_high: Number = 0.2 * c_mid * c_mid * mid_inv * mid_inv / c_0;\n variable k2_high: Number = 1 - k1_high / (c_max - c_mid + 0.0001);\n variable c_diff: Number = c - k0_high;\n variable t_high: Number = c_diff / (k1_high + k2_high * c_diff + 0.0001);\n s = mid + 0.2 * t_high;\n ];\n ];\n \n // Clamp saturation\n if (s > 1) [ s = 1; ];\n if (s < 0) [ s = 0; ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKHSL;\noutput.h = h;\noutput.s = s;\noutput.l = l;\noutput" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsv-color/0/", - schema: { - "name": "OKHSV", - "type": "color", - "description": "OKHSV color space by Björn Ottosson - a perceptually uniform HSV based on OKLab. H is hue (0-360), S is saturation (0-1), V is value (0-1). Reference: https://bottosson.github.io/posts/colorpicker/", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees), same as OKLCH hue" - }, - "s": { - "type": "number", - "description": "Saturation (0-1), ratio of chroma to maximum at this value" - }, - "v": { - "type": "number", - "description": "Value/brightness (0-1), with V=1 being the brightest for that saturation" - } - }, - "required": [ - "h", - "s", - "v" - ], - "order": [ - "h", - "s", - "v" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKHSV Color Initializer", - "keyword": "okhsv", - "description": "Creates an OKHSV color from H, S, V values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSV Color Initializer\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n//\n// Creates an OKHSV color from hue, saturation, and value.\n// OKHSV is a perceptually uniform version of HSV built on OKLab.\n//\n// Parameters:\n// - h: Hue angle (0-360 degrees), same as OKLCH hue\n// - s: Saturation (0-1), ratio of chroma to maximum at this value\n// - v: Value/brightness (0-1), with V=1 being brightest for that saturation\n// - alpha: Optional alpha channel (0-1)\n//\n// Input: Object with h, s, v, and optional alpha properties\n// Output: Color.OKHSV\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable v: Number = input.v;\n\nvariable output: Color.OKHSV;\noutput.h = h;\noutput.s = s;\noutput.v = v;\n\n// Set alpha if provided\nif (input.alpha != null) [\n output.alpha = input.alpha;\n];\n\noutput" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to OKHSV using Ottosson's algorithm with Halley's method refinement for gamut boundary", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to OKHSV Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsv.js\n// Reference: https://bottosson.github.io/posts/oklab/ (original OKLab paper)\n//\n// OKHSV maps colors to a perceptually uniform HSV cylinder.\n// The algorithm finds the exact gamut boundary using compute_max_saturation\n// and positions colors relative to the cusp (maximum chroma point).\n//\n// Key difference from OKHSL:\n// - V=1 means maximum brightness (white at S=0, cusp color at S=1)\n// - More intuitive for picking saturated colors\n//\n// Input: Color.OKLab with l (0-1), a, b coordinates\n// Output: Color.OKHSV with h (0-360), s (0-1), v (0-1)\n\nvariable lab_l: Number = input.l;\nvariable lab_a: Number = input.a;\nvariable lab_b: Number = input.b;\n\n// Native constants\nvariable pi_val: Number = pi();\n\n// Toe function constants (same as OKHSL)\nvariable toe_k1: Number = 0.206;\nvariable toe_k2: Number = 0.03;\nvariable toe_k3: Number = 1.17009708737864;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Convert to polar coordinates (L, C, H)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = sqrt(lab_a * lab_a + lab_b * lab_b);\nvariable h: Number = 0;\n\nif (c > 0.00001) [\n // Note: ColorJS uses atan2(-b, -a) and adds 0.5, we use atan2(b, a)\n h = atan2(lab_b, lab_a) * 180 / pi_val;\n if (h < 0) [\n h = h + 360;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Initialize defaults and apply toe function to L for V\n// toe(x) = 0.5 * (k3*x - k1 + sqrt((k3*x - k1)^2 + 4*k2*k3*x))\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s: Number = 0;\nvariable v: Number = lab_l;\n\n// Apply toe function to get initial V\nif (lab_l > 0.0001 && lab_l < 0.9999) [\n variable term: Number = toe_k3 * lab_l - toe_k1;\n v = 0.5 * (term + sqrt(term * term + 4 * toe_k2 * toe_k3 * lab_l));\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 3: Compute normalized hue direction\n// ═══════════════════════════════════════════════════════════════════════════\nvariable a_: Number = 0;\nvariable b_: Number = 0;\nif (c > 0.00001) [\n a_ = lab_a / c;\n b_ = lab_b / c;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Pre-compute LMS coefficients (used in multiple steps)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable kl: Number = 0.3963377774 * a_ + 0.2158037573 * b_;\nvariable km: Number = -0.1055613458 * a_ - 0.0638541728 * b_;\nvariable ks: Number = -0.0894841775 * a_ - 1.2914855480 * b_;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 4: Find maximum saturation and cusp\n// Only process if we have chroma (non-achromatic) and L is valid\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s_max: Number = 0;\nvariable l_cusp: Number = 1;\nvariable c_cusp: Number = 0;\n\n// Variables for LMS intermediate calculations\nvariable l_lms: Number = 0;\nvariable m_lms: Number = 0;\nvariable s_lms: Number = 0;\n\nif (c > 0.00001 && lab_l > 0.00001 && lab_l < 0.99999) [\n // Determine which RGB component clips first\n variable k0: Number = 0;\n variable k1_coef: Number = 0;\n variable k2_coef: Number = 0;\n variable k3_coef: Number = 0;\n variable k4_coef: Number = 0;\n variable wl: Number = 0;\n variable wm: Number = 0;\n variable ws: Number = 0;\n \n variable test_r: Number = -1.88170328 * a_ - 0.80936493 * b_;\n variable test_g: Number = 1.81444104 * a_ - 1.19445276 * b_;\n \n if (test_r > 1) [\n k0 = 1.19086277;\n k1_coef = 1.76576728;\n k2_coef = 0.59662641;\n k3_coef = 0.75515197;\n k4_coef = 0.56771245;\n wl = 4.0767416621;\n wm = -3.3077115913;\n ws = 0.2309699292;\n ] else [\n if (test_g > 1) [\n k0 = 0.73956515;\n k1_coef = -0.45954404;\n k2_coef = 0.08285427;\n k3_coef = 0.12541073;\n k4_coef = -0.14503204;\n wl = -1.2684380046;\n wm = 2.6097574011;\n ws = -0.3413193965;\n ] else [\n k0 = 1.35733652;\n k1_coef = -0.00915799;\n k2_coef = -1.15130210;\n k3_coef = -0.50559606;\n k4_coef = 0.00692167;\n wl = -0.0041960863;\n wm = -0.7034186147;\n ws = 1.7076147010;\n ];\n ];\n \n // Polynomial approximation\n s_max = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(denom) > 0.000001) [\n s_max = s_max - f * f1 / denom;\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Find cusp (L_cusp, C_cusp)\n // ═══════════════════════════════════════════════════════════════════════\n if (s_max > 0) [\n variable l_cusp_temp: Number = 1 + s_max * kl;\n variable m_cusp_temp: Number = 1 + s_max * km;\n variable s_cusp_temp: Number = 1 + s_max * ks;\n \n l_lms = l_cusp_temp * l_cusp_temp * l_cusp_temp;\n m_lms = m_cusp_temp * m_cusp_temp * m_cusp_temp;\n s_lms = s_cusp_temp * s_cusp_temp * s_cusp_temp;\n \n variable r_lin: Number = 4.0767416621 * l_lms - 3.3077115913 * m_lms + 0.2309699292 * s_lms;\n variable g_lin: Number = -1.2684380046 * l_lms + 2.6097574011 * m_lms - 0.3413193965 * s_lms;\n variable b_lin: Number = -0.0041960863 * l_lms - 0.7034186147 * m_lms + 1.7076147010 * s_lms;\n \n variable max_rgb: Number = r_lin;\n if (g_lin > max_rgb) [ max_rgb = g_lin; ];\n if (b_lin > max_rgb) [ max_rgb = b_lin; ];\n \n if (max_rgb > 0) [\n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 5: Compute OKHSV S and V using ColorJS algorithm\n//\n// The algorithm uses the ST coordinate system where:\n// - S_t = C/L (slope from origin)\n// - T_t = C/(1-L) (slope from white)\n//\n// Key formulas from ColorJS:\n// - t = tMax / (c + l * tMax)\n// - lv = t * l, cv = t * c\n// - Apply RGB scaling and toe compensation\n// - v = l / lv, s = ((s0 + tMax) * cv) / (tMax * s0 + tMax * k * cv)\n// ═══════════════════════════════════════════════════════════════════════════\nif (c > 0.00001 && lab_l > 0.00001 && lab_l < 0.99999 && l_cusp > 0.0001 && c_cusp > 0.0001) [\n // Compute ST values at cusp\n variable s_t_cusp: Number = c_cusp / l_cusp;\n variable t_t_cusp: Number = c_cusp / (1 - l_cusp + 0.0001);\n \n // Fixed s0 parameter\n variable s_0: Number = 0.5;\n variable k_param: Number = 1 - s_0 / s_t_cusp;\n \n // Compute t and derived values (following ColorJS exactly)\n variable t: Number = t_t_cusp / (c + lab_l * t_t_cusp + 0.0001);\n variable lv: Number = t * lab_l;\n variable cv: Number = t * c;\n \n // Apply inverse toe to lv for compensation\n variable lvt: Number = lv;\n if (lv > 0.0001 && lv < 0.9999) [\n lvt = (lv * lv + toe_k1 * lv) / (toe_k3 * (lv + toe_k2));\n ];\n \n variable cvt: Number = cv;\n if (lv > 0.0001) [\n cvt = cv * lvt / lv;\n ];\n \n // RGB scale computation\n variable scale_l_: Number = 1 + s_max * kl;\n variable scale_m_: Number = 1 + s_max * km;\n variable scale_s_: Number = 1 + s_max * ks;\n \n variable lms_l: Number = lvt + a_ * cvt * kl;\n variable lms_m: Number = lvt + a_ * cvt * km;\n variable lms_s: Number = lvt + a_ * cvt * ks;\n \n // Convert to linear RGB using LMS\n variable lms_l_cubed: Number = lms_l * lms_l * lms_l;\n variable lms_m_cubed: Number = lms_m * lms_m * lms_m;\n variable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n \n variable rs: Number = 4.0767416621 * lms_l_cubed - 3.3077115913 * lms_m_cubed + 0.2309699292 * lms_s_cubed;\n variable gs: Number = -1.2684380046 * lms_l_cubed + 2.6097574011 * lms_m_cubed - 0.3413193965 * lms_s_cubed;\n variable bs: Number = -0.0041960863 * lms_l_cubed - 0.7034186147 * lms_m_cubed + 1.7076147010 * lms_s_cubed;\n \n variable max_s: Number = rs;\n if (gs > max_s) [ max_s = gs; ];\n if (bs > max_s) [ max_s = bs; ];\n if (max_s < 0.0001) [ max_s = 0.0001; ];\n \n variable scale_l: Number = pow(1 / max_s, 0.3333333333333333);\n \n // Scale L and C\n variable l_scaled: Number = lab_l / scale_l;\n variable c_scaled: Number = c / scale_l;\n \n // Apply toe to scaled L for compensation\n variable l_toed: Number = l_scaled;\n if (l_scaled > 0.0001 && l_scaled < 0.9999) [\n variable term2: Number = toe_k3 * l_scaled - toe_k1;\n l_toed = 0.5 * (term2 + sqrt(term2 * term2 + 4 * toe_k2 * toe_k3 * l_scaled));\n ];\n \n c_scaled = c_scaled * l_toed / (l_scaled + 0.0001);\n \n // Compute final v and s\n if (lv > 0.0001) [\n v = l_toed / lv;\n if (v > 1) [ v = 1; ];\n if (v < 0) [ v = 0; ];\n ];\n \n variable denom_s: Number = t_t_cusp * s_0 + t_t_cusp * k_param * cv;\n if (abs(denom_s) > 0.0001) [\n s = ((s_0 + t_t_cusp) * cv) / denom_s;\n if (s > 1) [ s = 1; ];\n if (s < 0) [ s = 0; ];\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKHSV;\noutput.h = h;\noutput.s = s;\noutput.v = v;\noutput" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/", - schema: { - "name": "P3", - "type": "color", - "description": "Display-P3 color space with sRGB transfer function. Wider gamut than sRGB, common on modern Apple displays.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Red channel (0-1, can exceed for out-of-gamut)" - }, - "g": { - "type": "number", - "description": "Green channel (0-1, can exceed for out-of-gamut)" - }, - "b": { - "type": "number", - "description": "Blue channel (0-1, can exceed for out-of-gamut)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Display-P3 Color Initializer", - "keyword": "p3", - "description": "Creates a Display-P3 color from 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Display-P3 Color Initializer\n// Creates a Display-P3 color from 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values\n\nvariable color_values: List = {input};\nvariable output: Color.P3;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/", - "target": "$self", - "description": "Converts Linear P3 to P3 by applying sRGB transfer function", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear P3 to P3 Conversion\n// Applies sRGB transfer function (gamma encoding)\n// P3 uses the same transfer function as sRGB\n// Reference: CSS Color Level 4\n//\n// Algorithm (same as sRGB):\n// if linear ≤ 0.0031308: encoded = 12.92 × linear\n// else: encoded = 1.055 × linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearP3 with linear r, g, b values\n// Output: Color.P3 with gamma-encoded r, g, b values\n\n// Transfer function constants (same as sRGB)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_exponent: Number = 0.4166666666666667;\n\n// Get linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable encoded_r: Number = 0;\nif (linear_r <= threshold) [\n encoded_r = linear_scale * linear_r;\n] else [\n encoded_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable encoded_g: Number = 0;\nif (linear_g <= threshold) [\n encoded_g = linear_scale * linear_g;\n] else [\n encoded_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable encoded_b: Number = 0;\nif (linear_b <= threshold) [\n encoded_b = linear_scale * linear_b;\n] else [\n encoded_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.P3;\noutput.r = encoded_r;\noutput.g = encoded_g;\noutput.b = encoded_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/css-color/0/", - schema: { - "name": "CSS", - "type": "color", - "description": "CSS color string representation. Outputs the appropriate CSS syntax for any color space.", - "schema": { - "type": "object", - "properties": { - "value": { - "type": "string", - "description": "CSS color string (e.g., 'rgb(255 128 64)', 'oklch(0.7 0.15 180)')" - } - }, - "required": [ - "value" - ] - }, - "initializers": [ - { - "title": "CSS Color Initializer", - "keyword": "css", - "description": "Creates a CSS color from a string value", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CSS Color Initializer\n// Accepts a CSS color string and returns it\n// This is primarily used for type registration; \n// the main usage is converting TO css from other color types\n//\n// Input: String (CSS color value)\n// Output: String (same value)\n\nvariable input: List = {input};\nvariable value: String = input.get(0);\n\nreturn value;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/", - "target": "$self", - "description": "Converts RGB (0-255) to CSS rgb() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB to CSS Conversion\n// Converts RGB (0-255) to CSS rgb() syntax\n// CSS Color Level 4 modern syntax: rgb(r g b) or rgb(r g b / alpha)\n//\n// Input: Color.Rgb with r, g, b in 0-255 range, optional alpha (0-1)\n// Output: Color.CSS with value like \"rgb(255 128 64)\" or \"rgb(255 128 64 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable r: Number = round({input}.r);\nvariable g: Number = round({input}.g);\nvariable b: Number = round({input}.b);\n\nvariable css_value: String = \"rgb(\".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB (0-1) to CSS color(srgb) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to CSS Conversion\n// Converts sRGB (0-1) to CSS color(srgb) syntax\n// CSS Color Level 4: color(srgb r g b) or color(srgb r g b / alpha)\n//\n// Input: Color.SRGB with r, g, b in 0-1 range, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(srgb 1 0.5 0.25)\" or \"color(srgb 1 0.5 0.25 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable r: Number = round({input}.r * precision) / precision;\nvariable g: Number = round({input}.g * precision) / precision;\nvariable b: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"color(srgb \".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - "target": "$self", - "description": "Converts HSL to CSS hsl() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL to CSS Conversion\n// Converts HSL to CSS hsl() syntax\n// CSS Color Level 4: hsl(h s l) or hsl(h s l / alpha) where s and l are percentages\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1), optional alpha (0-1)\n// Output: Color.CSS with value like \"hsl(120 50% 75%)\" or \"hsl(120 50% 75% / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100;\nvariable h: Number = round({input}.h * precision) / precision;\nvariable s: Number = round({input}.s * 100 * precision) / precision;\nvariable l: Number = round({input}.l * 100 * precision) / precision;\n\nvariable css_value: String = \"hsl(\".concat(h.to_string()).concat(\" \").concat(s.to_string()).concat(\"% \").concat(l.to_string()).concat(\"%\");\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hwb-color/0/", - "target": "$self", - "description": "Converts HWB to CSS hwb() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HWB to CSS Conversion\n// Converts HWB to CSS hwb() syntax\n// CSS Color Level 4: hwb(h w b) or hwb(h w b / alpha) where w and b are percentages\n//\n// Input: Color.HWB with h (0-360), w (0-1), b (0-1), optional alpha (0-1)\n// Output: Color.CSS with value like \"hwb(120 10% 20%)\" or \"hwb(120 10% 20% / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100;\nvariable h: Number = round({input}.h * precision) / precision;\nvariable w: Number = round({input}.w * 100 * precision) / precision;\nvariable b_val: Number = round({input}.b * 100 * precision) / precision;\n\nvariable css_value: String = \"hwb(\".concat(h.to_string()).concat(\" \").concat(w.to_string()).concat(\"% \").concat(b_val.to_string()).concat(\"%\");\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lab-color/0/", - "target": "$self", - "description": "Converts CIE Lab to CSS lab() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE Lab to CSS Conversion\n// Converts CIE Lab to CSS lab() syntax\n// CSS Color Level 4: lab(L a b) or lab(L a b / alpha) where L is a percentage\n//\n// Input: Color.Lab with l (0-100), a, b, optional alpha (0-1)\n// Output: Color.CSS with value like \"lab(75% 20 -30)\" or \"lab(75% 20 -30 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 10000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable a: Number = round({input}.a * precision) / precision;\nvariable b_val: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"lab(\".concat(l.to_string()).concat(\"% \").concat(a.to_string()).concat(\" \").concat(b_val.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lch-color/0/", - "target": "$self", - "description": "Converts CIE LCH to CSS lch() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE LCH to CSS Conversion\n// Converts CIE LCH to CSS lch() syntax\n// CSS Color Level 4: lch(L C H) or lch(L C H / alpha) where L is a percentage\n//\n// Input: Color.LCH with l (0-100), c, h (0-360), optional alpha (0-1)\n// Output: Color.CSS with value like \"lch(75% 50 180)\" or \"lch(75% 50 180 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 10000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable c: Number = round({input}.c * precision) / precision;\nvariable h: Number = round({input}.h * precision) / precision;\n\nvariable css_value: String = \"lch(\".concat(l.to_string()).concat(\"% \").concat(c.to_string()).concat(\" \").concat(h.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to CSS oklab() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to CSS Conversion\n// Converts OKLab to CSS oklab() syntax\n// CSS Color Level 4: oklab(L a b) or oklab(L a b / alpha) where L is 0-1 decimal\n//\n// Input: Color.OKLab with l (0-1), a, b, optional alpha (0-1)\n// Output: Color.CSS with value like \"oklab(0.7 0.1 -0.05)\" or \"oklab(0.7 0.1 -0.05 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable a: Number = round({input}.a * precision) / precision;\nvariable b_val: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"oklab(\".concat(l.to_string()).concat(\" \").concat(a.to_string()).concat(\" \").concat(b_val.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "target": "$self", - "description": "Converts OKLCH to CSS oklch() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH to CSS Conversion\n// Converts OKLCH to CSS oklch() syntax\n// CSS Color Level 4: oklch(L C H) or oklch(L C H / alpha) where L is 0-1 decimal\n//\n// Input: Color.OKLCH with l (0-1), c, h (0-360), optional alpha (0-1)\n// Output: Color.CSS with value like \"oklch(0.7 0.15 180)\" or \"oklch(0.7 0.15 180 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable c: Number = round({input}.c * precision) / precision;\nvariable h: Number = round({input}.h * precision) / precision;\n\nvariable css_value: String = \"oklch(\".concat(l.to_string()).concat(\" \").concat(c.to_string()).concat(\" \").concat(h.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - "target": "$self", - "description": "Converts Linear sRGB to CSS color(srgb-linear) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB to CSS Conversion\n// Converts Linear sRGB to CSS color(srgb-linear) syntax\n// CSS Color Level 4: color(srgb-linear r g b)\n//\n// Input: Color.LinearSRGB with r, g, b in 0-1 range\n// Output: Color.CSS with value like \"color(srgb-linear 1 0.25 0.0625)\"\n\nvariable precision: Number = 100000;\nvariable r: Number = round({input}.r * precision) / precision;\nvariable g: Number = round({input}.g * precision) / precision;\nvariable b: Number = round({input}.b * precision) / precision;\n\nvariable output: Color.CSS;\noutput.value = \"color(srgb-linear \".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string()).concat(\")\");\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to CSS color(xyz-d65) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to CSS Conversion\n// Converts XYZ-D65 to CSS color(xyz-d65) syntax\n// CSS Color Level 4: color(xyz-d65 x y z) or color(xyz-d65 x y z / alpha)\n//\n// Input: Color.XYZD65 with x, y, z tristimulus values, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(xyz-d65 0.4124 0.2126 0.0193)\" or \"color(xyz-d65 0.4124 0.2126 0.0193 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable x: Number = round({input}.x * precision) / precision;\nvariable y: Number = round({input}.y * precision) / precision;\nvariable z: Number = round({input}.z * precision) / precision;\n\nvariable css_value: String = \"color(xyz-d65 \".concat(x.to_string()).concat(\" \").concat(y.to_string()).concat(\" \").concat(z.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d50-color/0/", - "target": "$self", - "description": "Converts XYZ-D50 to CSS color(xyz-d50) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D50 to CSS Conversion\n// Converts XYZ-D50 to CSS color(xyz-d50) syntax\n// CSS Color Level 4: color(xyz-d50 x y z) or color(xyz-d50 x y z / alpha)\n//\n// Input: Color.XYZD50 with x, y, z tristimulus values, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(xyz-d50 0.4360 0.2225 0.0139)\" or \"color(xyz-d50 0.4360 0.2225 0.0139 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable x: Number = round({input}.x * precision) / precision;\nvariable y: Number = round({input}.y * precision) / precision;\nvariable z: Number = round({input}.z * precision) / precision;\n\nvariable css_value: String = \"color(xyz-d50 \".concat(x.to_string()).concat(\" \").concat(y.to_string()).concat(\" \").concat(z.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/", - "target": "$self", - "description": "Converts Display-P3 to CSS color(display-p3) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Display-P3 to CSS Conversion\n// Converts Display-P3 to CSS color(display-p3) syntax\n// CSS Color Level 4: color(display-p3 r g b) or color(display-p3 r g b / alpha)\n//\n// Input: Color.P3 with r, g, b in 0-1 range, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(display-p3 1 0.5 0.25)\" or \"color(display-p3 1 0.5 0.25 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable r: Number = round({input}.r * precision) / precision;\nvariable g: Number = round({input}.g * precision) / precision;\nvariable b: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"color(display-p3 \".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hwb-color/0/", - schema: { - "name": "HWB", - "type": "color", - "description": "HWB color space - Hue, Whiteness, Blackness. CSS Color Level 4.", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - }, - "w": { - "type": "number", - "description": "Whiteness (0-1)" - }, - "b": { - "type": "number", - "description": "Blackness (0-1)" - } - }, - "required": [ - "h", - "w", - "b" - ], - "order": [ - "h", - "w", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "HWB Color Initializer", - "keyword": "hwb", - "description": "Creates an HWB color from H, W, B values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HWB Color Initializer\n// Creates an HWB color from H, W, B values\n// Input: List of [h, w, b] or [h, w, b, alpha] values\n\nvariable hwb_values: List = {input};\nvariable output: Color.HWB;\n\noutput.h = hwb_values.get(0);\noutput.w = hwb_values.get(1);\noutput.b = hwb_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (hwb_values.length() > 3) [\n output.alpha = hwb_values.get(3);\n];\n\nreturn output;" - } - }, - { - "title": "HWBA Color Initializer", - "keyword": "hwba", - "description": "Creates an HWB color with alpha from H, W, B, A values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HWBA Color Initializer\n// Creates an HWB color with alpha from H, W, B, A values\n//\n// Usage: hwba(180, 0.2, 0.3, 0.9) → Color.HWB { h: 180, w: 0.2, b: 0.3, alpha: 0.9 }\n//\n// Input: List of 4 numbers [h, w, b, alpha]\n// Output: Color.HWB with alpha\n\nvariable hwb_values: List = {input};\nvariable output: Color.HWB;\n\noutput.h = hwb_values.get(0);\noutput.w = hwb_values.get(1);\noutput.b = hwb_values.get(2);\noutput.alpha = hwb_values.get(3);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsv-color/0/", - "target": "$self", - "description": "Converts HSV to HWB", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSV to HWB Conversion\n// Reference: CSS Color Level 4\n//\n// Algorithm:\n// H stays the same\n// W = (1 - S) * V (Whiteness)\n// B = 1 - V (Blackness)\n//\n// Input: Color.HSV with h, s, v values\n// Output: Color.HWB with h, w, b values\n\n// Get input HSV values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable v: Number = {input}.v;\n\n// Calculate whiteness and blackness\nvariable w: Number = (1 - s) * v;\nvariable b: Number = 1 - v;\n\n// Create output\nvariable output: Color.HWB;\noutput.h = h;\noutput.w = w;\noutput.b = b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsv-color/0/", - schema: { - "name": "HSV", - "type": "color", - "description": "HSV color space - Hue, Saturation, Value. Also known as HSB (Hue, Saturation, Brightness).", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - }, - "s": { - "type": "number", - "description": "Saturation (0-1)" - }, - "v": { - "type": "number", - "description": "Value/Brightness (0-1)" - } - }, - "required": [ - "h", - "s", - "v" - ], - "order": [ - "h", - "s", - "v" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "HSV Color Initializer", - "keyword": "hsv", - "description": "Creates an HSV color from H, S, V values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSV Color Initializer\n// Creates an HSV color from H, S, V values\n// Input: List of [h, s, v] or [h, s, v, alpha] values\n\nvariable hsv_values: List = {input};\nvariable output: Color.HSV;\n\noutput.h = hsv_values.get(0);\noutput.s = hsv_values.get(1);\noutput.v = hsv_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (hsv_values.length() > 3) [\n output.alpha = hsv_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB to HSV", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to HSV Conversion\n// Reference: Standard RGB to HSV algorithm\n//\n// Algorithm:\n// V = max(R, G, B)\n// S = (V - min(R, G, B)) / V (if V > 0)\n// H depends on which channel is max\n//\n// Input: Color.SRGB with r, g, b in 0-1 range\n// Output: Color.HSV with h (0-360), s (0-1), v (0-1)\n\n// Get input sRGB values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Find max and min\nvariable max_val: Number = r;\nif (g > max_val) [\n max_val = g;\n];\nif (b > max_val) [\n max_val = b;\n];\n\nvariable min_val: Number = r;\nif (g < min_val) [\n min_val = g;\n];\nif (b < min_val) [\n min_val = b;\n];\n\n// Value is the max channel\nvariable v: Number = max_val;\n\n// Calculate saturation and hue\nvariable s: Number = 0;\nvariable h: Number = 0;\nvariable delta: Number = max_val - min_val;\n\nif (max_val > 0) [\n s = delta / max_val;\n];\n\nif (delta > 0) [\n // Hue calculation depends on which channel is max\n if (max_val == r) [\n h = ((g - b) / delta);\n if (g < b) [\n h = h + 6;\n ];\n ] else [\n if (max_val == g) [\n h = (b - r) / delta + 2;\n ] else [\n h = (r - g) / delta + 4;\n ];\n ];\n \n // Convert to degrees\n h = h * 60;\n];\n\n// Normalize hue to 0-360\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.HSV;\noutput.h = h;\noutput.s = s;\noutput.v = v;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lab-color/0/", - schema: { - "name": "Lab", - "type": "color", - "description": "CIE Lab (L*a*b*) perceptually uniform color space. L is lightness (0-100), a is green-red axis, b is blue-yellow axis.", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-100)" - }, - "a": { - "type": "number", - "description": "Green-red axis (typically -125 to 125)" - }, - "b": { - "type": "number", - "description": "Blue-yellow axis (typically -125 to 125)" - } - }, - "required": [ - "l", - "a", - "b" - ], - "order": [ - "l", - "a", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Lab Color Initializer", - "keyword": "lab", - "description": "Creates a CIE Lab color from L, a, b values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE Lab Color Initializer\n// Creates a Lab color from L, a, b values\n// Input: List of [l, a, b] or [l, a, b, alpha] values\n\nvariable lab_values: List = {input};\nvariable output: Color.Lab;\n\noutput.l = lab_values.get(0);\noutput.a = lab_values.get(1);\noutput.b = lab_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lab_values.length() > 3) [\n output.alpha = lab_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d50-color/0/", - "target": "$self", - "description": "Converts XYZ-D50 to CIE Lab using the CIE standard formula", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D50 to CIE Lab Conversion\n// Reference: CIE 15.3:2004 section 8.2.1.1\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/lab.js\n//\n// Algorithm:\n// 1. Scale XYZ by D50 white point reference\n// 2. Apply f function: f(x) = x > ε ? cbrt(x) : (κ*x + 16)/116\n// 3. L = 116 * f[Y] - 16\n// 4. a = 500 * (f[X] - f[Y])\n// 5. b = 200 * (f[Y] - f[Z])\n//\n// Input: Color.XYZD50 with x, y, z tristimulus\n// Output: Color.Lab with l, a, b coordinates\n\n// Get input XYZ-D50 values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// CIE constants (exact rational fractions)\n// ε = 216/24389 = (6/29)^3\nvariable epsilon: Number = 0.008856451679035631;\n// κ = 24389/27 = (29/3)^3 \nvariable kappa: Number = 903.2962962962963;\n\n// D50 white point reference (ColorJS exact values)\n// D50: [0.3457/0.3585, 1.0, (1-0.3457-0.3585)/0.3585]\nvariable white_x: Number = 0.9642956764295677;\nvariable white_y: Number = 1.0;\nvariable white_z: Number = 0.8251046025104602;\n\n// Scale XYZ by white point\nvariable xr: Number = x / white_x;\nvariable yr: Number = y / white_y;\nvariable zr: Number = z / white_z;\n\n// Apply f function with cube root\n// f(t) = t > ε ? t^(1/3) : (κt + 16) / 116\nvariable cube_root_exp: Number = 0.3333333333333333;\n\nvariable fx: Number = 0;\nvariable fy: Number = 0;\nvariable fz: Number = 0;\n\nif (xr > epsilon) [\n fx = pow(xr, cube_root_exp);\n] else [\n fx = (kappa * xr + 16) / 116;\n];\n\nif (yr > epsilon) [\n fy = pow(yr, cube_root_exp);\n] else [\n fy = (kappa * yr + 16) / 116;\n];\n\nif (zr > epsilon) [\n fz = pow(zr, cube_root_exp);\n] else [\n fz = (kappa * zr + 16) / 116;\n];\n\n// Calculate Lab values\nvariable lab_l: Number = 116 * fy - 16;\nvariable lab_a: Number = 500 * (fx - fy);\nvariable lab_b: Number = 200 * (fy - fz);\n\n// Create output\nvariable output: Color.Lab;\noutput.l = lab_l;\noutput.a = lab_a;\noutput.b = lab_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d50-color/0/", - schema: { - "name": "XYZD50", - "type": "color", - "description": "CIE XYZ color space with D50 white point. Used for Lab and LCH color spaces.", - "schema": { - "type": "object", - "properties": { - "x": { - "type": "number", - "description": "X tristimulus value" - }, - "y": { - "type": "number", - "description": "Y tristimulus value (luminance)" - }, - "z": { - "type": "number", - "description": "Z tristimulus value" - } - }, - "required": [ - "x", - "y", - "z" - ], - "order": [ - "x", - "y", - "z" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "XYZ-D50 Color Initializer", - "keyword": "xyzd50", - "description": "Creates an XYZ-D50 color from tristimulus values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D50 Color Initializer\n// Creates an XYZ-D50 color from tristimulus values\n// Input: List of [x, y, z] tristimulus values\n\nvariable xyz_values: List = {input};\nvariable output: Color.XYZD50;\n\noutput.x = xyz_values.get(0);\noutput.y = xyz_values.get(1);\noutput.z = xyz_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to XYZ-D50 using Bradford chromatic adaptation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to XYZ-D50 Conversion\n// Chromatic adaptation using Bradford transform\n// Reference: https://github.com/color-js/color.js/blob/main/src/adapt.js\n//\n// Bradford CAT matrix for D65 → D50:\n// [1.0479298 0.0229469 -0.0501923]\n// [0.0296278 0.9904344 -0.0170738]\n// [-0.0092430 0.0150552 0.7518743]\n//\n// Input: Color.XYZD65 with x, y, z tristimulus (D65 white)\n// Output: Color.XYZD50 with x, y, z tristimulus (D50 white)\n\n// Get input XYZ-D65 values\nvariable x65: Number = {input}.x;\nvariable y65: Number = {input}.y;\nvariable z65: Number = {input}.z;\n\n// Bradford chromatic adaptation matrix (ColorJS exact values)\n// Row 1\nvariable x50: Number = x65 * 1.0479297925449969 + y65 * 0.022946870601609652 + z65 * -0.05019226628920524;\n// Row 2\nvariable y50: Number = x65 * 0.02962780877005599 + y65 * 0.9904344267538799 + z65 * -0.017073799063418826;\n// Row 3\nvariable z50: Number = x65 * -0.009243040646204504 + y65 * 0.015055191490298152 + z65 * 0.7518742814281371;\n\n// Create output\nvariable output: Color.XYZD50;\noutput.x = x50;\noutput.y = y50;\noutput.z = z50;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lch-color/0/", - schema: { - "name": "LCH", - "type": "color", - "description": "CIE LCH color space - the polar form of CIE Lab. L is lightness (0-100), C is chroma, H is hue angle (0-360).", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-100)" - }, - "c": { - "type": "number", - "description": "Chroma" - }, - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - } - }, - "required": [ - "l", - "c", - "h" - ], - "order": [ - "l", - "c", - "h" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "LCH Color Initializer", - "keyword": "lch", - "description": "Creates a CIE LCH color from L, C, H values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE LCH Color Initializer\n// Creates a LCH color from L, C, H values\n// Input: List of [l, c, h] or [l, c, h, alpha] values\n\nvariable lch_values: List = {input};\nvariable output: Color.LCH;\n\noutput.l = lch_values.get(0);\noutput.c = lch_values.get(1);\noutput.h = lch_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lch_values.length() > 3) [\n output.alpha = lch_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lab-color/0/", - "target": "$self", - "description": "Converts CIE Lab to LCH using Cartesian to polar transformation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE Lab to LCH Conversion\n// Converts Cartesian (a, b) to polar (C, H) coordinates\n// Same algorithm as OKLab → OKLCH, but for CIE Lab\n//\n// Algorithm:\n// L stays the same (lightness 0-100)\n// C = sqrt(a² + b²) (chroma)\n// H = atan2(b, a) * 180/π (hue angle in degrees)\n//\n// Input: Color.Lab with l, a, b coordinates\n// Output: Color.LCH with l, c, h coordinates\n\n// Get input Lab values\nvariable l: Number = {input}.l;\nvariable a: Number = {input}.a;\nvariable b: Number = {input}.b;\n\n// Constants\nvariable pi: Number = pi();\nvariable rad_to_deg: Number = 180 / pi;\n\n// Calculate chroma (distance from neutral axis)\nvariable c: Number = sqrt(a * a + b * b);\n\n// Calculate hue angle using atan2\nvariable h_rad: Number = atan2(b, a);\nvariable h: Number = h_rad * rad_to_deg;\n\n// Normalize hue to 0-360 range\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.LCH;\noutput.l = l;\noutput.c = c;\noutput.h = h;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/lighten/0/", - schema: { - "name": "Lighten", - "type": "function", - "description": "Makes a color lighter by increasing its lightness in OKLab space. Amount is 0-1 where 0.25 = 25% lighter.", - "keyword": "lighten", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to lighten" - }, - "amount": { - "type": "number", - "description": "Amount to lighten (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// lighten: Increase lightness proportionally towards white\n// Amount: 0-1 where 0.25 means 25% closer to white\n//\n// Algorithm: L' = L + (1 - L) * amount\n// This ensures we approach white (L=1) proportionally.\n//\n// Input: Any color space (converted to OKLab internally)\n// Output: OKLCH (working space)\n// To get sRGB: lighten(color, 0.25).to.srgb()\n\nvariable input: List = {input};\nvariable color: Color.OKLab = input.get(0).to.oklab();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new lightness (move toward 1)\nvariable current_l: Number = color.l;\nvariable new_l: Number = current_l + (1 - current_l) * amount;\n\n// Clamp to valid range\nif (new_l > 1) [ new_l = 1; ];\n\n// Create output in OKLab, return as OKLCH (working space)\nvariable result: Color.OKLab;\nresult.l = new_l;\nresult.a = color.a;\nresult.b = color.b;\n\nreturn result.to.oklch();" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/darken/0/", - schema: { - "name": "Darken", - "type": "function", - "description": "Makes a color darker by decreasing its lightness in OKLab space. Amount is 0-1 where 0.25 = 25% darker.", - "keyword": "darken", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to darken" - }, - "amount": { - "type": "number", - "description": "Amount to darken (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// darken: Decrease lightness proportionally towards black\n// Amount: 0-1 where 0.25 means 25% closer to black\n//\n// Algorithm: L' = L * (1 - amount)\n// This ensures we approach black (L=0) proportionally.\n//\n// Input: Any color space (converted to OKLab internally)\n// Output: OKLCH (working space)\n// To get sRGB: darken(color, 0.25).to.srgb()\n\nvariable input: List = {input};\nvariable color: Color.OKLab = input.get(0).to.oklab();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new lightness (move toward 0)\nvariable current_l: Number = color.l;\nvariable new_l: Number = current_l * (1 - amount);\n\n// Clamp to valid range\nif (new_l < 0) [ new_l = 0; ];\n\n// Create output in OKLab, return as OKLCH (working space)\nvariable result: Color.OKLab;\nresult.l = new_l;\nresult.a = color.a;\nresult.b = color.b;\n\nreturn result.to.oklch();" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/saturate/0/", - schema: { - "name": "Saturate", - "type": "function", - "description": "Increases color saturation by boosting chroma in OKLCH space. Amount is 0-1 where 0.25 = 25% more saturated.", - "keyword": "saturate", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to saturate" - }, - "amount": { - "type": "number", - "description": "Amount to saturate (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Saturate a color by increasing its OKLCH chroma\n// Amount: 0-1 where 0.25 means 25% increase\n//\n// Algorithm: C' = C * (1 + amount)\n// Chroma has no strict upper bound but ~0.4 is practical max for sRGB\n\nvariable input: List = {input};\nvariable color: Color.OKLCH = input.get(0).to.oklch();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new chroma\nvariable current_c: Number = color.c;\nvariable new_c: Number = current_c * (1 + amount);\n\n// Soft clamp at 0.4 (approximate sRGB gamut max)\nif (new_c > 0.4) [ new_c = 0.4; ];\n\n// Create output in OKLCH, return as sRGB\nvariable result: Color.OKLCH;\nresult.l = color.l;\nresult.c = new_c;\nresult.h = color.h;\n\nreturn result;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/desaturate/0/", - schema: { - "name": "Desaturate", - "type": "function", - "description": "Decreases color saturation by reducing chroma in OKLCH space. Amount is 0-1 where 0.25 = 25% less saturated.", - "keyword": "desaturate", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to desaturate" - }, - "amount": { - "type": "number", - "description": "Amount to desaturate (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Desaturate a color by decreasing its OKLCH chroma\n// Amount: 0-1 where 0.25 means 25% decrease, 1.0 = full grayscale\n//\n// Algorithm: C' = C * (1 - amount)\n\nvariable input: List = {input};\nvariable color: Color.OKLCH = input.get(0).to.oklch();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new chroma\nvariable current_c: Number = color.c;\nvariable new_c: Number = current_c * (1 - amount);\n\n// Clamp to valid range\nif (new_c < 0) [ new_c = 0; ];\n\n// Create output in OKLCH, return as sRGB\nvariable result: Color.OKLCH;\nresult.l = color.l;\nresult.c = new_c;\nresult.h = color.h;\n\nreturn result;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/mix/0/", - schema: { - "name": "Mix", - "type": "function", - "description": "Blends two colors together in OKLCH space. Amount 0 = first color, 1 = second color, 0.5 = equal blend.", - "keyword": "mix", - "input": { - "type": "object", - "properties": { - "color1": { - "type": "color", - "description": "First color" - }, - "color2": { - "type": "color", - "description": "Second color" - }, - "amount": { - "type": "number", - "description": "Blend amount (0-1). Default is 0.5" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// mix: Blend two colors in perceptual space\n// Uses shortest hue path for natural color blending.\n//\n// Algorithm: Linear interpolation on L, C, and shortest-path H\n//\n// Input: Any color space (converted to OKLCH internally)\n// Output: OKLCH (working space)\n// To get sRGB: mix(color1, color2, 0.5).to.srgb()\n\nvariable input: List = {input};\nvariable color1: Color.OKLCH = input.get(0).to.oklch();\nvariable color2: Color.OKLCH = input.get(1).to.oklch();\n\n// Default amount is 0.5 (50/50 blend)\nvariable amount: Number = 0.5;\nif (input.length() > 2) [\n amount = input.get(2);\n];\n\n// Interpolate lightness and chroma linearly\nvariable new_l: Number = color1.l + (color2.l - color1.l) * amount;\nvariable new_c: Number = color1.c + (color2.c - color1.c) * amount;\n\n// Interpolate hue using shortest path\nvariable h1: Number = color1.h;\nvariable h2: Number = color2.h;\nvariable h_diff: Number = h2 - h1;\n\n// Normalize to shortest path (-180 to 180)\nif (h_diff > 180) [ h_diff = h_diff - 360; ];\nif (h_diff < -180) [ h_diff = h_diff + 360; ];\n\nvariable new_h: Number = h1 + h_diff * amount;\nif (new_h < 0) [ new_h = new_h + 360; ];\nif (new_h >= 360) [ new_h = new_h - 360; ];\n\n// Create mixed color\nvariable result: Color.OKLCH;\nresult.l = new_l;\nresult.c = new_c;\nresult.h = new_h;\n\nreturn result;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/invert/0/", - schema: { - "name": "Invert Color", - "type": "function", - "description": "Inverts a color by inverting each RGB channel (R' = 255 - R, G' = 255 - G, B' = 255 - B).", - "keyword": "invert", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to invert." - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Invert Color Function\n// Returns the inverse/negative of a color by subtracting each RGB channel from 255\n//\n// Algorithm:\n// R' = 255 - R\n// G' = 255 - G \n// B' = 255 - B\n//\n// Use cases:\n// - Creating negative images\n// - High contrast accessibility variants\n// - Artistic color effects\n//\n// Input: Any color (converted to RGB internally)\n// Output: Color.Rgb with inverted channels\n\nvariable input: List = {input};\n\n// Convert input to RGB (0-255 range)\nvariable rgb_color: Color.Rgb = input.get(0).to.rgb();\n\n// Invert each channel by subtracting from 255\nvariable inverted_r: Number = 255 - rgb_color.r;\nvariable inverted_g: Number = 255 - rgb_color.g;\nvariable inverted_b: Number = 255 - rgb_color.b;\n\n// Create and return the inverted color\nvariable inverted_color: Color.Rgb = rgb(inverted_r, inverted_g, inverted_b);\nreturn inverted_color;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/constants/css-hex-colors/0/", - schema: { - "name": "CSS Hex Colors", - "type": "constants", - "description": "CSS named colors mapped to their hex values (CSS Color Level 4)", - "inline": true, - "values": { - "aliceblue": "#F0F8FF", - "antiquewhite": "#FAEBD7", - "aqua": "#00FFFF", - "aquamarine": "#7FFFD4", - "azure": "#F0FFFF", - "beige": "#F5F5DC", - "bisque": "#FFE4C4", - "black": "#000000", - "blanchedalmond": "#FFEBCD", - "blue": "#0000FF", - "blueviolet": "#8A2BE2", - "brown": "#A52A2A", - "burlywood": "#DEB887", - "cadetblue": "#5F9EA0", - "chartreuse": "#7FFF00", - "chocolate": "#D2691E", - "coral": "#FF7F50", - "cornflowerblue": "#6495ED", - "cornsilk": "#FFF8DC", - "crimson": "#DC143C", - "cyan": "#00FFFF", - "darkblue": "#00008B", - "darkcyan": "#008B8B", - "darkgoldenrod": "#B8860B", - "darkgray": "#A9A9A9", - "darkgreen": "#006400", - "darkgrey": "#A9A9A9", - "darkkhaki": "#BDB76B", - "darkmagenta": "#8B008B", - "darkolivegreen": "#556B2F", - "darkorange": "#FF8C00", - "darkorchid": "#9932CC", - "darkred": "#8B0000", - "darksalmon": "#E9967A", - "darkseagreen": "#8FBC8F", - "darkslateblue": "#483D8B", - "darkslategray": "#2F4F4F", - "darkslategrey": "#2F4F4F", - "darkturquoise": "#00CED1", - "darkviolet": "#9400D3", - "deeppink": "#FF1493", - "deepskyblue": "#00BFFF", - "dimgray": "#696969", - "dimgrey": "#696969", - "dodgerblue": "#1E90FF", - "firebrick": "#B22222", - "floralwhite": "#FFFAF0", - "forestgreen": "#228B22", - "fuchsia": "#FF00FF", - "gainsboro": "#DCDCDC", - "ghostwhite": "#F8F8FF", - "gold": "#FFD700", - "goldenrod": "#DAA520", - "gray": "#808080", - "green": "#008000", - "greenyellow": "#ADFF2F", - "grey": "#808080", - "honeydew": "#F0FFF0", - "hotpink": "#FF69B4", - "indianred": "#CD5C5C", - "indigo": "#4B0082", - "ivory": "#FFFFF0", - "khaki": "#F0E68C", - "lavender": "#E6E6FA", - "lavenderblush": "#FFF0F5", - "lawngreen": "#7CFC00", - "lemonchiffon": "#FFFACD", - "lightblue": "#ADD8E6", - "lightcoral": "#F08080", - "lightcyan": "#E0FFFF", - "lightgoldenrodyellow": "#FAFAD2", - "lightgray": "#D3D3D3", - "lightgreen": "#90EE90", - "lightgrey": "#D3D3D3", - "lightpink": "#FFB6C1", - "lightsalmon": "#FFA07A", - "lightseagreen": "#20B2AA", - "lightskyblue": "#87CEFA", - "lightslategray": "#778899", - "lightslategrey": "#778899", - "lightsteelblue": "#B0C4DE", - "lightyellow": "#FFFFE0", - "lime": "#00FF00", - "limegreen": "#32CD32", - "linen": "#FAF0E6", - "magenta": "#FF00FF", - "maroon": "#800000", - "mediumaquamarine": "#66CDAA", - "mediumblue": "#0000CD", - "mediumorchid": "#BA55D3", - "mediumpurple": "#9370DB", - "mediumseagreen": "#3CB371", - "mediumslateblue": "#7B68EE", - "mediumspringgreen": "#00FA9A", - "mediumturquoise": "#48D1CC", - "mediumvioletred": "#C71585", - "midnightblue": "#191970", - "mintcream": "#F5FFFA", - "mistyrose": "#FFE4E1", - "moccasin": "#FFE4B5", - "navajowhite": "#FFDEAD", - "navy": "#000080", - "oldlace": "#FDF5E6", - "olive": "#808000", - "olivedrab": "#6B8E23", - "orange": "#FFA500", - "orangered": "#FF4500", - "orchid": "#DA70D6", - "palegoldenrod": "#EEE8AA", - "palegreen": "#98FB98", - "paleturquoise": "#AFEEEE", - "palevioletred": "#DB7093", - "papayawhip": "#FFEFD5", - "peachpuff": "#FFDAB9", - "peru": "#CD853F", - "pink": "#FFC0CB", - "plum": "#DDA0DD", - "powderblue": "#B0E0E6", - "purple": "#800080", - "rebeccapurple": "#663399", - "red": "#FF0000", - "rosybrown": "#BC8F8F", - "royalblue": "#4169E1", - "saddlebrown": "#8B4513", - "salmon": "#FA8072", - "sandybrown": "#F4A460", - "seagreen": "#2E8B57", - "seashell": "#FFF5EE", - "sienna": "#A0522D", - "silver": "#C0C0C0", - "skyblue": "#87CEEB", - "slateblue": "#6A5ACD", - "slategray": "#708090", - "slategrey": "#708090", - "snow": "#FFFAFA", - "springgreen": "#00FF7F", - "steelblue": "#4682B4", - "tan": "#D2B48C", - "teal": "#008080", - "thistle": "#D8BFD8", - "tomato": "#FF6347", - "turquoise": "#40E0D0", - "violet": "#EE82EE", - "wheat": "#F5DEB3", - "white": "#FFFFFF", - "whitesmoke": "#F5F5F5", - "yellow": "#FFFF00", - "yellowgreen": "#9ACD32" - } - } - }, -]; - -export function makeConfig() { - return new Config().registerSchemas(SCHEMAS); -} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3f045541a8..5f1dad88cd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -210,9 +210,6 @@ importers: '@penpot/text-editor': specifier: link:text-editor version: link:text-editor - '@penpot/tokenscript': - specifier: link:packages/tokenscript - version: link:packages/tokenscript '@penpot/ua-parser': specifier: penpot/ua-parser#1.0.0 version: https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4 @@ -445,12 +442,6 @@ importers: packages/mousetrap: {} - packages/tokenscript: - dependencies: - '@tokens-studio/tokenscript-interpreter': - specifier: ^0.26.0 - version: 0.26.0 - packages/ui: dependencies: react: @@ -547,12 +538,6 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - '@ark/schema@0.56.6': - resolution: {integrity: sha512-OmLv80BCF2ib+zjecrXDosip/CSnZSYBGjblhqWYZWFOzdLQka6QymHToE9u7Z/HZW2KA8Hwc8MCNXoI3uGSIA==} - - '@ark/util@0.56.6': - resolution: {integrity: sha512-mHqq0f0HyDd9YadpFl25w0QECAP7d8HGIUpEC85duD+L+xr4i/CwfOid/Z7zJFFF/47oLt6+pZB9RbqWpBYTYg==} - '@asamuzakjp/css-color@7.1.3': resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -1902,11 +1887,6 @@ packages: peerDependencies: style-dictionary: ^5.0.0 - '@tokens-studio/tokenscript-interpreter@0.26.0': - resolution: {integrity: sha512-dGjvUJnXRspWYp98FZw43l4cN+0ey/cF5sEJjL3coKc5C7DY7MsKgkmOONizmaZqf13GUIzklTEas3gt3jvrOQ==} - engines: {node: '>=16.0.0'} - hasBin: true - '@tokens-studio/types@0.5.2': resolution: {integrity: sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==} @@ -2134,12 +2114,6 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - arkregex@0.0.12: - resolution: {integrity: sha512-sOxL0eX50pnRYO4OyajKZChGuuj/iHwC5tysiAaGOPBu+zJwJQckWn7/DRH2++ol5uaKpyRn7N0hKT7nDqGmgg==} - - arktype@2.2.7: - resolution: {integrity: sha512-AbgM5SQvwCTURSR+ENfPtOG7cL5VZ0hqOVtoWyXKw2SviFddZhCBEUAjjVJYKCNFlPPR1VX5LGUKxsSqqc4Kdw==} - array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -3873,9 +3847,6 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - picocolors@0.2.1: resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==} @@ -4174,10 +4145,6 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - readline-sync@1.4.10: - resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} - engines: {node: '>= 0.8.0'} - recast@0.23.21: resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} @@ -5203,10 +5170,6 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yauzl@3.4.0: - resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} - engines: {node: '>=12'} - yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -5215,12 +5178,6 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@ark/schema@0.56.6': - dependencies: - '@ark/util': 0.56.6 - - '@ark/util@0.56.6': {} - '@asamuzakjp/css-color@7.1.3': dependencies: '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) @@ -6461,13 +6418,6 @@ snapshots: is-mergeable-object: 1.1.1 style-dictionary: 5.5.5 - '@tokens-studio/tokenscript-interpreter@0.26.0': - dependencies: - arktype: 2.2.7 - commander: 14.0.3 - readline-sync: 1.4.10 - yauzl: 3.4.0 - '@tokens-studio/types@0.5.2': {} '@tybys/wasm-util@0.10.4': @@ -6717,16 +6667,6 @@ snapshots: aria-query@5.3.2: {} - arkregex@0.0.12: - dependencies: - '@ark/util': 0.56.6 - - arktype@2.2.7: - dependencies: - '@ark/schema': 0.56.6 - '@ark/util': 0.56.6 - arkregex: 0.0.12 - array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -8555,8 +8495,6 @@ snapshots: pathval@2.0.1: {} - pend@1.2.0: {} - picocolors@0.2.1: {} picocolors@1.1.1: {} @@ -8897,8 +8835,6 @@ snapshots: readdirp@5.1.1: {} - readline-sync@1.4.10: {} - recast@0.23.21: dependencies: ast-types: 0.16.3 @@ -10018,8 +9954,4 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 - yauzl@3.4.0: - dependencies: - pend: 1.2.0 - yocto-queue@1.2.2: {} diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 9b37274b5f..8021298f2c 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -10,7 +10,6 @@ ["style-dictionary$default" :as sd] [app.common.files.tokens :as cfo] [app.common.logging :as l] - [app.common.schema :as sm] [app.common.time :as ct] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] @@ -86,8 +85,7 @@ (let [number? (or (number? value) (numeric-string? value)) parsed-value (cfo/parse-token-value value) - out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int) - (<= (:value parsed-value) sm/min-safe-int))] + out-of-bounds (cto/number-out-of-bounds? (:value parsed-value))] (cond (and parsed-value (not out-of-bounds) number?) @@ -112,8 +110,7 @@ If the `value` is not parseable and/or has missing references returns a map with `:errors`." [value] (let [parsed-value (cfo/parse-token-value value) - out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int) - (<= (:value parsed-value) sm/min-safe-int))] + out-of-bounds (cto/number-out-of-bounds? (:value parsed-value))] (if (and parsed-value (not out-of-bounds)) parsed-value (if out-of-bounds @@ -185,7 +182,7 @@ If the `value` is not parseable and/or has missing references returns a map with `:errors`." [value] (let [normalized-value (str/lower (str/trim value)) - valid? (contains? #{"none" "uppercase" "lowercase" "capitalize"} normalized-value) + valid? (contains? cto/text-case-values normalized-value) references (seq (cto/find-token-value-references value))] (cond valid? diff --git a/frontend/src/app/main/data/tokenscript.cljs b/frontend/src/app/main/data/tokenscript.cljs index 7f0cbee92e..f002d49bed 100644 --- a/frontend/src/app/main/data/tokenscript.cljs +++ b/frontend/src/app/main/data/tokenscript.cljs @@ -1,166 +1,255 @@ +;; 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.data.tokenscript + "Resolves the workspace tokens with the tokenscript interpreter + (`tokenscript.core`, from penpot/tokenscript-interpreter). + + `resolve-tokens` takes the usual map of token name to token and returns + the same map with `:resolved-value` (a value ready for shape attributes: + numbers in pixels, colors as hex strings, composites as maps), + `:resolved-symbol` (the tokenscript value, see + `tokenscript.symbols`) and `:errors` (Penpot error maps, see + `app.main.data.workspace.tokens.errors`) added to each token." (:require - ["@penpot/tokenscript" :refer [BaseSymbolType - ColorSymbol - ListSymbol NumberSymbol - NumberWithUnitSymbol - ProcessorError - processTokens - TokenSymbol - makeConfig]] [app.common.data :as d] [app.common.logging :as l] [app.common.time :as ct] [app.common.types.token :as cto] [app.main.data.workspace.tokens.errors :as wte] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [tokenscript.core :as tsc] + [tokenscript.errors :as tse] + [tokenscript.symbols :as sym])) -(l/set-level! :debug) +;; Value conversion ----------------------------------------------------------- -;; Config ---------------------------------------------------------------------- +(defn- color->hex-string + "Hex string of a color symbol (`#rrggbbaa` when it has alpha)." + [color] + (sym/to-string (tsc/color->hex color))) -(def config (makeConfig)) +(defn- symbol->value + "Converts a resolved tokenscript value into the shape used by shape + attributes and the token forms: -;; Class predicates ------------------------------------------------------------ -;; Predicates to get information about the tokenscript interpreter symbol type -;; Or to determine the error + - colors: hex strings (alpha as `#rrggbbaa`) + - `rem` values: pixels (16px root font size), `%` values: fractions + - other numbers with unit: the number + - structured tokens (typography, shadows): maps with keyword keys and + vectors + - null: nil" + [v] + (let [entries->map (fn [entries] + (into {} (map (fn [[k v]] [(keyword k) (symbol->value v)])) entries))] + (cond + (nil? v) nil + (sym/null? v) nil + (sym/token? v) (let [content (sym/value v)] + (if (map? content) + (entries->map content) + (mapv symbol->value content))) + (sym/list? v) (mapv symbol->value (sym/value v)) + (sym/dictionary? v) (entries->map (sym/value v)) + (sym/color? v) (color->hex-string v) + (sym/number-with-unit? v) (let [n (sym/value v)] + (case (:unit v) + "rem" (* n 16) + "%" (/ n 100) + n)) + (sym/symbol? v) (sym/value v) + :else v))) -(defn tokenscript-symbol? [v] - (instance? BaseSymbolType v)) +;; Value validation ----------------------------------------------------------- +;; +;; Tokenscript resolves any expression; these are the Penpot level checks +;; on the resolved value of each token type (ranges, allowed keywords, +;; composite shapes) that decide whether a token can be applied. -(defn structured-token? [v] - (instance? TokenSymbol v)) +(defn- words->string + "Values like `700 Italic` resolve to a list of words; font weights are + strings." + [v] + (if (sequential? v) (str/join " " (map str v)) v)) -(defn structured-record-token? [^js v] - (and (structured-token? v) (instance? js/Map (.-value v)))) - -(defn structured-array-token? [^js v] - (and (structured-token? v) (instance? js/Array (.-value v)))) - -(defn number-with-unit-symbol? [v] - (instance? NumberWithUnitSymbol v)) - -(defn number-symbol? [v] - (instance? NumberSymbol v)) - -(defn list-symbol? [v] - (instance? ListSymbol v)) - -(defn color-symbol? [v] - (instance? ColorSymbol v)) - -(defn processor-error? [err] - (instance? ProcessorError err)) - -;; Conversion Tools ------------------------------------------------------------ -;; Helpers to convert tokenscript symbols to penpot accepted formats - -(defn color-symbol->hex-string [^js v] - (when (color-symbol? v) - (.toString (.to v "hex")))) - -(defn color-alpha [^js v] - (if (.isHex v) - 1 - (or (.getAttribute v "alpha") 1))) - -(defn color-symbol->penpot-color [^js v] - {:color (color-symbol->hex-string v) - :opacity (color-alpha v)}) - -(defn rem-number-with-unit? [v] - (and (number-with-unit-symbol? v) - (= (.-unit v) "rem"))) - -(defn percent-number-with-unit? [v] - (and (number-with-unit-symbol? v) - (= (.-unit v) "%"))) - -(defn rem->px [^js v] - (* (.-value v) 16)) - -(declare tokenscript-symbols->penpot-unit) - -(defn font-family-symbols->penpot-unit - "Converts a resolved font-family value into a vector of family names. - Each family name is the string form of its whole entry, which is a list of - word symbols when the family reached tokenscript unquoted." - [^js v] - (when (some? v) - (let [entries (.-value v)] - (if (instance? js/Array entries) - (mapv str entries) - [(str v)])))) - -(defn structured-token->penpot-map - "Converts structured token (record or array) to penpot map format. - Structured tokens are non-primitive token types like `typography` or `box-shadow`." - [^js token-symbol] - (if (instance? js/Array (.-value token-symbol)) - (mapv tokenscript-symbols->penpot-unit (.-value token-symbol)) - (let [entries (es6-iterator-seq (.entries (.-value token-symbol)))] - (into {} (map (fn [[k v]] - (let [k (keyword k)] - ;; The font-family member of a composite is a family list - [k (if (= :font-family k) - (font-family-symbols->penpot-unit v) - (tokenscript-symbols->penpot-unit v))]))) - entries)))) - -(defn tokenscript-symbols->penpot-unit [^js v] +(defn- font-family->vector + "Font families are always a flat vector of family names, also when the + value is a single name or references a font family token." + [v] (cond - (nil? v) nil - (structured-token? v) (structured-token->penpot-map v) - (list-symbol? v) (structured-token->penpot-map v) - (color-symbol? v) (.-value (.to v "hex")) - (rem-number-with-unit? v) (rem->px v) - (percent-number-with-unit? v) (/ (.-value v) 100) - :else (.-value v))) + (string? v) [v] + (sequential? v) (vec (flatten v)) + :else v)) -(defn resolved-value->penpot-unit - "Converts the resolved value of a token of the given `type`. - Font families need the type: a list of symbols is a list of families at the - top level and the words of a single family name inside an entry." - [type ^js v] - (if (= :font-family type) - (font-family-symbols->penpot-unit v) - (tokenscript-symbols->penpot-unit v))) +(defn- line-height->ratio + "Line heights in pixels are stored relative to the font size. Returns + the ratio or an error when the font size is not available." + [line-height font-size] + (if (and (sym/number-with-unit? line-height) (= "px" (:unit line-height))) + (if (number? font-size) + {:value (/ (sym/value line-height) font-size)} + {:error (wte/error-with-value :error.style-dictionary/composite-line-height-needs-font-size font-size)}) + {:value (symbol->value line-height)})) -;; Processors ------------------------------------------------------------------ -;; The processor resolves tokens -;; resolved/error tokens get put back into a clojure structure directly during build time -;; For updating tokens we use the `TokenResolver` crud methods from the processing result -;; The `TokenResolver` has detailed information for each tokens dependency graph +(defn- typography->value + "Converts a resolved typography composite, adjusting the fields the + text attributes expect. With `line-height-ratio` a line height in + pixels is converted relative to the font size." + [s line-height-ratio] + (let [value (symbol->value s)] + (if-not (map? value) + {:value value} + (let [value (cond-> value + (contains? value :font-family) (update :font-family font-family->vector) + (contains? value :font-weight) (update :font-weight words->string)) + line-height (when (and line-height-ratio (sym/token? s)) + (get (sym/value s) "line-height"))] + (if (some? line-height) + (let [{ratio :value error :error} (line-height->ratio line-height (:font-size value))] + (if error + {:error error} + {:value (assoc value :line-height ratio)})) + {:value value}))))) -(defn create-token-builder - "Collects resolved tokens during build time into a clojure structure. - Returns Tokenscript Symbols in `:resolved-value` key." - [tokens] - (let [output (volatile! tokens) +(defn- convert-value + "Converts the resolved symbol of a token to the value shape expected by + its type. Returns `{:value v}` or `{:error e}`. Options: - ;; When a token is resolved (No parsing / reference errors) we assing `:resolved-value` for the original token - on-resolve - (fn [^js/String token-name ^js/Symbol resolved-symbol] - (vswap! output assoc-in [token-name :resolved-value] resolved-symbol)) + :line-height-ratio convert typography line heights in pixels to a + ratio of the font size (default true)" + ([type s] (convert-value type s nil)) + ([type s {:keys [line-height-ratio] :or {line-height-ratio true}}] + (case type + :typography (typography->value s line-height-ratio) + :font-family {:value (font-family->vector (symbol->value s))} + :font-weight {:value (words->string (symbol->value s))} + {:value (symbol->value s)}))) - ;; When a token contains any errors we assing `:errors` for the original token - on-error - (fn [^js/String token-name ^js/Error _error ^js/String _original-value] - (let [value (get tokens token-name) - default-error [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]] - (vswap! output assoc-in [token-name :errors] default-error))) +(defn- convert-token-value + "`convert-value` for a token: a failing conversion (e.g. a color that + cannot be written as hex) is an error of that token, not of the whole + resolution." + [{:keys [type value]} s] + (try + (convert-value type s) + (catch :default _ + {:error (wte/error-with-value :error.style-dictionary/invalid-token-value value)}))) - ;; Extract the atom value - get-result - (fn [] @output)] - #js {:onResolve on-resolve - :onError on-error - :getResult get-result})) +(defn plugin-value + "Resolved value of a token as exposed by the plugin API: like + `:resolved-value` but with typography line heights left as resolved." + [{:keys [type resolved-symbol]}] + (when (some? resolved-symbol) + (:value (convert-value type resolved-symbol {:line-height-ratio false})))) + +(defn- validate-value + "Returns a Penpot error for the resolved `value` of `token`, or nil when + it is acceptable. The interpreter validates the kind of value of each + type; these are the Penpot specific limits on top." + [{:keys [type]} value] + (if (and (number? value) (cto/number-out-of-bounds? value)) + (wte/error-with-value :error.token/number-too-large value) + (case type + :text-decoration + (when-not (and (string? value) (cto/valid-text-decoration value)) + (wte/error-with-value :error.style-dictionary/invalid-token-value-text-decoration value)) + + :font-weight + (when-not (cto/valid-font-weight-variant (str value)) + (wte/error-with-value :error.style-dictionary/invalid-token-value-font-weight value)) + + :font-family + (when-not (or (string? value) (and (sequential? value) (every? string? value))) + (wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value)) + + nil))) + +;; Errors --------------------------------------------------------------------- + +(defn- missing-reference-of + "Missing token name behind an issue, if the root cause is a missing + token." + [{:keys [code data]}] + (case code + :processor/token-not-found (:token-name data) + :interpreter/unknown-reference (:reference data) + :processor/dependency-error (let [cause (:cause data)] + (when (= :processor/token-not-found (:code cause)) + (get-in cause [:data :token-name]))) + nil)) + +(defn- circular? + [{:keys [code data]}] + (or (= :processor/circular-dependency code) + (= :processor/circular-dependency (get-in data [:cause :code])))) + +(defn- issue->error + "Maps a tokenscript issue to a Penpot token error. The interpreter only + reports codes and data; the messages are Penpot's. Codes without a + message of their own are an invalid value." + [{:keys [code data] :as issue} value] + (cond + (circular? issue) + (wte/error-with-value :error.token/circular-reference nil) + + (missing-reference-of issue) + (wte/error-with-value :error.style-dictionary/missing-reference [(missing-reference-of issue)]) + + :else + (case code + (:operations/division-by-zero :functions/division-by-zero) + (wte/error-with-value :error.token/division-by-zero nil) + + :operations/cannot-mix-units + (wte/error-with-value :error.token/incompatible-units (:units data)) + + :operations/cannot-compare-units + (wte/error-with-value :error.token/incompatible-units [(:unit-a data) (:unit-b data)]) + + :interpreter/unknown-function + (wte/error-with-value :error.token/unknown-function (:function-name data)) + + (:functions/argument-out-of-range :functions/invalid-base) + (wte/error-with-value :error.token/function-argument-out-of-range (:function-name data)) + + (wte/error-with-value :error.style-dictionary/invalid-token-value value)))) + +(defn- validation-error + "Maps a failed token type validation of the interpreter (an issue with + an `INVALID__` code) to the Penpot error of the token's + type." + [{:keys [code]} {:keys [type value]}] + (case type + :opacity (wte/error-with-value :error.style-dictionary/invalid-token-value-opacity value) + :stroke-width (wte/error-with-value :error.style-dictionary/invalid-token-value-stroke-width value) + :text-case (wte/error-with-value :error.style-dictionary/invalid-token-value-text-case value) + :text-decoration (wte/error-with-value :error.style-dictionary/invalid-token-value-text-decoration value) + :font-weight (wte/error-with-value :error.style-dictionary/invalid-token-value-font-weight value) + :font-family (wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value) + :typography (wte/error-with-value :error.style-dictionary/invalid-token-value-typography value) + :shadow (wte/error-with-value :error.style-dictionary/invalid-token-value-shadow value) + (if (= "INVALID_NUMBER_UNIT" code) + (wte/error-with-value :error.style-dictionary/value-with-units value) + (wte/error-with-value :error.style-dictionary/invalid-token-value value)))) + +(defn- token-errors + [issues token] + (->> issues + (keep (fn [issue] + (cond + (tse/error-map? issue) (issue->error issue (:value token)) + (= :tokenscript/issue (:type issue)) (validation-error issue token)))) + (seq))) + +;; Resolution ----------------------------------------------------------------- (defn- quote-font-family - "Quotes a font family name so tokenscript reads it as one string instead of - parsing its words (`Red` would become a color, `2P` a number and a word). - Entries with a token reference or already quoted are kept as they are." + "Quotes a font family name so tokenscript reads it as one string instead + of parsing its words (`Red` would become a color, `2P` a number and a + word). Entries with a token reference or already quoted are kept." [family] (cond (or (not (string? family)) @@ -183,49 +272,86 @@ (mapv quote-font-family value) value)) -(defn- token-value->tokenscript-value - [type value] - (case type - :font-family (quote-font-families value) - :typography (cond-> value - (map? value) (d/update-when :font-family quote-font-families)) - value)) - -(defn clj->token->tokenscript-token - "Convert penpot token into a format that tokenscript can handle." +(defn- token->input + "Token data for the interpreter. Types are passed by their DTCG names, + which the interpreter's bundled token types validate." [{:keys [type value]}] - #js {"$type" (name type) - "$value" (clj->js (token-value->tokenscript-value type value))}) + (cond-> {:value (case type + :font-family (quote-font-families value) + :typography (cond-> value + (map? value) (d/update-when :font-family quote-font-families)) + value)} + (some? type) (assoc :type (get cto/token-type->dtcg-token-type type (name type))))) -(defn clj-tokens->tokenscript-tokens - "Convert penpot map of tokens into tokenscript map structure. - tokenscript accepts a map of [token-name {\"$type\": string, \"$value\": any}]" +(defn- empty-value? + [token] + (nil? (:value token))) + +(defn- resolved-token + "Adds the result of the interpreter to `token`: `:resolved-value` and + `:resolved-symbol` for the resolved symbol `s`, or `:errors` for its + `issues`, a failing conversion or a value Penpot doesn't accept." + [token issues s] + (if-let [errors (token-errors issues token)] + (assoc token :errors (vec errors)) + (let [{:keys [value error]} (convert-token-value token s)] + (if-let [error (or error (validate-value token value))] + (assoc token :errors [error]) + (assoc token + :resolved-value value + :resolved-symbol s))))) + +(defn- resolve-tokens* [tokens] - (let [token-map (js/Map.)] - (doseq [[k token] tokens] - (.set token-map k (clj->token->tokenscript-token token))) - token-map)) + (let [tpoint (ct/tpoint-ms) + inputs (into {} + (comp (remove (fn [[_ token]] (empty-value? token))) + (map (fn [[name token]] [name (token->input token)]))) + tokens) + result (tsc/build inputs) + resolved (:tokens result) + issues (:issues result) + output (reduce-kv + (fn [acc name token] + (assoc acc name + (if (empty-value? token) + (assoc token :errors [(wte/get-error-code :error.token/empty-input)]) + (resolved-token token (get issues name) (get resolved name))))) + {} + tokens)] + (l/dbg :hint "tokenscript/resolve-tokens" :elapsed (tpoint)) + output)) -(defn process-tokens - "Builds tokens using `tokenscript`." +;; Several components resolve the same token maps on every change (the +;; sidebar, the forms and the propagation each compute the active tokens +;; separately), so the results of the last few maps are kept. Entries are +;; matched by identity first and by equality second: comparing two maps +;; of a few hundred tokens is far cheaper than resolving them again. +(def ^:private cache-size 8) + +(defonce ^:private results-cache (volatile! [])) + +(defn- cached-result [tokens] - (let [input (clj-tokens->tokenscript-tokens tokens) - result (processTokens input #js {:config config - :builder (create-token-builder tokens)})] - result)) + (let [entries @results-cache] + (or (some (fn [[t result]] (when (identical? t tokens) result)) entries) + (some (fn [[t result]] (when (= t tokens) result)) entries)))) -(defn update-token +(defn- remember-result + [tokens result] + (vswap! results-cache (fn [entries] (vec (take cache-size (cons [tokens result] entries)))))) + +(defn resolve-tokens + "Resolves `tokens` (a map of token name to token). Returns the tokens + with `:resolved-value` or `:errors` added." + [tokens] + (or (cached-result tokens) + (let [result (resolve-tokens* tokens)] + (remember-result tokens result) + result))) + +(defn resolve-token + "Resolves `token` against `tokens` (the token replaces any token with + the same name). Returns the resolved token." [tokens token] - (let [result (process-tokens tokens) - resolver (.-resolver result)] - (.updateToken resolver #js {:tokenPath (:name token) - :tokenData (clj->token->tokenscript-token token)}))) - -;; Main ------------------------------------------------------------------------ - -(defn resolve-tokens [tokens] - (let [tpoint (ct/tpoint-ms) - result (process-tokens tokens) - elapsed (tpoint)] - (l/dbg :hint "tokenscript/resolve-tokens" :elapsed elapsed) - (.-output result))) + (get (resolve-tokens (assoc tokens (:name token) token)) (:name token))) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 6b667e3631..3ab4427ce6 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -735,9 +735,6 @@ any-variant? (->> shapes vals (some ctk/is-variant?) boolean) resolved-value (get-in resolved-tokens [(:name token) :resolved-value]) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/resolved-value->penpot-unit (:type token) resolved-value) - resolved-value) tokenized-attributes (cfo/attributes-map attributes token) type (:type token)] (rx/concat diff --git a/frontend/src/app/main/data/workspace/tokens/color.cljs b/frontend/src/app/main/data/workspace/tokens/color.cljs index a713e76d0f..7112ca8255 100644 --- a/frontend/src/app/main/data/workspace/tokens/color.cljs +++ b/frontend/src/app/main/data/workspace/tokens/color.cljs @@ -7,9 +7,7 @@ (ns app.main.data.workspace.tokens.color (:require [app.common.files.tokens :as cfo] - [app.config :as cf] - [app.main.data.tinycolor :as tinycolor] - [app.main.data.tokenscript :as ts])) + [app.main.data.tinycolor :as tinycolor])) (defn color-bullet-color [token-color-value] (when-let [tc (tinycolor/valid-color token-color-value)] @@ -19,8 +17,5 @@ (tinycolor/->hex-string tc)))) (defn resolved-token-bullet-color [{:keys [resolved-value] :as token}] - (if (contains? cf/flags :tokenscript) - (when (and resolved-value (ts/color-symbol? resolved-value)) - (ts/color-symbol->penpot-color resolved-value)) - (when (and resolved-value (cfo/color-token? token)) - (color-bullet-color resolved-value)))) + (when (and resolved-value (cfo/color-token? token)) + (color-bullet-color resolved-value))) diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index bb4d06ded7..daa27111c8 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -57,6 +57,22 @@ {:error/code :error.token/number-too-large :error/fn #(str (tr "errors.tokens.number-too-large" %))} + :error.token/division-by-zero + {:error/code :error.token/division-by-zero + :error/fn #(tr "errors.tokens.division-by-zero")} + + :error.token/incompatible-units + {:error/code :error.token/incompatible-units + :error/fn #(tr "errors.tokens.incompatible-units" (str/join ", " %))} + + :error.token/unknown-function + {:error/code :error.token/unknown-function + :error/fn #(tr "errors.tokens.unknown-function" %)} + + :error.token/function-argument-out-of-range + {:error/code :error.token/function-argument-out-of-range + :error/fn #(tr "errors.tokens.function-argument-out-of-range" %)} + ;; Surfaced when a token name conflicts with a token-group prefix ;; across the active sets (e.g. one set defines `a`, another set ;; defines `a.b`). DTCG/StyleDictionary cannot represent both as diff --git a/frontend/src/app/main/data/workspace/tokens/format.cljs b/frontend/src/app/main/data/workspace/tokens/format.cljs index ada74c1df8..759399c3d1 100644 --- a/frontend/src/app/main/data/workspace/tokens/format.cljs +++ b/frontend/src/app/main/data/workspace/tokens/format.cljs @@ -1,6 +1,11 @@ +;; 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.data.workspace.tokens.format (:require - [app.main.data.tokenscript :as ts] [cuerdas.core :as str])) (def category-dictionary @@ -35,39 +40,10 @@ (str/join "\n") (str "\n"))) -(defn- format-structured-token - "Formats tokenscript Token" - [token-symbol] - (->> (.-value token-symbol) - (.entries) - (es6-iterator-seq) - (format-map-entries))) - -(defn format-tokenscript-symbol - [^js tokenscript-symbol] - (cond - (ts/rem-number-with-unit? tokenscript-symbol) - (str (ts/rem->px tokenscript-symbol) "px") - - (ts/color-symbol? tokenscript-symbol) - (ts/color-symbol->hex-string tokenscript-symbol) - - (ts/structured-record-token? tokenscript-symbol) - (format-structured-token tokenscript-symbol) - - (ts/structured-array-token? tokenscript-symbol) - (str/join "\n" (map format-tokenscript-symbol (.-value tokenscript-symbol))) - - :else - (.toString tokenscript-symbol))) - (defn format-token-value "Converts token value of any shape to a string." [token-value] (cond - (ts/tokenscript-symbol? token-value) - (format-tokenscript-symbol token-value) - (map? token-value) (format-map-entries token-value) diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index aa43061336..2027e5be42 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -6,7 +6,6 @@ (ns app.main.data.workspace.tokens.propagation (:require - [app.common.data :as d] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.tokens :as cfo] @@ -217,9 +216,7 @@ (when (and tokens-lib tokens-status) (when-let [tokens-tree (cfo/get-tokens-in-active-sets tokens-status tokens-lib)] (->> (if (contains? cf/flags :tokenscript) - (rx/of (-> (ts/resolve-tokens tokens-tree) - (d/update-vals #(update % :resolved-value - (partial ts/resolved-value->penpot-unit (:type %)))))) + (rx/of (ts/resolve-tokens tokens-tree)) (sd/resolve-tokens tokens-tree)) (rx/mapcat (fn [sd-tokens] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs index 1e8d9a6198..477d7327ea 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs @@ -81,10 +81,7 @@ (sd/resolve-tokens-interactive tokens)) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (first errors)}))))))))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs index b3dbec0c94..a98e51ce74 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -58,10 +58,7 @@ (sd/resolve-tokens-interactive tokens)) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (if errors diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs index 48d8586565..7833bd95ea 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs @@ -76,10 +76,7 @@ (sd/resolve-tokens-interactive tokens)) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/resolved-value->penpot-unit (:type token) resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (if errors diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs index 8264836980..b7dd512b7f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs @@ -144,16 +144,25 @@ (defn- resolve-value-tokenscript - [tokens prev-token value] - (let [result (ts/update-token tokens (assoc prev-token :value value)) - token-result (.-resolved result)] + [tokens prev-token token-name value] + (let [valid-token-name? + (and (string? token-name) + (re-matches cto/token-name-validation-regex token-name)) + + token + (assoc prev-token + :value value + :name (if (or (not valid-token-name?) (str/blank? token-name)) + "__PENPOT__TOKEN__NAME__PLACEHOLDER__" + token-name)) + + {:keys [errors resolved-value]} + ;; Remove previous token when renaming a token + (ts/resolve-token (dissoc tokens (:name prev-token)) token)] (rx/of - (cond - (ts/processor-error? token-result) {:error (wte/error-with-value :error.style-dictionary/missing-reference (some->> (.-dependencyChain token-result) - (seq) - (rest)))} - (instance? js/Error token-result) {:error (wte/error-with-value :error.style-dictionary/invalid-token-value value)} - :else {:value token-result})))) + (if (seq errors) + {:error (first errors)} + {:value resolved-value})))) (defn- resolve-value [tokens prev-token token-name value] @@ -178,10 +187,7 @@ (sd/resolve-tokens-interactive) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (if errors diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs index 428d2fee31..717e37f831 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs @@ -44,10 +44,7 @@ (sd/resolve-tokens-interactive tokens')) (rx/mapcat (fn [resolved-tokens] - (let [resolved-token (cond-> (get resolved-tokens (:name token)) - (contains? cf/flags :tokenscript) - (update :resolved-value - (partial ts/resolved-value->penpot-unit (:type token))))] + (let [resolved-token (get resolved-tokens (:name token))] (cond (:resolved-value resolved-token) (rx/of resolved-token) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index cdb8d89284..d98c481028 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -111,7 +111,7 @@ (defn- generate-tooltip "Generates a tooltip for a given token" - [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set is-name-collision errors] + [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set error-message] (let [{:keys [name type resolved-value value]} token resolved-value-theme (:resolved-value theme-token) resolved-value (or resolved-value-theme resolved-value) @@ -141,8 +141,8 @@ ref-not-in-active-set (tr "workspace.tokens.ref-not-valid" name) - is-name-collision - (wte/resolve-error-message (first errors)) + error-message + error-message no-valid-value (tr "workspace.tokens.value-not-valid") @@ -237,9 +237,11 @@ is-viewer? (not can-edit) + tokenscript? (contains? cf/flags :tokenscript) + ref-not-in-active-set - (if (contains? cf/flags :tokenscript) - (seq (:errors resolved-token)) + (if tokenscript? + (wte/has-error-code? :error.style-dictionary/missing-reference errors) (and is-reference? (not (contains-reference-value? value active-theme-tokens)))) @@ -250,6 +252,11 @@ no-valid-value (seq errors) + ;; Tokenscript errors carry their own message, as in the edit form. + error-message + (when (and no-valid-value (or tokenscript? name-collision)) + (wte/resolve-error-message (first errors))) + errors? (or ref-not-in-active-set no-valid-value @@ -297,11 +304,11 @@ on-hover (mf/use-fn - (mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set name-collision errors) + (mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set error-message) (fn [_] (let [theme-token (get active-theme-tokens name) title (generate-tooltip is-viewer? (first selected-shapes) theme-token token - half-applied? no-valid-value ref-not-in-active-set name-collision errors)] + half-applied? no-valid-value ref-not-in-active-set error-message)] (reset! tooltip-content* title)))) ;; `tooltip-content` may hold rich (hiccup) content, so it's wrapped @@ -346,9 +353,8 @@ [:> icon* {:icon-id i/broken-link :class (stl/css :token-pill-icon) - :aria-label (if name-collision - (wte/resolve-error-message (first errors)) - (tr "workspace.tokens.missing-reference"))}] + :aria-label (or error-message + (tr "workspace.tokens.missing-reference"))}] color [:> swatch* {:background color diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index a558870f95..737099002e 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -232,14 +232,12 @@ k)) (defn- shadow-entry->js - "Converts one resolved shadow entry (a JS Map of field name -> tokenscript - symbol) into a plain JS object using the public member names and the - unit-converted values." - [^js m] + "Converts one resolved shadow entry (a map of field name -> value) into + a plain JS object using the public member names." + [m] (let [out #js {}] - (.forEach m (fn [sym k] - (obj/set! out (shadow-key->camel k) - (ts/tokenscript-symbols->penpot-unit sym)))) + (doseq [[k v] m] + (obj/set! out (shadow-key->camel (name k)) (clj->js v))) out)) (defn- shadow-resolved-value->js @@ -249,38 +247,29 @@ (when (some? entries) (into-array (map shadow-entry->js entries)))) -(defn- font-families-resolved-value->js - "Converts a resolved fontFamilies value (a tokenscript list symbol) into the - documented `string[]` shape." - [resolved-value] - (some-> (ts/font-family-symbols->penpot-unit resolved-value) - (clj->js))) - (defn- get-resolved-value [token tokens-tree] (let [resolved-tokens (ts/resolve-tokens tokens-tree) - resolved-value (dm/get-in resolved-tokens [(:name token) :resolved-value])] + resolved-token (get resolved-tokens (:name token)) + resolved-value (:resolved-value resolved-token)] (cond (= :font-family (:type token)) - ;; A fontFamilies token resolves to a list of families; expose it as the - ;; documented `string[]` rather than the raw tokenscript list symbol. - (font-families-resolved-value->js resolved-value) + ;; A fontFamilies token resolves to a vector of families; expose it as + ;; the documented `string[]`. + (some-> resolved-value clj->js) (= :typography (:type token)) ;; A typography token resolves to a composite; expose it as the documented - ;; `TokenTypographyValue[]` rather than the raw tokenscript structure. - (typography-resolved-value->js - (ts/tokenscript-symbols->penpot-unit resolved-value)) + ;; `TokenTypographyValue[]` with the line height as resolved. + (typography-resolved-value->js (ts/plugin-value resolved-token)) (= :shadow (:type token)) - ;; A shadow token resolves to a list of composites whose entries the - ;; tokenscript unit conversion leaves as raw symbols; expose them as the + ;; A shadow token resolves to a list of composites; expose them as the ;; documented `TokenShadowValue[]`. - (shadow-resolved-value->js - (ts/tokenscript-symbols->penpot-unit resolved-value)) + (shadow-resolved-value->js resolved-value) :else - (ts/tokenscript-symbols->penpot-unit resolved-value)))) + resolved-value))) (defn token-proxy? [p] (obj/type-of? p "TokenProxy")) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 28a0028593..5dd9553f7d 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -110,6 +110,7 @@ [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] [frontend-tests.tokens.token-path-test] + [frontend-tests.tokens.tokenscript-test] [frontend-tests.tokens.workspace-tokens-remap-test] [frontend-tests.ui.check-updates-test] [frontend-tests.ui.color-token-position-test] @@ -269,6 +270,7 @@ 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.token-path-test + 'frontend-tests.tokens.tokenscript-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/tokenscript_test.cljs b/frontend/test/frontend_tests/tokens/tokenscript_test.cljs new file mode 100644 index 0000000000..f6c1b8e01d --- /dev/null +++ b/frontend/test/frontend_tests/tokens/tokenscript_test.cljs @@ -0,0 +1,184 @@ +;; 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.tokenscript-test + (:require + [app.common.test-helpers.ids-map :as cthi] + [app.common.types.tokens-lib :as ctob] + [app.main.data.tokenscript :as ts] + [cljs.test :as t :include-macros true])) + +(defn- tokens-map + "Tokens map of a library with one set holding `tokens`." + [& tokens] + (let [lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) :name "core")))] + (-> (reduce (fn [lib token] (ctob/add-token lib (cthi/id :core-set) (ctob/make-token token))) lib tokens) + (ctob/get-all-tokens-map)))) + +(t/deftest resolve-tokens-test + (t/testing "resolves references and converts units to pixels" + (let [resolved (ts/resolve-tokens + (tokens-map {:name "borderRadius.sm" :value "12px" :type :border-radius} + {:name "borderRadius.md-with-dashes" :value "{borderRadius.sm} * 2" :type :border-radius} + {:name "borderRadius.rem" :value "2rem" :type :border-radius} + {:name "opacity.half" :value "50%" :type :opacity} + {:name "borderRadius.large" :value "123456789012345" :type :border-radius} + {:name "borderRadius.largePx" :value "123456789012345px" :type :border-radius} + {:name "borderRadius.largeFn" :value "{borderRadius.sm} * 200000000" :type :border-radius}))] + (t/is (= 12 (get-in resolved ["borderRadius.sm" :resolved-value]))) + (t/is (= 24 (get-in resolved ["borderRadius.md-with-dashes" :resolved-value]))) + (t/is (= 32 (get-in resolved ["borderRadius.rem" :resolved-value]))) + (t/is (= 0.5 (get-in resolved ["opacity.half" :resolved-value]))) + (doseq [name ["borderRadius.large" "borderRadius.largePx" "borderRadius.largeFn"]] + (t/is (nil? (get-in resolved [name :resolved-value]))) + (t/is (= :error.token/number-too-large (get-in resolved [name :errors 0 :error/code]))))))) + +(t/deftest resolve-tokens-colors-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "color.red" :value "#ff0000" :type :color} + {:name "color.alias" :value "{color.red}" :type :color} + {:name "color.alpha" :value "#ff000080" :type :color} + {:name "color.named" :value "red" :type :color} + {:name "color.light" :value "lighten({color.red}, 0.2)" :type :color}))] + (t/is (= "#ff0000" (get-in resolved ["color.alias" :resolved-value]))) + (t/is (= "#ff000080" (get-in resolved ["color.alpha" :resolved-value])) "alpha is kept as hex8") + (t/is (= "#FF0000" (get-in resolved ["color.named" :resolved-value])) "css names resolve") + (t/is (string? (get-in resolved ["color.light" :resolved-value])) "color functions resolve to hex"))) + +(t/deftest resolve-tokens-errors-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "a" :value "{missing}" :type :border-radius} + {:name "b" :value "{a} + 1" :type :border-radius} + {:name "x" :value "{y}" :type :border-radius} + {:name "y" :value "{x}" :type :border-radius} + {:name "z" :value "{x} + 1" :type :border-radius} + {:name "bad" :value "1 ´ 2" :type :border-radius}))] + (t/is (= :error.style-dictionary/missing-reference (get-in resolved ["a" :errors 0 :error/code]))) + (t/is (= ["missing"] (get-in resolved ["a" :errors 0 :error/value]))) + (t/is (= :error.style-dictionary/missing-reference (get-in resolved ["b" :errors 0 :error/code])) + "dependents report the missing token") + (t/is (= ["missing"] (get-in resolved ["b" :errors 0 :error/value]))) + (t/is (= :error.token/circular-reference (get-in resolved ["x" :errors 0 :error/code]))) + (t/is (= :error.token/circular-reference (get-in resolved ["y" :errors 0 :error/code]))) + (t/is (= :error.token/circular-reference (get-in resolved ["z" :errors 0 :error/code])) + "dependents of a cycle report the cycle") + (t/is (= :error.style-dictionary/invalid-token-value (get-in resolved ["bad" :errors 0 :error/code]))))) + +(t/deftest resolve-tokens-error-codes-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "zero" :value "1 / 0" :type :number} + {:name "mod-zero" :value "mod(4, 0)" :type :number} + {:name "units" :value "1px * 2em" :type :sizing} + {:name "fn" :value "foo(1)" :type :number} + {:name "ln" :value "ln(0)" :type :number} + {:name "ref" :value "{zero} + 1" :type :number})) + error (fn [name] (select-keys (get-in resolved [name :errors 0]) [:error/code :error/value]))] + (t/is (= {:error/code :error.token/division-by-zero :error/value nil} (error "zero"))) + (t/is (= {:error/code :error.token/division-by-zero :error/value nil} (error "mod-zero"))) + (t/is (= {:error/code :error.token/incompatible-units :error/value ["px" "em"]} (error "units"))) + (t/is (= {:error/code :error.token/unknown-function :error/value "foo"} (error "fn"))) + (t/is (= {:error/code :error.token/function-argument-out-of-range :error/value "ln"} (error "ln"))) + (t/is (= {:error/code :error.style-dictionary/invalid-token-value :error/value "{zero} + 1"} (error "ref")) + "a token failing through its reference is an invalid value"))) + +;; A token named "a" and a token named "a.b" can coexist: `{a}` resolves +;; to the token, `{a.b}` to the nested one (#9584). +(t/deftest resolve-tokens-name-collision-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "a" :value "8px" :type :border-radius} + {:name "a.b" :value "12px" :type :border-radius} + {:name "c" :value "{a} + {a.b}" :type :border-radius}))] + (t/is (= 8 (get-in resolved ["a" :resolved-value]))) + (t/is (= 12 (get-in resolved ["a.b" :resolved-value]))) + (t/is (= 20 (get-in resolved ["c" :resolved-value]))))) + +(t/deftest resolve-tokens-composites-test + (t/testing "typography and shadow tokens resolve field by field" + (let [resolved (ts/resolve-tokens + (tokens-map {:name "size" :value "16" :type :font-size} + {:name "typo" :type :typography + :value {:font-family ["Roboto" "Arial"] + :font-size "{size} * 2" + :font-weight "700 Italic" + :line-height "48px"}} + {:name "shadow" :type :shadow + :value [{:offset-x "2" :offset-y "{size}" :blur "4" :spread "0" + :color "#000" :inset false}]}))] + (t/is (= {:font-family ["Roboto" "Arial"] :font-size 32 :font-weight "700 Italic" :line-height 1.5} + (get-in resolved ["typo" :resolved-value])) + "line heights in pixels become a ratio of the font size") + (t/is (= [{:offset-x 2 :offset-y 16 :blur 4 :spread 0 :color "#000" :inset false}] + (get-in resolved ["shadow" :resolved-value]))))) + (t/testing "font families referencing a font family token are flattened" + (let [resolved (ts/resolve-tokens + (tokens-map {:name "families" :value ["Arial" "sans-serif"] :type :font-family} + {:name "single" :value "Roboto" :type :font-family} + {:name "typo" :value {:font-family ["{families}"]} :type :typography}))] + (t/is (= ["Arial" "sans-serif"] (get-in resolved ["families" :resolved-value]))) + (t/is (= ["Roboto"] (get-in resolved ["single" :resolved-value]))) + (t/is (= ["Arial" "sans-serif"] (get-in resolved ["typo" :resolved-value :font-family]))))) + (t/testing "a typography token with an array value is invalid" + (let [resolved (ts/resolve-tokens (tokens-map {:name "typography.bad" :value ["Roboto"] :type :typography}))] + (t/is (nil? (get-in resolved ["typography.bad" :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value-typography + (get-in resolved ["typography.bad" :errors 0 :error/code])))))) + +(t/deftest resolve-tokens-value-validation-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "opacity.bad" :value "2" :type :opacity} + {:name "stroke.bad" :value "-1" :type :stroke-width} + {:name "case.ok" :value "uppercase" :type :text-case} + {:name "case.bad" :value "wat" :type :text-case} + {:name "weight.ok" :value "bold" :type :font-weight} + {:name "weight.bad" :value "heavyish" :type :font-weight}))] + (t/is (= :error.style-dictionary/invalid-token-value-opacity (get-in resolved ["opacity.bad" :errors 0 :error/code]))) + (t/is (= :error.style-dictionary/invalid-token-value-stroke-width (get-in resolved ["stroke.bad" :errors 0 :error/code]))) + (t/is (= "uppercase" (get-in resolved ["case.ok" :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value-text-case (get-in resolved ["case.bad" :errors 0 :error/code]))) + (t/is (= "bold" (get-in resolved ["weight.ok" :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value-font-weight (get-in resolved ["weight.bad" :errors 0 :error/code]))))) + +;; The interpreter validates the kind of value of each token type: `1foo` +;; and `1 2` are lists, not numbers. +(t/deftest resolve-tokens-type-validation-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "size.glued" :value "1foo" :type :dimensions} + {:name "size.list" :value "1 2" :type :sizing} + {:name "size.ok" :value "1rem" :type :sizing} + {:name "number.glued" :value "1foo" :type :number} + {:name "number.unit" :value "10px" :type :number} + {:name "number.ok" :value "1.5" :type :number} + {:name "opacity.percent" :value "150%" :type :opacity} + {:name "typography.bad" :value {:font-size "1foo" :line-height "1.5"} :type :typography} + {:name "shadow.bad" :value "8" :type :shadow})) + code (fn [name] (get-in resolved [name :errors 0 :error/code]))] + (doseq [name ["size.glued" "size.list" "number.glued"]] + (t/is (nil? (get-in resolved [name :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value (code name)))) + (t/is (= "1foo" (get-in resolved ["size.glued" :errors 0 :error/value]))) + (t/is (= :error.style-dictionary/value-with-units (code "number.unit"))) + (t/is (= :error.style-dictionary/invalid-token-value-opacity (code "opacity.percent"))) + (t/is (= :error.style-dictionary/invalid-token-value-typography (code "typography.bad"))) + (t/is (= :error.style-dictionary/invalid-token-value-shadow (code "shadow.bad"))) + (t/is (= 16 (get-in resolved ["size.ok" :resolved-value]))) + (t/is (= 1.5 (get-in resolved ["number.ok" :resolved-value]))))) + +;; A token with a `nil` value (e.g. a composite typography token saved via +;; the workspace form with no fields filled in) is tagged with an empty +;; input error and does not affect the other tokens. +(t/deftest resolve-tokens-nil-value-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "typography.empty" :value nil :type :typography} + {:name "borderRadius.sm" :value "12px" :type :border-radius}))] + (t/is (nil? (get-in resolved ["typography.empty" :resolved-value]))) + (t/is (= :error.token/empty-input (get-in resolved ["typography.empty" :errors 0 :error/code]))) + (t/is (= 12 (get-in resolved ["borderRadius.sm" :resolved-value]))))) + +(t/deftest resolve-token-test + (let [tokens (tokens-map {:name "a" :value "8px" :type :border-radius}) + resolved (ts/resolve-token tokens {:name "b" :value "{a} * 2" :type :border-radius})] + (t/is (= 16 (:resolved-value resolved))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 040339f8ef..5c22390bbd 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1805,6 +1805,10 @@ msgstr "Token has circular reference" msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "Line Height depends on Font Size. Add a Font Size to get the resolved value." +#: src/app/main/data/workspace/tokens/errors.cljs:62 +msgid "errors.tokens.division-by-zero" +msgstr "Invalid token value: division by zero" + #: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:350, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:436 msgid "errors.tokens.empty-field" msgstr "This field cannot be empty" @@ -1817,10 +1821,18 @@ msgstr "Token value cannot be empty" msgid "errors.tokens.error-parse" msgstr "Import Error: Could not parse JSON." +#: src/app/main/data/workspace/tokens/errors.cljs:74 +msgid "errors.tokens.function-argument-out-of-range" +msgstr "Invalid token value: the argument of %s() is out of range" + #: src/app/main/data/workspace/tokens/errors.cljs:33, src/app/main/data/workspace/tokens/errors.cljs:38 msgid "errors.tokens.import-error" msgstr "Import Error:" +#: src/app/main/data/workspace/tokens/errors.cljs:66 +msgid "errors.tokens.incompatible-units" +msgstr "Invalid token value: the units %s cannot be combined" + #: src/app/main/data/workspace/tokens/errors.cljs:54 msgid "errors.tokens.invalid-color" msgstr "Invalid color value: %s" @@ -1914,6 +1926,10 @@ msgstr "Shadow spread must be greater than or equal to 0." msgid "errors.tokens.stroke-width-range" msgstr "Stroke width must be greater than or equal to 0." +#: src/app/main/data/workspace/tokens/errors.cljs:70 +msgid "errors.tokens.unknown-function" +msgstr "Invalid token value: unknown function %s" + #: src/app/main/data/workspace/tokens/errors.cljs:84, ../common/src/app/common/files/tokens.cljc:52 msgid "errors.tokens.value-with-percent" msgstr "Invalid value: % is not allowed." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index e369ed0b12..4900d82796 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1837,6 +1837,10 @@ msgstr "" "El Line Height depende del Font Size. Añade un Font Size para obtener el " "valor computado." +#: src/app/main/data/workspace/tokens/errors.cljs:62 +msgid "errors.tokens.division-by-zero" +msgstr "Valor de token no válido: división por cero" + #: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:350, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:436 msgid "errors.tokens.empty-field" msgstr "Este campo no puede estar vacío" @@ -1849,10 +1853,18 @@ msgstr "El valor del token no puede estar vacío" msgid "errors.tokens.error-parse" msgstr "Error al importar: No se pudo procesar el JSON." +#: src/app/main/data/workspace/tokens/errors.cljs:74 +msgid "errors.tokens.function-argument-out-of-range" +msgstr "Valor de token no válido: el argumento de %s() está fuera de rango" + #: src/app/main/data/workspace/tokens/errors.cljs:33, src/app/main/data/workspace/tokens/errors.cljs:38 msgid "errors.tokens.import-error" msgstr "Error al importar:" +#: src/app/main/data/workspace/tokens/errors.cljs:66 +msgid "errors.tokens.incompatible-units" +msgstr "Valor de token no válido: las unidades %s no se pueden combinar" + #: src/app/main/data/workspace/tokens/errors.cljs:54 msgid "errors.tokens.invalid-color" msgstr "Valor de color inválido: %s" @@ -1947,6 +1959,10 @@ msgstr "La extensión (spread) de la sombra debe ser mayor o igual a 0." msgid "errors.tokens.stroke-width-range" msgstr "Stroke width debe ser mayor o igual a 0." +#: src/app/main/data/workspace/tokens/errors.cljs:70 +msgid "errors.tokens.unknown-function" +msgstr "Valor de token no válido: función desconocida %s" + #: src/app/main/data/workspace/tokens/errors.cljs:84, ../common/src/app/common/files/tokens.cljc:52 msgid "errors.tokens.value-with-percent" msgstr "Valor no válido: no se permite %." From 6508449fa0e0731ba3f395afd524d25a663556b3 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 23 Oct 2026 09:55:23 +0200 Subject: [PATCH 126/167] :bug: Rebuild text layout when image fills finish loading (#12564) Text image shaders are baked into Skia paragraphs at layout time. touch_shapes_with_image only scanned shape.fills/strokes, so text spans were never invalidated after store_image. Detect text content image fills and force a layout rebuild before touching tiles. Closes #12563 --- render-wasm/src/wasm/fills/image.rs | 63 +++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index e6b2e634c5..72befd5e55 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -1,30 +1,67 @@ use crate::error::{Error, Result}; use crate::get_resources; use crate::mem; -use crate::shapes::Fill; +use crate::shapes::{Fill, Shape, TextContent, Type}; use crate::state::State; use crate::uuid::Uuid; use crate::with_state; use crate::{shapes::ImageFill, utils::uuid_from_u32_quartet}; use macros::wasm_error; +fn text_content_uses_image(content: &TextContent, image_id: Uuid) -> bool { + content.paragraphs().iter().any(|paragraph| { + paragraph.children().iter().any(|span| { + span.fills + .iter() + .any(|f| matches!(f, Fill::Image(i) if i.id() == image_id)) + }) + }) +} + +fn shape_uses_image(shape: &Shape, image_id: Uuid) -> bool { + if shape + .fills() + .any(|f| matches!(f, Fill::Image(i) if i.id() == image_id)) + || shape + .strokes + .iter() + .any(|s| matches!(&s.fill, Fill::Image(i) if i.id() == image_id)) + { + return true; + } + match &shape.shape_type { + Type::Text(content) => text_content_uses_image(content, image_id), + _ => false, + } +} + +fn rebuild_text_layout_for_image(shape: &mut Shape) { + let Type::Text(text_content) = &mut shape.shape_type else { + return; + }; + // Image shaders are baked into Skia paragraphs at layout time. When the + // media arrives after the first layout, force a rebuild so text fills + // pick up `get_image` (rect fills look up the image every paint). + text_content.force_next_layout_update(); + text_content.update_layout(shape.selrect); + shape.apply_deferred_batch_paint(); + shape.invalidate_extrect(); +} + fn touch_shapes_with_image(state: &mut State, image_id: Uuid) { - let ids: Vec = state + let ids: Vec<(Uuid, bool)> = state .shapes .iter() - .filter(|shape| { - shape - .fills() - .any(|f| matches!(f, Fill::Image(i) if i.id() == image_id)) - || shape - .strokes - .iter() - .any(|s| matches!(&s.fill, Fill::Image(i) if i.id() == image_id)) - }) - .map(|shape| shape.id) + .filter(|shape| shape_uses_image(shape, image_id)) + .map(|shape| (shape.id, matches!(shape.shape_type, Type::Text(_)))) .collect(); - for id in ids { + for (id, is_text) in ids { + if is_text { + if let Some(shape) = state.shapes.get_mut(&id) { + rebuild_text_layout_for_image(shape); + } + } state.touch_shape(id); } } From e27c65c77a9430891469b462472483bddc8b62b3 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 23 Oct 2026 11:02:23 +0200 Subject: [PATCH 127/167] :sparkles: Run WASM asset exports locally in the browser (#12515) When render-wasm is active, shape exports (single file, ZIP, and Export boards multi-page PDF) run on the shared worker WASM module so the UI stays responsive. A progressive PDF session (begin/add-page/finish) updates progress per page; cancel settles on the client. The server exporter path remains for the SVG renderer. --- .../src/app/main/data/exports/assets.cljs | 377 ++++++++++++------ frontend/src/app/main/data/exports/wasm.cljs | 88 +++- frontend/src/app/main/ui/exports/assets.cljs | 13 +- frontend/src/app/render_wasm/api.cljs | 114 +++++- frontend/src/app/worker.cljs | 1 + frontend/src/app/worker/exports.cljs | 260 ++++++++++++ frontend/src/app/worker/messages.cljs | 70 +++- frontend/src/app/worker/thumbnails.cljs | 44 +- frontend/src/app/worker/wasm.cljs | 87 ++++ .../data/exports_assets_test.cljs | 253 +++++++++++- frontend/test/frontend_tests/runner.cljs | 2 + .../frontend_tests/worker/exports_test.cljs | 70 ++++ render-wasm/src/main.rs | 63 +++ render-wasm/src/render/pdf.rs | 311 +++++++++++++-- render-wasm/src/render/pdf/glyph_text.rs | 19 + render-wasm/src/state.rs | 23 ++ 16 files changed, 1554 insertions(+), 241 deletions(-) create mode 100644 frontend/src/app/worker/exports.cljs create mode 100644 frontend/src/app/worker/wasm.cljs create mode 100644 frontend/test/frontend_tests/worker/exports_test.cljs diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 5ca4f3e983..e85c1c3cd3 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -6,10 +6,10 @@ (ns app.main.data.exports.assets (:require + [app.common.files.helpers :as cfh] [app.common.time :as ct] [app.common.uuid :as uuid] [app.main.data.event :as ev] - [app.main.data.exports.wasm :as wasm.exports] [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] @@ -18,9 +18,11 @@ [app.main.features :as features] [app.main.repo :as rp] [app.main.store :as st] + [app.main.worker :as mw] [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] [app.util.i18n :refer [tr]] + [app.util.webapi :as wapi] [app.util.websocket :as ws] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -236,17 +238,22 @@ (update [_ state] (let [time-diff (ct/diff-ms (get-in state [:export :last-update]) (ct/now)) healthy? (< time-diff 6000) - ;; The legacy path has no server-side figures to track; it keeps - ;; reporting progress over the client's own list. - job? (some? (get-in state [:export :job-id]))] + ;; Local WASM jobs also store a client job-id so the widget can + ;; show cancelling/cancelled (it keys those labels on job-id). + job? (some? (get-in state [:export :job-id])) + current (get-in state [:export :status]) + ;; Cancel settles the widget immediately; a late worker + ;; `:progress` must not flip status back to "running". + terminal? (contains? #{"cancelled" "cancelling" "ended" "error"} current) + apply? (or (not= status "running") (not terminal?))] (cond-> state - job? + (and job? apply?) (update :export assoc :status status) - (and job? (some? total)) + (and job? apply? (some? total)) (update :export assoc :total total) - (= status "running") + (and apply? (= status "running")) (update :export assoc :progress done :last-update (ct/now) :healthy? healthy?) (= status "error") @@ -266,24 +273,37 @@ (when (= status "ended") (dom/trigger-download-uri filename mtype resource-uri))))) -(defn cancel-export - "Stops the running export. Only reachable on the job API path, where the - exporter can actually abort the work. +(def ^:private local-export-job-id* + "Job id of the in-flight worker multi-export, if any." + (atom nil)) - The widget settles from here rather than from the job's `cancelled` message: - the outcome is known once the request returns, and waiting on a round trip - through redis and the websocket would leave it stuck whenever that message is - missed." +(defn cancel-export + "Stops the running export. On the job API path the exporter aborts; on the + local WASM path the worker is told to stop and the widget settles here. + + Local cancel settles on the client: if the worker is stuck before the first + cancel check, waiting for a `:cancelled` reply would leave the UI hung." [] (ptk/reify ::cancel-export ptk/WatchEvent (watch [_ state _] - (when-let [job-id (get-in state [:export :job-id])] - (let [resource-id (get-in state [:export :resource-id]) - settle (rx/concat - (rx/of (update-export-status {:status "cancelled"})) - (->> (rx/of (clear-export-state resource-id)) - (rx/delay default-timeout)))] + (let [backend (get-in state [:export :backend]) + job-id (get-in state [:export :job-id]) + resource-id (get-in state [:export :resource-id]) + settle (rx/concat + (rx/of (update-export-status {:status "cancelled"})) + (->> (rx/of (clear-export-state resource-id)) + (rx/delay default-timeout)))] + (cond + (= "local" backend) + (do + (when-let [local-job-id @local-export-job-id*] + (mw/emit! {:cmd :exports/cancel :job-id local-job-id})) + ;; Mark cancelled so a late `:done` from the worker is ignored. + (reset! local-export-job-id* nil) + settle) + + (some? job-id) (rx/concat ;; Stopping is not instantaneous: the request has to reach the ;; exporter and the work has to unwind. @@ -302,6 +322,13 @@ (features/active-feature? state "render-wasm/v1")) (defn- export-target + "Where a single-shape export runs. + + With render-wasm off → `:exporter` (server). + With render-wasm on → never the server API: always `:client` when the + format is supported, except true `:too-large` (beyond raster limits — + the exporter would fail too). Oversized-for-GPU rasters still run + locally via the CPU path in `export-bytes`." [state {:keys [type object-id scale]}] (cond (not (and (wasm-export-enabled? state) @@ -311,18 +338,14 @@ (contains? #{:pdf :svg} type) :client + ;; Raster: only refuse when WASM reports true `:too-large`. The legacy + ;; `:exporter` code means “use CPU raster” — we do that locally. :else - (or (wasm.api/export-target object-id (or scale 1) type) :exporter))) + (if (= :too-large (wasm.api/export-target object-id (or scale 1) type)) + :too-large + :client))) -(defn- request-simple-export-wasm - [export] - (ptk/reify ::request-simple-export-wasm - ptk/EffectEvent - (effect [_ _ _] - (case (:type export) - :pdf (wasm.exports/export-pdf export) - :svg (wasm.exports/export-svg export) - (wasm.exports/export-image export))))) +(declare request-local-export-wasm) (defn request-simple-export [{:keys [export]}] @@ -340,7 +363,10 @@ (watch [_ state _] (case @target :client - (rx/of (request-simple-export-wasm export)) + (rx/of (request-local-export-wasm {:exports [export] + :cmd :export-shapes + :name (:name export) + :kind :single})) :too-large (rx/of (ntf/error (tr "errors.export-too-large"))) @@ -363,95 +389,218 @@ (rx/of (clear-export-state uuid/zero) (export-failed cause)))))))))))) -(defn request-multiple-export +(defn- export-file-page-ids + [state exports] + (let [file-id (or (:file-id (first exports)) (:current-file-id state)) + page-id (or (:page-id (first exports)) (:current-page-id state))] + [file-id page-id])) + +(defn collect-export-objects + "Live page objects needed for `exports`: each root plus its descendants. + Falls back to the full page when the closure is empty (missing ids)." + [state exports] + (let [[file-id page-id] (export-file-page-ids state exports) + objects (or (dsh/lookup-page-objects state file-id page-id) {}) + root-ids (into #{} (map :object-id) exports) + ids (into #{} + (mapcat #(cfh/get-children-ids-with-self objects %)) + root-ids) + selected (select-keys objects ids)] + (if (seq selected) + selected + objects))) + +(defn- export-page-background + [state exports] + (let [[file-id page-id] (export-file-page-ids state exports) + page (dsh/lookup-page state file-id page-id)] + (:background page))) + +(defn- worker-export-entries + "Payload for the worker: drop `:shape` and other UI-only keys." + [exports] + (mapv (fn [{:keys [object-id type scale name suffix]}] + {:object-id object-id + :type type + :scale (or scale 1) + :name name + :suffix suffix}) + exports)) + +(defn- request-local-export-wasm + "Client-side export on the shared worker WASM module: one file (`:single`), + a ZIP of shapes (`:zip`), or a multi-page boards PDF (`:pdf-frames`)." + [{:keys [exports cmd name kind] + :or {cmd :export-shapes}}] + (ptk/reify ::request-local-export-wasm + ptk/WatchEvent + (watch [_ state _] + (let [resource-id (uuid/next) + job-id (uuid/next) + total (count exports) + kind (or kind + (if (= cmd :export-frames) :pdf-frames :zip)) + objects (collect-export-objects state exports) + background (export-page-background state exports) + entries (worker-export-entries exports) + clear-later (->> (rx/of (clear-export-state resource-id)) + (rx/delay default-timeout))] + (reset! local-export-job-id* job-id) + (swap! st/ongoing-tasks conj :export) + + (rx/concat + (rx/of (initialize-export-status exports cmd + {:id resource-id} + {:job-id job-id + :total total + :status "running" + :backend "local"})) + (->> (mw/ask-many! + {:cmd :exports/run-local + :job-id job-id + :kind kind + :exports entries + :objects objects + :background background + :name name}) + (rx/mapcat + (fn [{:keys [type] :as message}] + ;; Cancel clears `local-export-job-id*` and settles the + ;; widget locally. Ignore every later worker message so a + ;; stray `:progress` cannot flip the UI back to "running". + (if (nil? @local-export-job-id*) + (rx/empty) + (case type + :progress + (rx/of (update-export-status {:status "running" + :done (:done message) + :total (:total message)})) + + :done + (let [uri (wapi/create-uri (:blob message))] + (rx/concat + (rx/of (update-export-status + {:status "ended" + :done total + :filename (:filename message) + :mtype (:mtype message) + :resource-uri uri})) + clear-later)) + + :cancelled + (rx/concat + (rx/of (update-export-status {:status "cancelled"})) + clear-later) + + :error + (rx/of (export-widget-failed + exports cmd + (ex-info (or (:hint message) "worker export failed") + (or (:data message) + {:type :internal + :code :worker-export-error})))) + + (rx/empty))))) + (rx/catch + (fn [cause] + (if (= :export-cancelled (:type (ex-data cause))) + (rx/concat + (rx/of (update-export-status {:status "cancelled"})) + clear-later) + (rx/of (export-widget-failed exports cmd cause))))) + (rx/finalize + (fn [] + (reset! local-export-job-id* nil) + (swap! st/ongoing-tasks disj :export))))))))) + +(defn- request-multiple-export-server [{:keys [exports cmd name] + :or {cmd :export-shapes}}] + (ptk/reify ::request-multiple-export-server + ptk/WatchEvent + (watch [_ state _] + (let [resource-id (volatile! nil) + ;; Emits when the export request fails. + failed-s (rx/subject) + profile-id (:profile-id state) + ws-conn (:ws-conn state) + params (cond-> + {:exports exports + :cmd cmd + :profile-id profile-id + :force-multiple true + :is-wasm (wasm-export-enabled? state)} + (some? name) + (assoc :name name)) + + progress-stream + (->> (ws/get-rcv-stream ws-conn) + (rx/filter ws/message-event?) + (rx/map :payload) + (rx/filter #(= :export-update (:type %))) + (rx/filter #(= @resource-id (:resource-id %))) + (rx/take-until failed-s) + (rx/share)) + + stopper + (rx/filter #(or (= "ended" (:status %)) + (= "error" (:status %)) + (= "cancelled" (:status %))) + progress-stream) + + file-ids + (export-file-ids exports)] + + (swap! st/ongoing-tasks conj :export) + + (rx/concat + (dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms) + (rx/of (warn-unsaved-changes file-ids)) + (rx/merge + ;; Updates the export state with each progress message, until + ;; shortly after the export ends or fails. + (->> progress-stream + (rx/map update-export-status) + (rx/take-until (rx/delay 500 stopper)) + (rx/finalize (fn [] + (swap! st/ongoing-tasks disj :export)))) + + ;; Clears the export state a while after the export ends or is + ;; cancelled. + (->> progress-stream + (rx/filter #(or (= "ended" (:status %)) + (= "cancelled" (:status %)))) + (rx/take 1) + (rx/delay default-timeout) + (rx/map #(clear-export-state @resource-id)) + (rx/take-until (rx/delay 6000 stopper))) + + ;; Launches the exportation process and stores the resource id + ;; locally. Goes last so a failure reaches the progress listeners + ;; above: the push to `failed-s` releases the leave-page warning + ;; even though no progress message will ever arrive, and nothing + ;; is raised to the global error handler, which would replace the + ;; workspace with an error page. + (->> (rp/cmd! :export params) + (rx/map (fn [{:keys [id] :as resource}] + (vreset! resource-id id) + (initialize-export-status exports cmd resource nil))) + (rx/catch (fn [cause] + (rx/push! failed-s cause) + (rx/of (export-widget-failed exports cmd cause))))))))))) + +(defn request-multiple-export + [{:keys [exports cmd] :or {cmd :export-shapes} :as params}] - (let [exports (normalize-exports exports)] + (let [exports (normalize-exports exports) + params (assoc params :exports exports :cmd cmd)] (ptk/reify ::request-multiple-export ptk/WatchEvent (watch [_ state _] - (let [resource-id (volatile! nil) - ;; Emits when the export request fails. - failed-s (rx/subject) - profile-id (:profile-id state) - ws-conn (:ws-conn state) - params (cond-> - {:exports exports - :cmd cmd - :profile-id profile-id - :force-multiple true - :is-wasm (wasm-export-enabled? state)} - (some? name) - (assoc :name name)) - - progress-stream - (->> (ws/get-rcv-stream ws-conn) - (rx/filter ws/message-event?) - (rx/map :payload) - (rx/filter #(= :export-update (:type %))) - (rx/filter #(= @resource-id (:resource-id %))) - (rx/take-until failed-s) - (rx/share)) - - stopper - (rx/filter #(or (= "ended" (:status %)) - (= "error" (:status %)) - (= "cancelled" (:status %))) - progress-stream) - - file-ids - (export-file-ids exports)] - - (swap! st/ongoing-tasks conj :export) - - (rx/concat - (dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms) - (rx/of (warn-unsaved-changes file-ids)) - (rx/merge - ;; Updates the export state with each progress message, until - ;; shortly after the export ends or fails. - (->> progress-stream - (rx/map update-export-status) - (rx/take-until (rx/delay 500 stopper)) - (rx/finalize (fn [] - (swap! st/ongoing-tasks disj :export)))) - - ;; Clears the export state a while after the export ends or is - ;; cancelled. - (->> progress-stream - (rx/filter #(or (= "ended" (:status %)) - (= "cancelled" (:status %)))) - (rx/take 1) - (rx/delay default-timeout) - (rx/map #(clear-export-state @resource-id)) - (rx/take-until (rx/delay 6000 stopper))) - - ;; Launches the exportation process and stores the resource id - ;; locally. With wasm export active the job API is used instead: - ;; it answers with the exporter's own object count and gives a - ;; handle to cancel. It goes last so a failure reaches the - ;; progress listeners above: the push to `failed-s` releases the - ;; leave-page warning even though no progress message will ever - ;; arrive, and nothing is raised to the global error handler, - ;; which would replace the workspace with an error page. - (->> (if (wasm-export-enabled? state) - (->> (rp/cmd! :create-export-job params) - (rx/map (fn [{job-id :id :keys [total] :as job}] - (vreset! resource-id (:resource-id job)) - (initialize-export-status exports cmd - {:id (:resource-id job)} - {:job-id job-id - :total total - :status (:state job) - :backend (:backend job)})))) - (->> (rp/cmd! :export params) - (rx/map (fn [{:keys [id] :as resource}] - (vreset! resource-id id) - (initialize-export-status exports cmd resource nil))))) - (rx/catch (fn [cause] - (rx/push! failed-s cause) - (rx/of (export-widget-failed exports cmd cause)))))))))))) + (if (wasm-export-enabled? state) + (rx/of (request-local-export-wasm params)) + (rx/of (request-multiple-export-server params))))))) (defn request-export [{:keys [exports] :as params}] diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index 696b8ed716..b23556474a 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -9,51 +9,113 @@ [app.common.media :refer [format->mtype]] [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] - [app.util.webapi :as wapi])) + [app.util.webapi :as wapi] + [cuerdas.core :as str])) + +;; Regex shared with the exporter so ZIP entry names stay compatible. +(def ^:private sanitize-file-regex #"[\\/:*?\"<>|]") + +(defn export-extension + [type] + (case type + :png ".png" + :jpeg ".jpg" + :webp ".webp" + :svg ".svg" + :pdf ".pdf" + :zip ".zip" + "")) + +(defn sanitize-filename + "Exporter-compatible filename: strip reserved chars, then name+suffix+ext + with numeric de-dupe against `used`." + [{:keys [name suffix type]} used] + (let [base (-> (str (or name "") (or suffix "")) + (str/replace sanitize-file-regex "_"))] + (loop [index 0] + (let [candidate (str base + (when (pos? index) + (str "-" (inc index))) + (export-extension type))] + (if (contains? used candidate) + (recur (inc index)) + candidate))))) + +(defn export-bytes + "Encoded bytes for one export entry. Returns nil when WASM is not ready. + Used by the worker local export path (single / ZIP) and by main-thread + helpers (plugins, clipboard) that still need a sync URI. + + Raster formats use the GPU surface when it fits; otherwise the CPU + raster path (same as the headless exporter) so oversized shapes still + export locally without calling the server API." + [{:keys [type scale object-id] :as _export}] + (let [scale (or scale 1)] + (case type + :pdf (wasm.api/render-shape-pdf object-id) + :svg (wasm.api/render-shape-svg object-id scale) + (if (= :client (wasm.api/export-target object-id scale type)) + (wasm.api/render-shape-pixels object-id scale type) + (wasm.api/render-shape-raster object-id scale type))))) (defn export-image-uri - [{:keys [type scale object-id]}] + [{:keys [type] :as params}] ;; The export type doubles as the encoder format, so the bytes always match ;; the mtype we wrap them in. `:svg` never reaches here — it needs vector ;; markup and `exports.assets/wasm-export-types` keeps it off this path. - (let [bytes (wasm.api/render-shape-pixels object-id scale type) + (let [bytes (export-bytes params) mtype (format->mtype type) - blob (wapi/create-blob bytes mtype)] + blob (wapi/create-blob bytes mtype)] (wapi/create-uri blob))) (defn export-image [{:keys [type name] :as params}] - (let [url (export-image-uri params) + (let [url (export-image-uri params) mtype (format->mtype type)] (dom/trigger-download-uri name mtype url) (wapi/revoke-uri url) nil)) (defn export-pdf-uri - [{:keys [object-id]}] - (let [bytes (wasm.api/render-shape-pdf object-id) - blob (wapi/create-blob bytes "application/pdf")] + [params] + (let [bytes (export-bytes (assoc params :type :pdf)) + blob (wapi/create-blob bytes "application/pdf")] (wapi/create-uri blob))) (defn export-pdf [{:keys [name] :as params}] - (let [url (export-pdf-uri params) + (let [url (export-pdf-uri params) filename (str name ".pdf")] (dom/trigger-download-uri filename "application/pdf" url) (js/queueMicrotask #(wapi/revoke-uri url)) nil)) (defn export-svg-uri - [{:keys [scale object-id]}] - (let [bytes (wasm.api/render-shape-svg object-id (or scale 1)) - blob (wapi/create-blob bytes "image/svg+xml")] + [params] + (let [bytes (export-bytes (assoc params :type :svg)) + blob (wapi/create-blob bytes "image/svg+xml")] (wapi/create-uri blob))) (defn export-svg [{:keys [name] :as params}] - (let [url (export-svg-uri params) + (let [url (export-svg-uri params) filename (str name ".svg")] (dom/trigger-download-uri filename "image/svg+xml" url) (js/queueMicrotask #(wapi/revoke-uri url)) nil)) +(defn export-frames-pdf-uri + "One multi-page PDF for the given object ids (page order = id order). + Synchronous main-thread helper; multi-export prefers the worker session." + [{:keys [object-ids]}] + (let [bytes (wasm.api/render-shapes-pdf object-ids) + blob (wapi/create-blob bytes "application/pdf")] + (wapi/create-uri blob))) + +(defn export-frames-pdf + [{:keys [name] :as params}] + (let [url (export-frames-pdf-uri params) + filename (str (or name "export") ".pdf")] + (dom/trigger-download-uri filename "application/pdf" url) + (js/queueMicrotask #(wapi/revoke-uri url)) + nil)) diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index f1be8e32a2..83140cb47c 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -231,12 +231,13 @@ queued? (and (some? job-id) (= "queued" status)) cancelling? (and (some? job-id) (= "cancelling" status)) cancelled? (and (some? job-id) (= "cancelled" status)) - ;; Only the wasm backend can actually stop: a browser render holds its - ;; pool slot until playwright gives up. - cancellable? (and (some? job-id) - (= "wasm" (:backend state)) - (:in-progress state) - (not cancelling?)) + ;; Server wasm jobs and local client-side exports can stop; a non-wasm + ;; browser render holds its pool slot until playwright gives up. + cancellable? (and (:in-progress state) + (not cancelling?) + (or (= "local" (:backend state)) + (and (some? job-id) + (= "wasm" (:backend state))))) total (or (:total state) (count items)) complete? (= progress total) circ (* 2 Math/PI 12) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index b31729caaa..f5af3275dd 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2899,29 +2899,47 @@ (when-let [snapshot wasm/canvas-snapshot] (reset! transition-image* snapshot))))) +(defn- read-raster-result + "Decode `[len][width][height][bytes]` from a `_render_shape_*` raster call." + [offset] + (let [heap (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 offset)) + result (dr/read-image-bytes heap (+ offset 12) length)] + (mem/free) + result)) + (defn render-shape-pixels - "Renders a shape subtree to encoded image bytes. `format` is :png, :jpeg or - :webp; jpeg is flattened onto white on the Rust side, since it has no alpha - channel." + "Renders a shape subtree to encoded image bytes on the GPU export surface. + `format` is :png, :jpeg or :webp; jpeg is flattened onto white on the Rust + side, since it has no alpha channel." [shape-id scale format] (when (initialized?) (let [buffer (uuid/get-u32 shape-id) + offset (h/call wasm/internal-module "_render_shape_pixels" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale + (sr/translate-raster-format format))] + (read-raster-result offset)))) - offset - (h/call wasm/internal-module "_render_shape_pixels" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - scale - (sr/translate-raster-format format)) - - heap (mem/get-heap-u8) - heapu32 (mem/get-heap-u32) - length (aget heapu32 (mem/->offset-32 offset)) - result (dr/read-image-bytes heap (+ offset 12) length)] - (mem/free) - result))) +(defn render-shape-raster + "Same output as `render-shape-pixels`, but on a CPU Skia surface (no + GPU/WebGL). Used when the export exceeds the GPU max texture size — the + same path the headless exporter uses for wasm rasters." + [shape-id scale format] + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id) + offset (h/call wasm/internal-module "_render_shape_raster" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale + (sr/translate-raster-format format))] + (read-raster-result offset)))) (defn get-shape-extrect [shape-id] @@ -2976,6 +2994,66 @@ (mem/free) result))) +(defn render-shapes-pdf + "Renders each shape id as a page of one PDF. IDs are written to the + transfer buffer (same layout as `set-focus-mode`) before the call." + [shape-ids] + (when (and (initialized?) (seq shape-ids)) + (let [ids (vec shape-ids) + size (mem/get-alloc-size ids UUID-U8-SIZE) + heap (mem/get-heap-u32) + offset (mem/alloc->offset-32 size) + + _ (reduce (fn [offset id] + (mem.h32/write-uuid offset heap id)) + offset + ids) + + result-offset + (h/call wasm/internal-module "_render_shapes_pdf") + + heapu8 (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 result-offset)) + result (dr/read-image-bytes heapu8 (+ result-offset 4) length)] + (mem/free) + result))) + +(defn pdf-export-begin! + "Opens a progressive multi-page PDF session." + [] + (when (initialized?) + (h/call wasm/internal-module "_pdf_export_begin"))) + +(defn pdf-export-add-page! + "Appends `shape-id` as the next page of the current PDF session." + [shape-id] + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id)] + (h/call wasm/internal-module "_pdf_export_add_page" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3))))) + +(defn pdf-export-finish + "Closes the PDF session and returns the encoded PDF bytes." + [] + (when (initialized?) + (let [offset (h/call wasm/internal-module "_pdf_export_finish") + heapu8 (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 offset)) + result (dr/read-image-bytes heapu8 (+ offset 4) length)] + (mem/free) + result))) + +(defn pdf-export-abort! + "Drops an in-flight PDF session without returning bytes." + [] + (when (initialized?) + (h/call wasm/internal-module "_pdf_export_abort"))) + (defn render-shape-svg [shape-id scale] (when (initialized?) diff --git a/frontend/src/app/worker.cljs b/frontend/src/app/worker.cljs index 860abcff27..5df69ac365 100644 --- a/frontend/src/app/worker.cljs +++ b/frontend/src/app/worker.cljs @@ -11,6 +11,7 @@ [app.common.schema :as sm] [app.common.types.objects-map] [app.util.object :as obj] + [app.worker.exports] [app.worker.impl :as impl] [app.worker.index] [app.worker.messages :as wm] diff --git a/frontend/src/app/worker/exports.cljs b/frontend/src/app/worker/exports.cljs new file mode 100644 index 0000000000..fa83b4ef29 --- /dev/null +++ b/frontend/src/app/worker/exports.cljs @@ -0,0 +1,260 @@ +;; 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.worker.exports + "Local WASM export (single file, ZIP, multi-page PDF) on the worker module. + + Runs through `app.worker.wasm/submit` so it never interleaves with + thumbnail WASM jobs on the same module." + (:require + [app.common.logging :as log] + [app.common.media :as media] + [app.common.render-wasm.wasm :as wasm] + [app.common.types.color :as cc] + [app.common.uuid :as uuid] + [app.main.data.exports.wasm :as wasm.exports] + [app.render-wasm.api :as wasm.api] + [app.util.webapi :as wapi] + [app.util.zip :as uz] + [app.worker.impl :as impl] + [app.worker.wasm :as wwasm] + [beicon.v2.core :as rx] + [promesa.core :as p])) + +(log/set-level! :info) + +(def ^:private ^:const canvas-size 100) + +(defn- export-cancelled + [] + (ex-info "export cancelled" {:type :export-cancelled})) + +(defn- export-mtype + [type] + (case type + :pdf "application/pdf" + (media/format->mtype type))) + +(defn- build-single + "One shape → one downloadable blob (PNG/JPEG/WebP/SVG/PDF)." + [exports job-id on-progress] + (let [export (first exports)] + (when-not export + (throw (ex-info "no export entry" + {:type :validation + :code :empty-export}))) + (when (wwasm/cancelled? job-id) + (throw (export-cancelled))) + (let [bytes (wasm.exports/export-bytes export)] + (when-not bytes + (throw (ex-info "wasm export returned no bytes" + {:type :internal + :code :wasm-export-empty + :object-id (:object-id export)}))) + (on-progress 1 1) + (p/resolved (wapi/create-blob bytes (export-mtype (:type export))))))) + +(defn- build-zip + [exports job-id on-progress] + (let [writer (uz/writer (uz/blob-writer :mtype "application/zip")) + used (volatile! #{}) + entries + (mapv (fn [export] + (let [filename (wasm.exports/sanitize-filename export @used)] + (vswap! used conj filename) + [filename export])) + exports) + total (count entries)] + (->> (p/reduce + (fn [writer [index [filename export]]] + (when (wwasm/cancelled? job-id) + (throw (export-cancelled))) + (let [bytes (wasm.exports/export-bytes export)] + (when-not bytes + (throw (ex-info "wasm export returned no bytes" + {:type :internal + :code :wasm-export-empty + :object-id (:object-id export)}))) + (->> (uz/add writer filename bytes) + (p/fmap (fn [_] + (on-progress (inc index) total) + writer))))) + writer + (map-indexed vector entries)) + (p/mcat (fn [writer] + (uz/close writer))) + (p/merr (fn [cause] + (try (uz/close writer) (catch :default _)) + (p/rejected cause)))))) + +(defn- build-frames-pdf + [exports job-id on-progress] + (let [ids (mapv :object-id exports) + total (count ids)] + (wasm.api/pdf-export-begin!) + (->> (p/reduce + (fn [_ [index id]] + (when (wwasm/cancelled? job-id) + (throw (export-cancelled))) + (wasm.api/pdf-export-add-page! id) + (on-progress (inc index) total) + ;; Yield so a cancel that arrived during add-page can stop + ;; before the next page (nil would run every page in one turn). + (p/resolved nil)) + nil + (map-indexed vector ids)) + (p/mcat + (fn [_] + (let [bytes (wasm.api/pdf-export-finish)] + (when-not bytes + (throw (ex-info "wasm pdf export returned no bytes" + {:type :internal + :code :wasm-export-empty}))) + (p/resolved (wapi/create-blob bytes "application/pdf"))))) + (p/merr + (fn [cause] + (wasm.api/pdf-export-abort!) + (p/rejected cause)))))) + +(defn- export-entry + "Keeps only the fields the worker needs to render and name files." + [{:keys [object-id type scale name suffix]}] + {:object-id object-id + :type type + :scale (or scale 1) + :name name + :suffix suffix}) + +(defn- run-local-export + [{:keys [job-id kind exports objects background] export-name :name}] + (let [exports (mapv export-entry exports)] + (->> (rx/from @wwasm/module) + (rx/mapcat + (fn [_] + (rx/create + (fn [subs] + (let [done? (volatile! false) + ;; Never `rx/error!` here: the main `ask-many!` stream would + ;; hang because worker error replies used to drop `:error` + ;; and never send `completed`. Emit a terminal message instead. + finish! + (fn [result cause] + (when-not @done? + (vreset! done? true) + (try (wasm.api/clear-canvas) (catch :default _)) + (wwasm/clear-cancel job-id) + (cond + (or (wwasm/cancelled? job-id) + (= :export-cancelled (:type (ex-data cause)))) + (do + (rx/push! subs {:type :cancelled}) + (rx/end! subs)) + + cause + (do + (rx/push! subs {:type :error + :hint (ex-message cause) + :data (ex-data cause)}) + (rx/end! subs)) + + :else + (let [entry (first exports) + filename (case kind + :pdf-frames + (str (or export-name "export") ".pdf") + + :single + (wasm.exports/sanitize-filename entry #{}) + + (str (or export-name "export") ".zip")) + mtype (case kind + :pdf-frames "application/pdf" + :single (export-mtype (:type entry)) + "application/zip")] + (rx/push! subs {:type :done + :filename filename + :mtype mtype + :blob result}) + (rx/end! subs))))) + + on-progress + (fn [done total] + (rx/push! subs {:type :progress + :done done + :total total})) + + start-export! + (fn [] + ;; `:on-render` runs after process-pending (images + + ;; fonts). `:on-shapes-ready` fires earlier and would + ;; export empty image fills / missing typefaces. + (when (wwasm/cancelled? job-id) + (finish! nil (export-cancelled))) + (when-not @done? + (try + (let [work (case kind + :pdf-frames + (build-frames-pdf exports job-id on-progress) + + :single + (build-single exports job-id on-progress) + + (build-zip exports job-id on-progress))] + (->> work + (p/fnly (fn [blob cause] + (finish! blob cause))))) + (catch :default cause + (finish! nil cause))))) + + canvas (js/OffscreenCanvas. canvas-size canvas-size) + init? (wasm.api/init-canvas-context canvas)] + (cond + (not init?) + (finish! nil (ex-info "failed to init worker wasm canvas" + {:type :internal + :code :wasm-canvas-init-failed})) + + (not (wasm/live?)) + (finish! nil (ex-info "worker wasm context is not live" + {:type :internal + :code :wasm-not-live})) + + (empty? objects) + (finish! nil (ex-info "no shapes to export" + {:type :validation + :code :empty-export-objects})) + + (wwasm/cancelled? job-id) + (finish! nil (export-cancelled)) + + :else + (try + (let [bgcolor (or background cc/canvas) + vbox {:x 0 :y 0 :width canvas-size :height canvas-size}] + (reset! wasm/disable-request-render? true) + (wasm.api/initialize-viewport + objects 1 vbox + :background bgcolor + :force-sync true + :on-render start-export!)) + (catch :default cause + (finish! nil cause)))) + (fn [] + (wwasm/cancel-job job-id) + (try (wasm.api/pdf-export-abort!) (catch :default _)) + (try (wasm.api/clear-canvas) (catch :default _))))))))))) + +(defmethod impl/handler :exports/run-local + [message _] + (let [job-id (or (:job-id message) (uuid/next)) + msg (assoc message :job-id job-id)] + (wwasm/submit #(run-local-export msg)))) + +(defmethod impl/handler :exports/cancel + [{:keys [job-id]} _] + (when job-id + (wwasm/cancel-job job-id)) + nil) diff --git a/frontend/src/app/worker/messages.cljs b/frontend/src/app/worker/messages.cljs index 2bb1c6a859..209deb37ef 100644 --- a/frontend/src/app/worker/messages.cljs +++ b/frontend/src/app/worker/messages.cljs @@ -11,26 +11,62 @@ [app.common.transit :as t] [app.util.object :as obj])) -(defn encode [{:keys [sender-id reply-to payload buffer?] :as message}] - #js {:cmd (d/name (:cmd payload)) - :senderId (when sender-id (str sender-id)) - :replyTo (when reply-to (str reply-to)) - :payload (if (= :initialize-indices (:cmd payload)) - (:file-raw payload) - (when (some? payload) (t/encode-str payload))) - :buffer (when (some? buffer?) buffer?)}) +(defn encode + "Serializes a worker message for `postMessage`. + + Transit cannot carry `js/Blob`. If the payload map has `:blob`, or the + message already has a top-level `:blob`, that value is sent as a + structured-clone field beside the Transit string — not inside it." + [{:keys [sender-id reply-to payload buffer? blob completed error]}] + (let [;; Peel `:blob` out of map payloads before Transit (selection/snap + ;; replies are vectors and other non-maps; leave them untouched). + inline-blob (when (map? payload) (:blob payload)) + blob (or blob inline-blob) + payload (cond-> payload + (some? inline-blob) + (dissoc :blob)) + obj #js {:cmd (d/name (:cmd payload)) + :senderId (when sender-id (str sender-id)) + :replyTo (when reply-to (str reply-to)) + :payload (if (= :initialize-indices (:cmd payload)) + (:file-raw payload) + (when (some? payload) (t/encode-str payload))) + :buffer (when (some? buffer?) buffer?) + :completed (when completed true)}] + (when (some? blob) + (obj/set! obj "blob" blob)) + (when (some? error) + (obj/set! obj "error" (clj->js error))) + obj)) + +(defn- js->error-map + [^js err] + (when err + (let [m (js->clj err :keywordize-keys true)] + (cond-> m + (string? (:data m)) + (assoc :data nil))))) (defn decode [^js data] (let [cmd (obj/get data "cmd") sender-id (obj/get data "senderId") reply-to (obj/get data "replyTo") payload (obj/get data "payload") - buffer (obj/get data "buffer")] - (d/without-nils - {:sender-id (when sender-id (uuid sender-id)) - :reply-to (when reply-to (uuid reply-to)) - :payload (if (= cmd "initialize-indices") - {:cmd :initialize-indices - :file-raw payload} - (when (some? payload) (t/decode-str payload))) - :buffer? buffer}))) + buffer (obj/get data "buffer") + blob (obj/get data "blob") + completed (obj/get data "completed") + error (obj/get data "error") + decoded + (d/without-nils + {:sender-id (when sender-id (uuid sender-id)) + :reply-to (when reply-to (uuid reply-to)) + :payload (if (= cmd "initialize-indices") + {:cmd :initialize-indices + :file-raw payload} + (when (some? payload) (t/decode-str payload))) + :buffer? buffer + :completed completed + :error (js->error-map error)})] + (cond-> decoded + (some? blob) + (update :payload assoc :blob blob)))) diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index e42a9687b4..074b23c400 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -14,13 +14,13 @@ [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as cc] [app.common.uri :as u] - [app.common.uuid :as uuid] [app.config :as cf] [app.main.fonts :as fonts] [app.main.render :as render] [app.render-wasm.api :as wasm.api] [app.util.http :as http] [app.worker.impl :as impl] + [app.worker.wasm :as wwasm] [beicon.v2.core :as rx] [okulary.core :as l] [promesa.core :as p] @@ -138,23 +138,12 @@ :height height :background background})))))) -(defonce ^:private wasm-module - (delay - (let [module (unchecked-get js/globalThis "WasmModule") - init-fn (unchecked-get module "default") - href (cf/resolve-href "js/render-wasm.wasm")] - (->> (init-fn #js {:locateFile (constantly href)}) - (p/fnly (fn [module cause] - (if cause - (js/console.error cause) - (set! wasm/internal-module module)))))))) - (defn- render-thumbnail-with-wasm - [{:keys [id file-id revn width] :as message}] - (->> (rx/from @wasm-module) + [{:keys [file-id revn width] :as _message}] + (->> (rx/from @wwasm/module) (rx/mapcat #(request-data-for-thumbnail file-id revn false)) (rx/mapcat - (fn [{:keys [page] :as file}] + (fn [{:keys [page] :as _file}] (rx/create (fn [subs] (let [bgcolor (or (:background page) cc/canvas) @@ -187,34 +176,13 @@ (if cause (rx/error! subs cause) (rx/push! subs - {:id id - :data data + {:data data :file-id file-id :revn revn})) (rx/end! subs))))))) (rx/end! subs)) nil))))))) -(defonce ^:private - thumbnails-queue - (rx/subject)) - -(defonce ^:private - thumbnails-stream - (->> thumbnails-queue - (rx/mapcat render-thumbnail-with-wasm) - (rx/share))) - (defmethod impl/handler :thumbnails/generate-for-file-wasm [message _] - (rx/create - (fn [subs] - (let [id (uuid/next) - sid (->> thumbnails-stream - (rx/filter #(= id (:id %))) - (rx/subs! - (fn [result] - (rx/push! subs result) - (rx/end! subs))))] - (rx/push! thumbnails-queue (assoc message :id id)) - #(rx/dispose! sid))))) + (wwasm/submit #(render-thumbnail-with-wasm message))) diff --git a/frontend/src/app/worker/wasm.cljs b/frontend/src/app/worker/wasm.cljs new file mode 100644 index 0000000000..eca6c47071 --- /dev/null +++ b/frontend/src/app/worker/wasm.cljs @@ -0,0 +1,87 @@ +;; 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.worker.wasm + "Shared render-wasm loader and serial work queue for the web worker. + + Thumbnails and local multi-export share one WASM module instance. Jobs that + touch that module (initialize-viewport, render, clear-canvas) must run through + `submit` so they never interleave." + (:require + [app.common.render-wasm.wasm :as wasm] + [app.config :as cf] + [beicon.v2.core :as rx] + [promesa.core :as p])) + +;; Delay that resolves to the worker's WasmModule instance. +(defonce module + (delay + (let [module (unchecked-get js/globalThis "WasmModule") + init-fn (unchecked-get module "default") + href (cf/resolve-href "js/render-wasm.wasm")] + (->> (init-fn #js {:locateFile (constantly href)}) + (p/fnly (fn [module cause] + (if cause + (js/console.error "worker wasm module init failed" cause) + (set! wasm/internal-module module)))))))) + +(defonce ^:private work-queue + (rx/subject)) + +(defonce ^:private cancelled-jobs* + (atom #{})) + +(defonce ^:private executor-sub* + (atom nil)) + +(defn- ensure-executor! + "Starts the serial drain once. Must be reachable from `submit` so release + builds cannot DCE the subscription (a lone unused `defonce` is removed)." + [] + (when (nil? @executor-sub*) + (reset! executor-sub* + (->> work-queue + (rx/mapcat + (fn [{:keys [bus work]}] + (->> (work) + (rx/tap (fn [value] + (rx/push! bus value))) + (rx/catch (fn [cause] + (rx/error! bus cause) + (rx/empty))) + (rx/finalize + (fn [] + (rx/end! bus)))))) + (rx/subs! + (fn [_]) + (fn [cause] + (js/console.error "worker wasm queue error" cause))))))) + +(defn cancel-job + "Marks `job-id` so an in-flight export stops between items." + [job-id] + (swap! cancelled-jobs* conj job-id) + nil) + +(defn cancelled? + [job-id] + (contains? @cancelled-jobs* job-id)) + +(defn clear-cancel + [job-id] + (swap! cancelled-jobs* disj job-id)) + +(defn submit + "Runs `work` (zero-arg → observable) on the serial WASM queue. + Returns a subject that mirrors `work` emissions and errors. + + Pushes onto the queue immediately (does not wait for a subscriber) so the + job starts even if the caller is slow to subscribe." + [work] + (ensure-executor!) + (let [bus (rx/subject)] + (rx/push! work-queue {:bus bus :work work}) + bus)) diff --git a/frontend/test/frontend_tests/data/exports_assets_test.cljs b/frontend/test/frontend_tests/data/exports_assets_test.cljs index a9c9393f25..4673b13411 100644 --- a/frontend/test/frontend_tests/data/exports_assets_test.cljs +++ b/frontend/test/frontend_tests/data/exports_assets_test.cljs @@ -8,12 +8,16 @@ (:require [app.common.uuid :as uuid] [app.main.data.exports.assets :as de] + [app.main.data.exports.wasm :as wasm.exports] [app.main.data.persistence :as dwp] [app.main.errors :as errors] [app.main.repo :as repo] [app.main.store :as st] + [app.main.worker :as mw] + [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] [app.util.i18n :as i18n] + [app.util.webapi :as wapi] [app.util.websocket :as ws] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] @@ -21,7 +25,6 @@ [frontend-tests.helpers.events :as the] [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) - (def ^:private export {:id (uuid/next) :object-id (uuid/next) :type :png @@ -37,6 +40,17 @@ {:profile-id (:id export) :ws-conn nil}) +(defn- wasm-state + [] + (assoc (test-state) :features #{"render-wasm/v1"})) + +(t/deftest sanitize-filename-strips-reserved-chars-and-dedupes + (t/is (= "a_b.png" + (wasm.exports/sanitize-filename {:name "a/b" :suffix "" :type :png} #{}))) + (t/is (= "board-2.pdf" + (wasm.exports/sanitize-filename {:name "board" :suffix "" :type :pdf} + #{"board.pdf"})))) + (t/deftest normalize-export-preserves-existing-name (t/is (= (export-with-name "Layer 1") (de/normalize-export (export-with-name "Layer 1"))))) @@ -203,3 +217,240 @@ (await (async/wait-for #(true? @downloaded) "the export runs")) (await (async/settle)) (t/is (nil? (:notification @store))))))) + +;; Scenario: WASM is on and the user exports one shape. Proves: the worker +;; builds a single blob (no ZIP); the exporter is not called. +;; Stub `export-target`: without a module it is nil; routing still stays +;; local (never the server API under wasm). +(t/deftest ^:async wasm-simple-export-stays-local + (let [cmd-calls (atom []) + worker-kind (atom nil) + downloaded (atom nil) + store (export-store (wasm-state) (atom [])) + page-id (uuid/next) + file-id (uuid/next) + one (assoc export :enabled true :name "layer" + :page-id page-id :file-id file-id)] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [cmd params] + (swap! cmd-calls conj [cmd params]) + (rx/throw (ex-info "exporter must not run" {})))) + wasm.api/export-target (mock/stub (constantly :client)) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [msg] + (reset! worker-kind (:kind msg)) + (rx/of {:type :done + :filename "layer.png" + :mtype "image/png" + :blob (js/Blob.)}))) + wapi/create-uri (mock/stub (constantly "blob:png")) + dom/trigger-download-uri (mock/stub (fn [filename mtype uri] + (reset! downloaded [filename mtype uri]))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-simple-export {:export one})) + (await (async/wait-for #(some? @downloaded) "the file downloads")) + (t/is (empty? @cmd-calls) "no exporter RPC") + (t/is (= :single @worker-kind)) + (t/is (= ["layer.png" "image/png" "blob:png"] @downloaded)))))) + +;; Scenario: WASM says the raster is too big for the GPU (legacy `:exporter` +;; code). Proves: still local worker download, no server RPC — CPU raster +;; runs inside the worker via `export-bytes`. +(t/deftest ^:async wasm-oversized-for-gpu-stays-local + (let [cmd-calls (atom []) + worker-kind (atom nil) + downloaded (atom nil) + store (export-store (wasm-state) (atom [])) + one (assoc export :enabled true :name "huge")] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [cmd params] + (swap! cmd-calls conj [cmd params]) + (rx/throw (ex-info "exporter must not run" {})))) + wasm.api/export-target (mock/stub (constantly :exporter)) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [msg] + (reset! worker-kind (:kind msg)) + (rx/of {:type :done + :filename "huge.png" + :mtype "image/png" + :blob (js/Blob.)}))) + wapi/create-uri (mock/stub (constantly "blob:png")) + dom/trigger-download-uri (mock/stub (fn [filename mtype uri] + (reset! downloaded [filename mtype uri]))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-simple-export {:export one})) + (await (async/wait-for #(some? @downloaded) "the file downloads")) + (t/is (empty? @cmd-calls) "no exporter RPC") + (t/is (= :single @worker-kind)) + (t/is (= ["huge.png" "image/png" "blob:png"] @downloaded)))))) + +;; Scenario: WASM reports true `:too-large` (beyond raster limits). Proves: +;; toast and no RPC — the exporter would fail the same way. +(t/deftest ^:async wasm-true-too-large-shows-toast + (let [cmd-calls (atom []) + store (export-store (wasm-state) (atom [])) + one (assoc export :enabled true :name "impossible")] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [cmd params] + (swap! cmd-calls conj [cmd params]) + (rx/throw (ex-info "exporter must not run" {})))) + wasm.api/export-target (mock/stub (constantly :too-large)) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (de/request-simple-export {:export one})) + (await (async/wait-for #(some? (:notification @store)) "the toast shows")) + (t/is (empty? @cmd-calls) "no exporter RPC") + (t/is (= "translated:errors.export-too-large" + (get-in @store [:notification :content]))) + (t/is (= :error (get-in @store [:notification :level]))))))) + +;; Scenario: WASM is on and the user exports several shapes. Proves: nothing +;; hits the exporter; the worker builds a ZIP and the client downloads it. +(t/deftest ^:async wasm-multiple-export-stays-local + (let [cmd-calls (atom []) + worker-cmd (atom nil) + downloaded (atom nil) + store (export-store (wasm-state) (atom [])) + page-id (uuid/next) + file-id (uuid/next) + exports [(assoc export :enabled true :name "a" :page-id page-id :file-id file-id) + (assoc export :id (uuid/next) + :object-id (uuid/next) + :page-id page-id + :file-id file-id + :enabled true + :name "b")]] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [cmd params] + (swap! cmd-calls conj [cmd params]) + (rx/throw (ex-info "exporter must not run" {})))) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [msg] + (reset! worker-cmd (:cmd msg)) + (rx/of {:type :done + :filename "page.zip" + :mtype "application/zip" + :blob (js/Blob.)}))) + wapi/create-uri (mock/stub (constantly "blob:zip")) + dom/trigger-download-uri (mock/stub (fn [filename mtype uri] + (reset! downloaded [filename mtype uri]))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-multiple-export {:exports exports :name "page"})) + (await (async/wait-for #(some? @downloaded) "the zip downloads")) + (t/is (empty? @cmd-calls) "no exporter RPC") + (t/is (= :exports/run-local @worker-cmd)) + (t/is (= ["page.zip" "application/zip" "blob:zip"] @downloaded)))))) + +;; Scenario: WASM is on and the user exports boards as PDF. Proves: worker +;; multi-page PDF, no exporter call. +(t/deftest ^:async wasm-export-frames-stays-local + (let [cmd-calls (atom []) + worker-kind (atom nil) + downloaded (atom nil) + store (export-store (wasm-state) (atom [])) + page-id (uuid/next) + file-id (uuid/next) + exports [(assoc export :enabled true :name "board-1" + :page-id page-id :file-id file-id) + (assoc export :id (uuid/next) + :object-id (uuid/next) + :page-id page-id + :file-id file-id + :enabled true + :name "board-2")]] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [cmd params] + (swap! cmd-calls conj [cmd params]) + (rx/throw (ex-info "exporter must not run" {})))) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [msg] + (reset! worker-kind (:kind msg)) + (rx/of {:type :progress :done 1 :total 2} + {:type :progress :done 2 :total 2} + {:type :done + :filename "page.pdf" + :mtype "application/pdf" + :blob (js/Blob.)}))) + wapi/create-uri (mock/stub (constantly "blob:pdf")) + dom/trigger-download-uri (mock/stub (fn [filename mtype uri] + (reset! downloaded [filename mtype uri]))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-multiple-export {:exports exports + :cmd :export-frames + :name "page"})) + (await (async/wait-for #(some? @downloaded) "the pdf downloads")) + (t/is (empty? @cmd-calls) "no exporter RPC") + (t/is (= :pdf-frames @worker-kind)) + (t/is (= ["page.pdf" "application/pdf" "blob:pdf"] @downloaded)))))) + +;; Scenario: the user cancels a local WASM multi-export. Proves: the widget +;; stays cancelled and late worker :progress/:done do not download or flip +;; status back to running. +(t/deftest ^:async wasm-local-cancel-ignores-late-worker-messages + (let [downloaded (atom nil) + cancel-cmds (atom []) + worker-bus (rx/subject) + store (export-store (wasm-state) (atom [])) + page-id (uuid/next) + file-id (uuid/next) + exports [(assoc export :enabled true :name "a" + :page-id page-id :file-id file-id) + (assoc export :id (uuid/next) + :object-id (uuid/next) + :page-id page-id + :file-id file-id + :enabled true + :name "b")]] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [_ _] + (rx/throw (ex-info "exporter must not run" {})))) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [_] worker-bus)) + mw/emit! (mock/stub (fn [msg] + (swap! cancel-cmds conj msg))) + wapi/create-uri (mock/stub (constantly "blob:zip")) + dom/trigger-download-uri (mock/stub (fn [filename mtype uri] + (reset! downloaded [filename mtype uri]))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-multiple-export {:exports exports :name "page"})) + (await (async/wait-for #(= "running" (get-in @store [:export :status])) + "export is running")) + (rx/push! worker-bus {:type :progress :done 1 :total 2}) + (await (async/wait-for #(= 1 (get-in @store [:export :progress])) + "first progress lands")) + (ptk/emit! store (de/cancel-export)) + (await (async/wait-for #(= "cancelled" (get-in @store [:export :status])) + "export is cancelled")) + (rx/push! worker-bus {:type :progress :done 2 :total 2}) + (rx/push! worker-bus {:type :done + :filename "page.zip" + :mtype "application/zip" + :blob (js/Blob.)}) + (await (async/settle)) + (t/is (= "cancelled" (get-in @store [:export :status]))) + (t/is (nil? @downloaded) "late :done does not download") + (t/is (= :exports/cancel (:cmd (first @cancel-cmds)))) + (rx/end! worker-bus))))) + +(t/deftest collect-export-objects-keeps-only-roots-and-descendants + (let [page-id (uuid/next) + file-id (uuid/next) + root (uuid/next) + child (uuid/next) + other (uuid/next) + objects {root {:id root :parent-id uuid/zero :shapes [child]} + child {:id child :parent-id root :shapes []} + other {:id other :parent-id uuid/zero :shapes []}} + state {:files {file-id {:id file-id + :data {:pages-index + {page-id {:id page-id + :objects objects}}}}}} + exports [{:object-id root :page-id page-id :file-id file-id}] + selected (de/collect-export-objects state exports)] + (t/is (= #{root child} (set (keys selected)))) + (t/is (not (contains? selected other))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 5dd9553f7d..f2585704e8 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -153,6 +153,7 @@ [frontend-tests.util-zip-test] [frontend-tests.util.dom.dnd-test] [frontend-tests.worker-snap-test] + [frontend-tests.worker.exports-test] [goog.object :as gobj])) (enable-console-print!) @@ -186,6 +187,7 @@ 'frontend-tests.data.exports-files-test 'frontend-tests.data.imports-test 'frontend-tests.data.jobs-test + 'frontend-tests.worker.exports-test 'frontend-tests.data.svg-upload-test 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test diff --git a/frontend/test/frontend_tests/worker/exports_test.cljs b/frontend/test/frontend_tests/worker/exports_test.cljs new file mode 100644 index 0000000000..5f1a9f6953 --- /dev/null +++ b/frontend/test/frontend_tests/worker/exports_test.cljs @@ -0,0 +1,70 @@ +;; 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.worker.exports-test + (:require + [app.common.uuid :as uuid] + [app.worker.wasm :as wwasm] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async])) + +;; Record order inside `work`: `submit` starts before any subscriber, so +;; tapping the returned bus can miss values. Plain Promise (not promesa): +;; `await` + promesa crashed the full suite in Node. +(t/deftest ^:async submit-runs-jobs-serially + (let [order (atom [])] + (await + (js/Promise. + (fn [resolve reject] + (let [timer (js/setTimeout + #(reject (js/Error. "submit queue timed out")) + 2000)] + (wwasm/submit + (fn [] + (swap! order conj :a-start) + (rx/concat + (rx/of :a-start) + (->> (rx/of :a-end) + (rx/delay 20) + (rx/tap #(swap! order conj :a-end)))))) + (wwasm/submit + (fn [] + (swap! order conj + {:b-sees-a-start (boolean (some #{:a-start} @order)) + :b-sees-a-end (boolean (some #{:a-end} @order))}) + (swap! order conj :b-end) + (->> (rx/of :b-end) + (rx/finalize + (fn [] + (js/clearTimeout timer) + (resolve nil)))))))))) + (t/is (= [:a-start :a-end + {:b-sees-a-start true :b-sees-a-end true} + :b-end] + @order) + "job B starts only after job A has finished"))) + +(t/deftest cancel-job-is-visible-to-cancelled + (let [job-id (uuid/next)] + (t/is (false? (wwasm/cancelled? job-id))) + (wwasm/cancel-job job-id) + (t/is (true? (wwasm/cancelled? job-id))) + (wwasm/clear-cancel job-id) + (t/is (false? (wwasm/cancelled? job-id))))) + +;; After a macrotask yield (as between PDF pages), `cancelled?` still holds. +(t/deftest ^:async cancel-is-honored-after-async-yield + (let [job-id (uuid/next) + steps (atom [])] + (swap! steps conj :page-1) + (wwasm/cancel-job job-id) + (await (async/settle)) + (if (wwasm/cancelled? job-id) + (swap! steps conj :stopped) + (swap! steps conj :page-2)) + (t/is (= [:page-1 :stopped] @steps)) + (wwasm/clear-cancel job-id))) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 9e590fa86a..0d39d76045 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -859,6 +859,69 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32) -> Result<*mu }) } +/// Multi-page PDF: shape UUIDs are read from the transfer buffer (16 bytes +/// each, same layout as `set_focus_mode`). Returns `[len u32 LE][pdf bytes]`. +#[no_mangle] +#[wasm_error] +pub extern "C" fn render_shapes_pdf() -> Result<*mut u8> { + let bytes = mem::bytes(); + let ids: Vec = bytes + .chunks(size_of::<::BytesType>()) + .map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string()))) + .collect::>>()?; + + with_state!(state, { + let data = state.render_shapes_pdf(&ids)?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + +/// Starts a progressive multi-page PDF session (see `pdf_export_add_page` / +/// `pdf_export_finish`). Replaces any previous session. +#[no_mangle] +#[wasm_error] +pub extern "C" fn pdf_export_begin() -> Result<()> { + with_state!(state, { state.pdf_export_begin() }) +} + +/// Appends one shape as a page of the current PDF export session. +#[no_mangle] +#[wasm_error] +pub extern "C" fn pdf_export_add_page(a: u32, b: u32, c: u32, d: u32) -> Result<()> { + let id = uuid_from_u32_quartet(a, b, c, d); + with_state!(state, { state.pdf_export_add_page(&id) }) +} + +/// Finishes the PDF export session and returns `[len u32 LE][pdf bytes]`. +#[no_mangle] +#[wasm_error] +pub extern "C" fn pdf_export_finish() -> Result<*mut u8> { + with_state!(state, { + let data = state.pdf_export_finish()?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + +/// Aborts the current PDF export session, if any. +#[no_mangle] +#[wasm_error] +pub extern "C" fn pdf_export_abort() -> Result<()> { + with_state!(state, { + state.pdf_export_abort(); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn render_shape_svg(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> { diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index d70f353ef4..746b26242d 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -1,6 +1,8 @@ +use std::mem::ManuallyDrop; + use skia_safe as skia; -use crate::error::Result; +use crate::error::{Error, Result}; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; @@ -10,50 +12,291 @@ use super::RenderResources; mod glyph_text; mod to_unicode; -/// Renders a shape tree to a PDF document and returns the raw PDF bytes. -/// -/// This is a dedicated vector-PDF render path that draws directly to a Skia -/// PDF canvas, bypassing the GPU surface system entirely. The result is a -/// true vector PDF — paths, text and fills are represented as PDF drawing -/// operations rather than rasterised bitmaps. Effects that are inherently -/// pixel-based (blur, shadows with blur) are rasterised internally by Skia's -/// PDF backend +/// In-flight multi-page PDF. The byte buffer and metadata are heap-pinned so +/// Skia's `Document` can hold `'static` write/metadata references across FFI +/// calls (skia-safe ties the document lifetime to both). Stored on +/// [`crate::state::State`], not a process-wide static. +pub(crate) struct PdfExportSession { + bytes: *mut Vec, + metadata: *mut skia::pdf::Metadata<'static>, + document: ManuallyDrop>, + roots: Vec, +} + +fn pdf_metadata() -> skia::pdf::Metadata<'static> { + skia::pdf::Metadata { + creator: "Penpot".to_string(), + producer: "Penpot (Skia PDF)".to_string(), + raster_dpi: Some(300.0), + ..Default::default() + } +} + +/// Starts a multi-page PDF session. Aborts any previous session in `slot` first. +pub fn export_begin(slot: &mut Option) -> Result<()> { + export_abort(slot); + let bytes = Box::into_raw(Box::new(Vec::new())); + let metadata = Box::into_raw(Box::new(pdf_metadata())); + // SAFETY: `bytes` and `metadata` stay pinned until finish/abort. Document + // writes through `&mut Vec`; reallocating the Vec's heap buffer is + // fine because the reference targets the Vec, not its buffer. + let document = unsafe { + let bytes_ref: &'static mut Vec = &mut *bytes; + let metadata_ref: &'static skia::pdf::Metadata<'static> = &*metadata; + skia::pdf::new_document(bytes_ref, Some(metadata_ref)) + }; + *slot = Some(PdfExportSession { + bytes, + metadata, + document: ManuallyDrop::new(document), + roots: Vec::new(), + }); + Ok(()) +} + +/// Appends one shape as a new page. Call between `export_begin` and +/// `export_finish`. +pub fn export_add_page( + slot: &mut Option, + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, +) -> Result<()> { + let session = slot + .as_mut() + .ok_or_else(|| Error::CriticalError("PDF export session not started".to_string()))?; + + let shape = tree + .get(id) + .ok_or_else(|| Error::CriticalError(format!("Shape not found for PDF: {id}")))?; + // Boards crop overflowing children (classic board box) but keep their own + // strokes/shadows/blur. Other shapes use full extrect. + let bounds = shape.export_root_bounds(tree, 1.0); + + // SAFETY: document was created against session.bytes; we put it back. + let document = unsafe { ManuallyDrop::take(&mut session.document) }; + let mut on_page = document.begin_page((bounds.width(), bounds.height()), None); + { + let page_canvas = on_page.canvas(); + page_canvas.translate((-bounds.left(), -bounds.top())); + if let Err(err) = vector::render_tree_pdf(shared, page_canvas, id, tree, bounds) { + // Restore the document so abort/finish can still run; do not + // record this root — the page content failed. + let document = on_page.end_page(); + session.document = ManuallyDrop::new(document); + return Err(err); + } + } + let document = on_page.end_page(); + session.document = ManuallyDrop::new(document); + session.roots.push(*id); + Ok(()) +} + +fn drop_session_buffers(bytes: *mut Vec, metadata: *mut skia::pdf::Metadata<'static>) { + unsafe { + drop(Box::from_raw(metadata)); + drop(Box::from_raw(bytes)); + } +} + +/// Closes the session and returns the PDF bytes (with ToUnicode fixup). +pub fn export_finish(slot: &mut Option, tree: ShapesPoolRef) -> Result> { + let session = slot + .take() + .ok_or_else(|| Error::CriticalError("PDF export session not started".to_string()))?; + + if session.roots.is_empty() { + let document = ManuallyDrop::into_inner(session.document); + document.close(); + drop_session_buffers(session.bytes, session.metadata); + return Err(Error::CriticalError( + "No shapes for multi-page PDF".to_string(), + )); + } + + let roots = session.roots; + let document = ManuallyDrop::into_inner(session.document); + document.close(); + let pdf_bytes = unsafe { *Box::from_raw(session.bytes) }; + unsafe { + drop(Box::from_raw(session.metadata)); + } + + // Skia maps ligatures and other substituted glyphs to U+0000; give them + // back their text so it can be copied from the PDF. + let glyph_texts = glyph_text::collect_many(tree, &roots); + Ok(to_unicode::fix_to_unicode(pdf_bytes, &glyph_texts)) +} + +/// Drops an in-flight session without returning bytes. +pub fn export_abort(slot: &mut Option) { + let Some(session) = slot.take() else { + return; + }; + let document = ManuallyDrop::into_inner(session.document); + document.close(); + drop_session_buffers(session.bytes, session.metadata); +} + +/// Renders a shape tree to a single-page PDF and returns the raw PDF bytes. pub fn render_to_pdf( shared: &mut RenderResources, id: &Uuid, tree: ShapesPoolRef, ) -> Result> { - let shape = tree - .get(id) - .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?; - // Boards crop overflowing children (classic board box) but keep their own - // strokes/shadows/blur. Other shapes use full extrect. - let bounds = shape.export_root_bounds(tree, 1.0); + render_to_pdf_multi(shared, &[*id], tree) +} - let mut pdf_bytes: Vec = Vec::new(); +/// Renders each shape root as a page of one PDF document, in `ids` order. +/// Uses a local session so it does not disturb a progressive export in +/// [`crate::state::State`]. +pub fn render_to_pdf_multi( + shared: &mut RenderResources, + ids: &[Uuid], + tree: ShapesPoolRef, +) -> Result> { + if ids.is_empty() { + return Err(Error::CriticalError( + "No shapes for multi-page PDF".to_string(), + )); + } + let mut session = None; + export_begin(&mut session)?; + for id in ids { + if let Err(err) = export_add_page(&mut session, shared, id, tree) { + export_abort(&mut session); + return Err(err); + } + } + match export_finish(&mut session, tree) { + Ok(bytes) => Ok(bytes), + Err(err) => { + export_abort(&mut session); + Err(err) + } + } +} - let metadata = skia::pdf::Metadata { - creator: "Penpot".to_string(), - producer: "Penpot (Skia PDF)".to_string(), - raster_dpi: Some(300.0), - ..Default::default() - }; +#[cfg(test)] +mod tests { + use super::*; - let document = skia::pdf::new_document(&mut pdf_bytes, Some(&metadata)); + use crate::shapes::{Fill, Rect, SolidColor, Type}; + use crate::state::ShapesPool; + use crate::utils::uuid_from_u32_quartet; - let mut on_page = document.begin_page((bounds.width(), bounds.height()), None); + use skia_safe as skia; - { - let page_canvas = on_page.canvas(); - page_canvas.translate((-bounds.left(), -bounds.top())); - vector::render_tree_pdf(shared, page_canvas, id, tree, bounds)?; + fn uid(n: u32) -> Uuid { + uuid_from_u32_quartet(0, 0, 0, n) } - let document = on_page.end_page(); - document.close(); + fn add_solid_rect( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + color: skia::Color, + ) { + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![Fill::Solid(SolidColor(color))]); + } - // Skia maps ligatures and other substituted glyphs to U+0000; give them - // back their text so it can be copied from the PDF. - let glyph_texts = glyph_text::collect(tree, id); - Ok(to_unicode::fix_to_unicode(pdf_bytes, &glyph_texts)) + /// Counts page objects: `/Type /Page` entries that are not `/Pages`. + fn page_count(pdf: &[u8]) -> usize { + let text = String::from_utf8_lossy(pdf); + text.match_indices("/Type /Page") + .filter(|(i, _)| !text[*i..].starts_with("/Type /Pages")) + .count() + } + + #[test] + fn multi_page_pdf_has_one_page_per_shape() { + let mut pool = ShapesPool::new(); + let a = uid(1); + let b = uid(2); + add_solid_rect( + &mut pool, + a, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + add_solid_rect( + &mut pool, + b, + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(0, 0, 255), + ); + + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let pdf = render_to_pdf_multi(&mut resources, &[a, b], &pool).expect("pdf"); + assert!(pdf.starts_with(b"%PDF"), "missing PDF header"); + assert_eq!(page_count(&pdf), 2, "expected two pages"); + } + + #[test] + fn session_api_reports_progress_sized_pages() { + let mut pool = ShapesPool::new(); + let a = uid(1); + let b = uid(2); + add_solid_rect( + &mut pool, + a, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + add_solid_rect( + &mut pool, + b, + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(0, 0, 255), + ); + + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let mut session = None; + export_begin(&mut session).expect("begin"); + export_add_page(&mut session, &mut resources, &a, &pool).expect("page a"); + export_add_page(&mut session, &mut resources, &b, &pool).expect("page b"); + let pdf = export_finish(&mut session, &pool).expect("finish"); + assert_eq!(page_count(&pdf), 2); + } + + #[test] + fn empty_id_list_is_an_error() { + let pool = ShapesPool::new(); + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let err = render_to_pdf_multi(&mut resources, &[], &pool).expect_err("empty"); + assert!( + matches!(err, Error::CriticalError(_)), + "empty ids must fail critically: {err:?}" + ); + } + + #[test] + fn single_page_wrapper_still_works() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + (0.0, 0.0, 50.0, 50.0), + skia::Color::from_rgb(0, 128, 0), + ); + + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let pdf = render_to_pdf(&mut resources, &id, &pool).expect("pdf"); + assert!(pdf.starts_with(b"%PDF")); + assert_eq!(page_count(&pdf), 1); + } } diff --git a/render-wasm/src/render/pdf/glyph_text.rs b/render-wasm/src/render/pdf/glyph_text.rs index d3623bf87f..71ba721343 100644 --- a/render-wasm/src/render/pdf/glyph_text.rs +++ b/render-wasm/src/render/pdf/glyph_text.rs @@ -197,6 +197,25 @@ pub fn collect(tree: ShapesPoolRef, root: &Uuid) -> GlyphTexts { glyph_texts } +/// Merges glyph texts from every root in `ids` (export-frames / multi PDF). +pub fn collect_many(tree: ShapesPoolRef, ids: &[Uuid]) -> GlyphTexts { + let mut result = GlyphTexts::default(); + for id in ids { + result.merge(collect(tree, id)); + } + result +} + +impl GlyphTexts { + fn merge(&mut self, other: GlyphTexts) { + for (font, glyphs) in other.fonts { + for (glyph, value) in glyphs { + self.insert(&font, glyph, value); + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index a3e3d91296..d3b803b18c 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -28,6 +28,8 @@ pub(crate) struct State { pub saved_shapes: Option, /// True while the first bulk load of shapes is in progress. pub loading: bool, + /// Progressive multi-page PDF export (begin / add-page / finish). + pub pdf_export: Option, } impl State { @@ -38,6 +40,7 @@ impl State { shapes: ShapesPool::new(), saved_shapes: None, loading: false, + pdf_export: None, } } @@ -103,6 +106,26 @@ impl State { crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes) } + pub fn render_shapes_pdf(&mut self, ids: &[Uuid]) -> Result> { + crate::render::pdf::render_to_pdf_multi(get_resources(), ids, &self.shapes) + } + + pub fn pdf_export_begin(&mut self) -> Result<()> { + crate::render::pdf::export_begin(&mut self.pdf_export) + } + + pub fn pdf_export_add_page(&mut self, id: &Uuid) -> Result<()> { + crate::render::pdf::export_add_page(&mut self.pdf_export, get_resources(), id, &self.shapes) + } + + pub fn pdf_export_finish(&mut self) -> Result> { + crate::render::pdf::export_finish(&mut self.pdf_export, &self.shapes) + } + + pub fn pdf_export_abort(&mut self) { + crate::render::pdf::export_abort(&mut self.pdf_export) + } + pub fn render_shape_svg(&mut self, id: &Uuid, scale: f32) -> Result> { crate::render::svg::render_to_svg(get_resources(), id, &self.shapes, scale) } From c3a23174a188df7d3d3030b0a87719b67542b57d Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:50:52 +0200 Subject: [PATCH 128/167] :arrow_up: Update DHI base images (#12597) - dhi.io/debian-base trixie-debian13-dev@9b02576 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13-dev@9b02576 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13@de80b46 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@9b02576 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@9b02576 (docker/devenv/Dockerfile) - dhi.io/nginx 1.31.7-debian13-dev@940ee35 (docker/images/Dockerfile.frontend) - dhi.io/node 24.22.1@ (docker/devenv/Dockerfile) - dhi.io/node 24.22.1@ (.nvmrc) - dhi.io/node 24.22.1-debian13-dev@20cf36e (docker/images/Dockerfile.media-processor) - dhi.io/node 24.22.1-debian13@32a2fa6 (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.1-debian13-dev@20cf36e (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.1-debian13-dev@20cf36e (docker/images/Dockerfile.exporter) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> Co-authored-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 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.nvmrc b/.nvmrc index cdd6de7dff..474d750b50 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.22.0 +v24.22.1 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index ba65456579..f24b5f91e6 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc 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.0 \ +ENV NODE_VERSION=v24.22.1 \ PNPM_VERSION=12.14.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='b7e7991d5407815adfa74a2a9cd4db683552c8ce32ff4a295c22e972849c3f5d'; \ + NODE_SHA256='33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3'; \ PNPM_ARCH='arm64'; \ PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ ;; \ 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='28ea0a438de9a13bd7891fd758eee6c7819ee8af5442dfd8d45bd65e56d4228c'; \ + NODE_SHA256='eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c'; \ PNPM_ARCH='x64'; \ PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ ;; \ diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index a0323a1c63..f875c09a95 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc 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:20079b51710f0397da5e056bfc7156b6aafc7ff3aa4ef4cbcb0b0f1df99cd8c4 AS image +FROM dhi.io/debian-base:trixie-debian13@sha256:de80b468d8c492508f24b30e529accfaa8c59fe4505ddd72e969b324d276ad21 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 3d74f8be43..20fa09f50e 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc 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:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 4bbb65594f..de837a867e 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 +FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 8213d88490..26f84d4640 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:10c0037149e51fec759fb6006d5ab8bc8343f0d718c86841bd510848cc5eeef4 +FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:940ee353dd807f5c5e32720923bb9b34ef9e631ae567320f3eaea7797d6c7964 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index f73a35696b..ebd3f43e36 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 AS build +FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.22.0-debian13@sha256:c6ddacf90691f33734423b06682a63b27abeeafee294830455e91c79614ece8b AS image +FROM dhi.io/node:24.22.1-debian13@sha256:32a2fa6af5209698a9354cef0b19ada3dbfac3090b1010b70afa9f1a298ba2d9 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 1590dcf6a1..dbb21d7903 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.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 +FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ From 033b9a336208886b7e6ba06d64561e175756f064 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:53:02 +0200 Subject: [PATCH 129/167] :arrow_up: update GitHub Actions (#12598) - actions/create-github-app-token v3.3.0 (.github/workflows/auto-label.yml) - pnpm/setup v3.1.1 (.github/workflows/plugins-deploy-styles-doc.yml) - pnpm/setup v3.1.1 (.github/workflows/plugins-deploy-api-doc.yml) - zizmor 1.31.1 (.github/workflows/lint-workflows.yml) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- .github/workflows/auto-label.yml | 2 +- .github/workflows/lint-workflows.yml | 2 +- .github/workflows/plugins-deploy-api-doc.yml | 2 +- .github/workflows/plugins-deploy-styles-doc.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 84615efe4b..65dcc51875 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Generate GitHub App token id: triage-app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ secrets.TRIAGE_APP_CLIENT_ID }} private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml index eb3ca73ed3..a9ad7d055c 100644 --- a/.github/workflows/lint-workflows.yml +++ b/.github/workflows/lint-workflows.yml @@ -34,7 +34,7 @@ permissions: {} env: ACTIONLINT_VERSION: '1.7.12' - ZIZMOR_VERSION: '1.30.1' + ZIZMOR_VERSION: '1.31.1' jobs: lint: diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index d9e7393859..bcbacd9b13 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -48,7 +48,7 @@ jobs: # No dependency cache here: a deploy must not restore a cache that # other workflows can write to. - name: Setup pnpm + Node.js - uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0 + uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: working-directory: plugins runtime: node@24.22.0 diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 4f4d23e271..d64a702184 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -46,7 +46,7 @@ jobs: # No dependency cache here: a deploy must not restore a cache that # other workflows can write to. - name: Setup pnpm + Node.js - uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0 + uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: working-directory: plugins runtime: node@24.22.0 From 76a83387d9d2753a78b13f067f314ebceb788df6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 23 Oct 2026 12:36:11 +0200 Subject: [PATCH 130/167] :arrow_up: Update JVM, Node, pnpm and devenv dependencies (#12600) Move the backend JDK to its latest maintenance build within Java 26 and refresh the dependency versions pinned by hand in the development and production images. - Backend: Zulu JDK 26.0.2 to 26.0.2.1. The jlink module list keeps jdk.management, so the prometheus process metrics stay exported. - Node.js 24.22.1 in .nvmrc, the development image tarball and the two plugins deploy workflows that pin it. - pnpm 12.15.0 in the four Dockerfiles, the 34 packageManager fields and the eleven lockfiles. - Development tools: clj-kondo 2026.10.20, Babashka 1.13.226, Pixi 0.83.0, GitHub CLI 2.103.0, AWS CLI 2.38.5 and uv 0.13.5. - Infra services: RustFS 1.0.3, Mailpit v1.32.0, and Valkey pinned to 8.1.10 instead of the floating 8.1 tag. Fix SERENA_UPDATE_VERSION in the compose file. A value there makes the start script reinstall that Serena release on every start, overriding the one baked into the image; it was replacing 1.7.0 with 1.6.1. The hardened base image updates are left to Renovate. Closes #12599 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh --- .github/workflows/plugins-deploy-api-doc.yml | 2 +- .../workflows/plugins-deploy-styles-doc.yml | 2 +- .serena/memories/devenv/core.md | 42 ++++++ .serena/memories/workflow/updating-pnpm.md | 4 +- backend/package.json | 2 +- backend/pnpm-lock.yaml | 124 +++++++++--------- common/package.json | 2 +- common/pnpm-lock.yaml | 124 +++++++++--------- docker/devenv/Dockerfile | 22 ++-- docker/devenv/docker-compose.infra.yml | 6 +- docker/devenv/docker-compose.main.yml | 2 +- docker/images/Dockerfile.backend | 8 +- 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/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 +++++++++--------- 56 files changed, 790 insertions(+), 748 deletions(-) diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index bcbacd9b13..f513ba6af9 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.0 + runtime: node@24.22.1 install: false - name: Install deps diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index d64a702184..65ed5c80f3 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.0 + runtime: node@24.22.1 install: false - name: Install deps diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index 675f038b76..d048aff4d1 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -77,3 +77,45 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi - `start-devenv` / `log-devenv` / `drop-devenv`: legacy paths around ws0 + shared infra. `drop-devenv` never removes volumes. `exporter/scripts/run` and `wait-and-start.sh` source `backend/scripts/_env` then `_env.local` if present. + +## Image and dependency pins + +Two Dockerfile trees, both multi-arch (`linux/amd64,linux/arm64`): +`docker/devenv` (dev) and `docker/images` + `docker/imagemagick` (prod). + +- Hand-pinned versions, one source each: + - `docker/devenv/Dockerfile`: `NODE_VERSION` (+ per-arch `NODE_SHA256`), + `PNPM_VERSION` (+ `PNPM_SHA256`), `CADDY_VERSION`, `CLOJURE_VERSION`, + Zulu JDK URL + `ESUM`, `RUSTUP_VERSION`/`RUST_VERSION`/ + `EMSCRIPTEN_VERSION`, `CLJKONDO_VERSION`, `BABASHKA_VERSION`, + `CLJFMT_VERSION`, `PIXI_VERSION`, `GITHUB_CLI_VERSION`, + `AWS_CLI_VERSION` (+ `BINARY_SHA256`), `UV_VERSION`, `SERENA_VERSION`, + `postgresql-client-18`, and the `penpotapp/imagemagick:` + `COPY --from`. + - `docker/images/Dockerfile.backend`: Zulu JDK URL + `ESUM`. The `jlink` + `--add-modules` list must keep `jdk.management` + (`mem:backend/subtleties`). + - `docker/images/Dockerfile.{exporter,mcp,media-processor}`: `ARG + PNPM_VERSION` (+ arch SHAs, same values as devenv). +- Node and pnpm must agree in five places: `.nvmrc`, the devenv tarball pin, + the two `plugins-deploy-*` workflows that set `runtime: node@`, and + the four Dockerfile pins. pnpm procedure: `mem:workflow/updating-pnpm`. +- `dhi.io` base images are pinned `tag@sha256:`; Renovate moves + them (`penpot-renovate[bot]`, commits titled `:arrow_up: Update DHI base + images`). Tag and digest move together: a new tag with the old digest still + pulls the old image, and the digest must be the multi-arch index, not a + per-platform one. +- Every hand-pinned checksum is per architecture; one wrong value breaks half + of the multi-arch build. +- `SERENA_UPDATE_VERSION` in `docker-compose.main.yml` must stay empty. Any + value makes `start-tmux.sh` reinstall that Serena release on every start, + overriding the `SERENA_VERSION` baked into the image (it silently replaced + 1.7.0 with 1.6.1). Set it only to test one specific release on purpose. +- Infra images live in `docker-compose.infra.yml`: `rustfs/rustfs` (owns the + S3 data volume), `axllent/mailpit` (SQLite in a volume), `valkey/valkey`, + `postgres:18.6`, `rroemhild/test-openldap`. Keep the volume names stable + when bumping them; replacement services get new volumes and leave the old + data untouched. +- Build locally: `docker build -f docker/devenv/Dockerfile docker/devenv/` + and `cd docker/images && ./build.sh --local`. CI: + `build-docker-devenv.yml`, `build-docker.yml`. diff --git a/.serena/memories/workflow/updating-pnpm.md b/.serena/memories/workflow/updating-pnpm.md index cbaafdeffc..3baf21b358 100644 --- a/.serena/memories/workflow/updating-pnpm.md +++ b/.serena/memories/workflow/updating-pnpm.md @@ -23,7 +23,7 @@ file (never pipe tool output through filters). - The store survives `node_modules` cleans. It is content-addressed and integrity-verified, so it cannot go stale; staleness lives in node_modules. Only `scripts/clean-node-modules --store` removes it. -- Every first-party `package.json` (35 files: workspace roots plus +- Every first-party `package.json` (34 files: workspace roots plus 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/`, @@ -60,7 +60,7 @@ file (never pipe tool output through filters). 3. From the repo root, on a host whose system pnpm is the target version (rebuilt devenv), run `scripts/sync-pnpm-version`. It resolves the integrity hash via `pnpm view` + node (no npm, no corepack, no - python3) and stamps the identical field into all 35 files, replacing + python3) and stamps the identical field into all 34 files, replacing only the value line (each file keeps its own indent) and inserting the key after `"name"`/`"version"` where missing. Explicit version instead: `scripts/sync-pnpm-version `. The explicit form works diff --git a/backend/package.json b/backend/package.json index a2813b9649..f877761df9 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 8cef22d73a..ec457e4c24 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' diff --git a/common/package.json b/common/package.json index 2fa9d1fcdd..2c63d73b31 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "type": "module", "repository": { "type": "git", diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 0df77b1194..182b225f53 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index f24b5f91e6..81fae7a791 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.1 \ - PNPM_VERSION=12.14.0 \ + PNPM_VERSION=12.15.0 \ 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='33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3'; \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ + 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'; \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -226,13 +226,13 @@ RUN set -eux; \ FROM base AS setup-utils -ENV CLJKONDO_VERSION=2026.08.04 \ - BABASHKA_VERSION=1.13.225 \ +ENV CLJKONDO_VERSION=2026.10.20 \ + BABASHKA_VERSION=1.13.226 \ CLJFMT_VERSION=0.16.6 \ - PIXI_VERSION=0.81.0 \ - GITHUB_CLI_VERSION=2.102.0 \ - AWS_CLI_VERSION=2.37.9 \ - UV_VERSION=0.12.23 \ + PIXI_VERSION=0.83.0 \ + GITHUB_CLI_VERSION=2.103.0 \ + AWS_CLI_VERSION=2.38.5 \ + UV_VERSION=0.13.5 \ UV_TOOL_DIR=/opt/uv/tools \ UV_TOOL_BIN_DIR=/opt/utils/bin \ UV_PYTHON_INSTALL_DIR=/opt/uv/python \ @@ -347,11 +347,11 @@ RUN set -ex; \ case "${ARCH}" in \ aarch64|arm64) \ BINARY_ARCH="aarch64"; \ - BINARY_SHA256="e7d2cca3622af4765871fe9ce7eeed03acd09dca10a99ebc46e92f79d78765c0"; \ + BINARY_SHA256="07711a936e9d271045ab937d2c2c5390c25f419e4ba9e25adb9e996031e3f6be"; \ ;; \ amd64|x86_64) \ BINARY_ARCH="x86_64"; \ - BINARY_SHA256="6b3a6a3d7bb3997928f0bdf7b866914224abf242c2e54e1dcebbe84bee64f356"; \ + BINARY_SHA256="5def7d1b7e55d892f9a381bb2cb2081a685712464be0d36d5d706af0ac48d0d3"; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/devenv/docker-compose.infra.yml b/docker/devenv/docker-compose.infra.yml index 6a876b36ad..a5c9a7d5b8 100644 --- a/docker/devenv/docker-compose.infra.yml +++ b/docker/devenv/docker-compose.infra.yml @@ -15,7 +15,7 @@ volumes: services: rustfs: - image: "rustfs/rustfs:1.0.0" + image: "rustfs/rustfs:1.0.3" command: /data volumes: @@ -68,7 +68,7 @@ services: - postgres valkey: - image: valkey/valkey:8.1 + image: valkey/valkey:8.1.10 restart: always command: valkey-server --save 120 1 --loglevel warning volumes: @@ -79,7 +79,7 @@ services: - valkey mailer: - image: axllent/mailpit:v1.31.1 + image: axllent/mailpit:v1.32.0 restart: always volumes: - "mailpit_data:/data" diff --git a/docker/devenv/docker-compose.main.yml b/docker/devenv/docker-compose.main.yml index 491c76b729..219e348a56 100644 --- a/docker/devenv/docker-compose.main.yml +++ b/docker/devenv/docker-compose.main.yml @@ -98,7 +98,7 @@ services: # Agentic devenv: set to a PyPI release version/tag to update Serena on startup, # leave empty to skip update and use the version baked into the image. - - SERENA_UPDATE_VERSION=1.6.1 + - SERENA_UPDATE_VERSION= - SHADOW_SERVER_URL=${SHADOW_SERVER_URL} networks: diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 20fa09f50e..32e8c2c1fa 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -27,12 +27,12 @@ RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ - ESUM='5b222fce0b7076a10ac7ae3b1009a6c2caf4f35bc4e81de72010af6750c5e146'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_aarch64.tar.gz'; \ + ESUM='8ff07320636ef5b30f0d7e16687932df52255c3ef3ffd920a6aae3723511ce7f'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.203-ca-jdk26.0.2.1-linux_aarch64.tar.gz'; \ ;; \ amd64|x86_64) \ - ESUM='4b7c114917aebd0fc6284fc7111245d7747a4d9603bd12d86b384b1abc9d575d'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_x64.tar.gz'; \ + ESUM='c6b810ca659e3293205444f47ac4af163396fd6d025c51fd5f767e628b2ceede'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.203-ca-jdk26.0.2.1-linux_x64.tar.gz'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index de837a867e..97daca2dfe 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.14.0 +ARG PNPM_VERSION=12.15.0 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index ebd3f43e36..f018302d33 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.14.0 +ARG PNPM_VERSION=12.15.0 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='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index dbb21d7903..ee0694bc34 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.14.0 +ARG PNPM_VERSION=12.15.0 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='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docs/package.json b/docs/package.json index 4897ce0855..076c461693 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 10afcffde5..460dc46c55 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' diff --git a/exporter/package.json b/exporter/package.json index d4dccd7091..40c8f6e923 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index ec7e2177a1..843416db40 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' diff --git a/frontend/package.json b/frontend/package.json index f8adeafd03..4e1433e206 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "browserslist": [ "defaults" ], diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index aa187debb2..293e69d269 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 103baefbe2..3eb605929d 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "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 b8269621d3..cd8c59aad1 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "exports": { ".": { "import": "./dist/index.js" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5f1dad88cd..6a0901cc8c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index 06cd1124a0..02cbcf2349 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/library/package.json b/library/package.json index af0488c1df..e41f4a6480 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "type": "module", "repository": { "type": "git", diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index cf2b0d0663..d8a67b195d 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' diff --git a/mcp/package.json b/mcp/package.json index c378c46e94..a62d084e1f 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "devDependencies": { "concurrently": "^10.0.5", "prettier": "^3.9.9" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index 45452fe567..b87f8ce17b 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "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 7e937c34fb..ffa4f3837a 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "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 b1e5fee3f3..ee816a759b 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -27,7 +27,7 @@ "engines": { "node": ">=20" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "dependencies": { "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 42334076f4..7b7f98e50e 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' diff --git a/media-processor/package.json b/media-processor/package.json index 3b91ada0bd..4a79f0ae66 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "dependencies": { "express": "^5.2.1", "multer": "^2.4.0", diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index 79ea794f43..147a963c3e 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' diff --git a/package.json b/package.json index 20591c84cf..05df2e97cd 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "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 fa28b3aad9..a762e7494c 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 5257d9f075..1f21539f19 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 73700d96b6..8402969c20 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index 419b52fa7b..de963120f4 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index 46941b0c59..97405221de 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -7,5 +7,5 @@ "test": "vitest", "lint": "eslint ." }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index af7a4d9c58..f1fc7219c9 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index 69226e5f40..841c979c14 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index 3dfe909673..5868285101 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index d4c72366d0..1c6ba084c1 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index 23e3d77b98..3e225d2f41 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index b715d220df..83d30f08a9 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index 66bdbad7f1..90faf2acea 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index 180320699b..b77a35b327 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 148f002897..87546804f9 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index 7fbd7f2fa7..f40741cf5e 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 985920cc4b..aab41a3eaf 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/package.json b/plugins/package.json index 3da12fffea..f6b6d8901c 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "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 a370bad9b2..faf9371961 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8b01fbc82..03d4f60051 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' diff --git a/render-wasm/package.json b/render-wasm/package.json index fea9914702..96253a6356 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.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index 87a062b5c1..9ef64d85bd 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@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 --- lockfileVersion: '9.0' From 2df5f417a01519044ce26ffe2dbaab1f9d365c33 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 23 Oct 2026 12:40:24 +0200 Subject: [PATCH 131/167] :rewind: Backport .github and docker/ changes from develop --- .github/CODEOWNERS | 9 + .github/actionlint.yaml | 14 + .github/workflows/auto-label.yml | 25 +- .github/workflows/build-adhoc.yml | 29 +- .github/workflows/build-bundle.yml | 52 +++- .github/workflows/build-develop.yml | 29 +- .../workflows/build-docker-admin-console.yml | 22 +- .github/workflows/build-docker-devenv.yml | 42 ++- .github/workflows/build-docker.yml | 134 ++++++--- .github/workflows/build-renovate.yml | 256 ++++++++++++++++++ .github/workflows/build-staging.yml | 29 +- .github/workflows/build-tag.yml | 41 ++- .github/workflows/build-tmp-tokens.yml | 29 +- .github/workflows/commit-checker.yml | 15 +- .github/workflows/lint-workflows.yml | 78 ++++++ .github/workflows/plugins-deploy-api-doc.yml | 38 +-- .github/workflows/plugins-deploy-package.yml | 81 +++--- .github/workflows/plugins-deploy-packages.yml | 44 ++- .../workflows/plugins-deploy-styles-doc.yml | 38 +-- .github/workflows/release.yml | 41 ++- .github/workflows/tests-backend.yml | 11 +- .github/workflows/tests-common.yml | 7 +- .github/workflows/tests-e2e.yml | 46 ++-- .github/workflows/tests-exporter.yml | 14 +- .github/workflows/tests-frontend.yml | 7 +- .github/workflows/tests-library.yml | 7 +- .github/workflows/tests-mcp.yml | 7 +- .github/workflows/tests-plugins.yml | 7 +- .github/workflows/tests-wasm.yml | 17 +- .github/zizmor.yml | 39 +++ docker/devenv/Dockerfile | 98 ++----- docker/devenv/docker-compose.infra.yml | 6 +- docker/devenv/docker-compose.main.yml | 7 +- docker/devenv/files/bashrc | 4 +- docker/devenv/files/entrypoint.sh | 14 + docker/devenv/files/nginx.conf | 20 +- docker/imagemagick/Dockerfile | 11 +- docker/images/Dockerfile.backend | 18 +- docker/images/Dockerfile.exporter | 17 +- docker/images/Dockerfile.frontend | 14 +- docker/images/Dockerfile.mcp | 10 +- docker/images/Dockerfile.media-processor | 92 +++---- docker/images/Dockerfile.storybook | 2 +- docker/images/docker-compose.yaml | 47 +++- ...ginx-admin-console-locations.conf.template | 6 +- docker/images/files/nginx-entrypoint.sh | 139 +++++++++- .../files/nginx-external-locations.conf | 24 ++ .../files/nginx-mcp-locations.conf.template | 6 - .../nginx-security-headers.conf.template | 6 + docker/images/files/nginx.conf.template | 21 +- 50 files changed, 1353 insertions(+), 417 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/actionlint.yaml create mode 100644 .github/workflows/build-renovate.yml create mode 100644 .github/workflows/lint-workflows.yml create mode 100644 .github/zizmor.yml create mode 100644 docker/images/files/nginx-security-headers.conf.template diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..2f3c88cb33 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Changes to workflows, CI configuration and repository automation need +# review from the ci-maintainers team. Issue and PR templates do not. +/.github/workflows/ @penpot/ci-maintainers +/.github/actions/ @penpot/ci-maintainers +/.github/scripts/ @penpot/ci-maintainers +/.github/dependabot.yml @penpot/ci-maintainers +/.github/zizmor.yml @penpot/ci-maintainers +/.github/actionlint.yaml @penpot/ci-maintainers +/.github/CODEOWNERS @penpot/ci-maintainers diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..166202b6e3 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,14 @@ +# actionlint configuration — https://github.com/rhysd/actionlint/blob/main/docs/config.md +self-hosted-runner: + labels: + - penpot-standar-runner + - penpot-extended-runner + +paths: + .github/workflows/**/*.{yml,yaml}: + ignore: + # shellcheck style/info notices that are pervasive in the existing + # scripts (unquoted `>> $GITHUB_OUTPUT`, repeated redirects). Real + # shellcheck warnings and errors are still reported. + - 'SC2086:info' + - 'SC2129:style' diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 491f422d1f..65dcc51875 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -1,25 +1,42 @@ name: Auto Label and Add to Project +# pull_request_target is required so the GitHub App secrets are available +# for PRs from forks. It is safe here: the job never checks out or runs PR +# code, it only reads ids from the event payload. on: issues: types: [opened] pull_request_target: types: [opened] +permissions: {} + jobs: triage: + # The Renovate Dependency Dashboard and Renovate's GitHub Actions PRs do + # not need triage. Other Renovate PRs (base images, Node.js, pnpm) do. + if: >- + !(github.event_name == 'issues' + && github.event.issue.user.login == 'penpot-renovate[bot]') + && !(github.event_name == 'pull_request_target' + && github.event.pull_request.user.login == 'penpot-renovate[bot]' + && github.event.pull_request.head.ref == 'renovate-github-actions') runs-on: ubuntu-latest steps: - name: Generate GitHub App token id: triage-app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: - app-id: ${{ secrets.TRIAGE_APP_ID }} + client-id: ${{ secrets.TRIAGE_APP_CLIENT_ID }} private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} - owner: penpot + # No owner/repositories: the token is scoped to this repository. + # Organization permissions (the project board) still apply. + permission-issues: write + permission-pull-requests: write + permission-organization-projects: write - name: Process Issue or PR - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.triage-app-token.outputs.token }} script: | diff --git a/.github/workflows/build-adhoc.yml b/.github/workflows/build-adhoc.yml index abfe01d5a1..1dcb83bbb5 100644 --- a/.github/workflows/build-adhoc.yml +++ b/.github/workflows/build-adhoc.yml @@ -20,18 +20,39 @@ on: required: false default: false +permissions: {} + jobs: build-bundle: + permissions: + contents: read uses: ./.github/workflows/build-bundle.yml - secrets: inherit + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} with: gh_ref: ${{ inputs.gh_ref }} force: ${{ inputs.force }} build-docker: needs: build-bundle + permissions: + contents: read uses: ./.github/workflows/build-docker.yml - secrets: inherit + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }} + PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} with: gh_ref: ${{ inputs.gh_ref }} # Pin build-docker to the commit build-bundle actually bundled. @@ -39,8 +60,10 @@ jobs: force: ${{ inputs.force }} build-docker-admin-console: + permissions: {} uses: ./.github/workflows/build-docker-admin-console.yml - secrets: inherit + secrets: + ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }} with: gh_ref: ${{ inputs.nitrate_ref || inputs.gh_ref }} force: ${{ inputs.force }} diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index e7f1f3a815..88c04a2634 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -20,7 +20,6 @@ on: description: 'Name of the branch or ref' type: string required: true - default: 'develop' force: description: 'Rebuild and overwrite even if this version already exists in S3' type: boolean @@ -30,6 +29,17 @@ on: sha: description: 'Bundled commit, full 40-char SHA.' value: ${{ jobs.check.outputs.sha }} + secrets: + AWS_ACCESS_KEY_ID: + required: false + AWS_REGION: + required: false + AWS_SECRET_ACCESS_KEY: + required: false + MATTERMOST_WEBHOOK: + required: false + S3_BUCKET: + required: false # Literal group name: under `workflow_call`, `github.workflow` resolves to the # caller's workflow, which put this workflow and the other reusable one called @@ -39,6 +49,9 @@ concurrency: group: build-bundle-${{ inputs.gh_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: # ── 1. Decide whether there is anything to build ─────────────────────── check: @@ -55,15 +68,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 ref: ${{ inputs.gh_ref }} - name: Extract some useful variables id: vars + env: + GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT + 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 "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT @@ -79,8 +95,12 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 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 }} run: | - if [ "${{ inputs.force }}" = "true" ]; then + if [ "$FORCE" = "true" ]; then echo "exists=false" >> $GITHUB_OUTPUT { echo "### 🔁 Bundle build forced" @@ -91,8 +111,8 @@ jobs: fi BUNDLE_EXISTS=$(aws s3api head-object \ - --bucket ${{ secrets.S3_BUCKET }} \ - --key "penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" \ + --bucket "$S3_BUCKET" \ + --key "penpot-sha-${SHORT_SHA}.zip" \ > /dev/null 2>&1 && echo "true" || echo "false") if [ "$BUNDLE_EXISTS" = "true" ]; then @@ -100,7 +120,7 @@ jobs: { echo "### ⏭️ Bundle build skipped" echo "" - echo "The bundle in S3 was already built from \`sha-${{ steps.vars.outputs.short_sha }}\` (\`${{ steps.vars.outputs.bundle_version }}\`)." + echo "The bundle in S3 was already built from \`sha-${SHORT_SHA}\` (\`${BUNDLE_VERSION}\`)." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -116,8 +136,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 ref: ${{ inputs.gh_ref }} @@ -142,18 +163,25 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 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 }} + BUNDLE_VERSION: ${{ needs.check.outputs.bundle_version }} run: | aws s3 cp zips/penpot.zip \ - s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ needs.check.outputs.short_sha }}.zip \ - --metadata bundle-version=${{ needs.check.outputs.bundle_version }} + "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" \ + --metadata "bundle-version=${BUNDLE_VERSION}" - name: Write step summary + env: + SHORT_SHA: ${{ needs.check.outputs.short_sha }} + BUNDLE_VERSION: ${{ needs.check.outputs.bundle_version }} + COMMIT_TITLE: ${{ needs.check.outputs.commit_title }} run: | { echo "### ✅ Bundle built" echo "" - echo "- Version: \`${{ needs.check.outputs.bundle_version }}\` (\`git describe --tags --always\`)" - echo "- Commit: [\`${{ needs.check.outputs.short_sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.check.outputs.short_sha }}) — ${{ needs.check.outputs.commit_title }}" + echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + 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-develop.yml b/.github/workflows/build-develop.yml index 8f12163965..e808f38384 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -18,18 +18,39 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: true +permissions: {} + jobs: build-bundle: + permissions: + contents: read uses: ./.github/workflows/build-bundle.yml - secrets: inherit + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} with: gh_ref: "develop" force: ${{ inputs.force || false }} build-docker: needs: build-bundle + permissions: + contents: read uses: ./.github/workflows/build-docker.yml - secrets: inherit + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }} + PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} with: gh_ref: "develop" # Pin build-docker to the commit build-bundle actually bundled. @@ -37,8 +58,10 @@ jobs: force: ${{ inputs.force || false }} build-docker-admin-console: + permissions: {} uses: ./.github/workflows/build-docker-admin-console.yml - secrets: inherit + secrets: + ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }} with: gh_ref: "develop" force: ${{ inputs.force || false }} diff --git a/.github/workflows/build-docker-admin-console.yml b/.github/workflows/build-docker-admin-console.yml index dbc28387b6..3d1ef04008 100644 --- a/.github/workflows/build-docker-admin-console.yml +++ b/.github/workflows/build-docker-admin-console.yml @@ -39,6 +39,8 @@ on: description: 'Token with Actions write access on penpot-nitrate' required: true +permissions: {} + jobs: build-nitrate-docker: runs-on: ubuntu-latest @@ -48,22 +50,23 @@ jobs: WORKFLOW: build-docker-admin-console.yml GH_REF: ${{ inputs.gh_ref }} DISPATCH_REF: ${{ inputs.dispatch_ref }} + FORCE: ${{ inputs.force }} steps: - name: Trigger nitrate docker build id: dispatch run: | - DISTINCT_ID="${{ github.run_id }}-${{ github.run_attempt }}" - CALLER_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + DISTINCT_ID="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + CALLER_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \ -f gh_ref="$GH_REF" \ - -f force="${{ inputs.force }}" \ + -f force="$FORCE" \ -f caller_run_id="$DISTINCT_ID" \ -f caller_run_url="$CALLER_URL" # Locate the dispatched run using the correlation id embedded in its run-name RUN_ID="" - for i in $(seq 1 24); do + for _ in $(seq 1 24); do sleep 5 RUN_ID=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" \ --limit 10 --json databaseId,displayTitle \ @@ -83,20 +86,25 @@ jobs: echo "::notice title=Nitrate docker build::$RUN_URL" - name: Wait for nitrate docker build + env: + RUN_ID: ${{ steps.dispatch.outputs.run_id }} run: | - gh run watch "${{ steps.dispatch.outputs.run_id }}" \ + gh run watch "$RUN_ID" \ --repo "$REPO" \ --interval 30 \ --exit-status - name: Report result if: always() && steps.dispatch.outputs.run_id != '' + env: + RUN_ID: ${{ steps.dispatch.outputs.run_id }} + RUN_URL: ${{ steps.dispatch.outputs.run_url }} run: | - CONCLUSION=$(gh run view "${{ steps.dispatch.outputs.run_id }}" \ + CONCLUSION=$(gh run view "$RUN_ID" \ --repo "$REPO" --json conclusion --jq '.conclusion') { echo "### 🐳 Nitrate docker build" echo "" echo "- Result: \`${CONCLUSION:-in_progress}\`" - echo "- Run: ${{ steps.dispatch.outputs.run_url }}" + echo "- Run: ${RUN_URL}" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index 2eab6be51e..a1c99e2bdd 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -3,40 +3,55 @@ name: DevEnv Docker Image Builder on: workflow_dispatch: +permissions: + contents: read + +concurrency: + group: build-docker-devenv + cancel-in-progress: false + jobs: build-and-push: name: Build and push DevEnv Docker image runs-on: penpot-standar-runner + timeout-minutes: 90 + env: + DOCKER_IMAGE: 'penpotapp/devenv' steps: - name: Set common environment variables run: | # Each job execution will use its own docker configuration. - echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV + echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}" >> "$GITHUB_ENV" - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up QEMU + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + with: + platforms: linux/amd64,linux/arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to Docker Registry (push destination) - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Login to Docker Hardened Images registry (base image pull) - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: dhi.io username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Build and push DevEnv Docker image - uses: docker/build-push-action@v7 - env: - DOCKER_IMAGE: 'penpotapp/devenv' + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: ./docker/devenv/ file: ./docker/devenv/Dockerfile @@ -56,4 +71,13 @@ jobs: TEXT: | 🚀 *[PENPOT] New devenv available* 📄 You may want to update your devenv. - @alvaro + + - name: Notify Mattermost (developers) + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 + with: + MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} + MATTERMOST_CHANNEL: penpotdev + TEXT: | + 🚀 *[PENPOT] New devenv available* + 📄 You may want to update your devenv. + (\cc @alvaro) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index d24b09436e..a8d6a1976d 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -19,7 +19,6 @@ on: description: 'Name of the branch or ref' type: string required: true - default: 'develop' sha: description: >- Exact commit to check out (full 40-char SHA, e.g. from @@ -33,6 +32,27 @@ on: type: boolean required: false default: false + secrets: + AWS_ACCESS_KEY_ID: + required: false + AWS_REGION: + required: false + AWS_SECRET_ACCESS_KEY: + required: false + DOCKER_PASSWORD: + required: false + DOCKER_REGISTRY: + required: false + DOCKER_USERNAME: + required: false + MATTERMOST_WEBHOOK: + required: false + PUB_DOCKER_PASSWORD: + required: false + PUB_DOCKER_USERNAME: + required: false + S3_BUCKET: + required: false # Literal group name: under `workflow_call`, `github.workflow` resolves to the # caller's workflow, which put this workflow and the other reusable one called @@ -50,6 +70,9 @@ env: # missing (e.g. if runners ever move to separate machines). BUNDLE_CACHE: /var/tmp/penpot-bundle-cache +permissions: + contents: read + jobs: # ── 1. Resolve the build key and check the whole set at once ─────────── prepare: @@ -58,6 +81,7 @@ jobs: timeout-minutes: 15 outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} + ref_slug: ${{ steps.vars.outputs.ref_slug }} bundle_version: ${{ steps.vars.outputs.bundle_version }} sha: ${{ steps.vars.outputs.sha }} short_sha: ${{ steps.vars.outputs.short_sha }} @@ -66,16 +90,21 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 ref: ${{ inputs.sha != '' && inputs.sha || inputs.gh_ref }} - name: Extract some useful variables id: vars + env: + GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - GH_REF="${{ inputs.gh_ref || github.ref_name }}" + # A git ref accepts characters that a Docker tag does not. + 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 echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT @@ -92,13 +121,16 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + FORCE: ${{ inputs.force }} + SHORT_SHA: ${{ steps.vars.outputs.short_sha }} run: | - if [ "${{ inputs.force }}" = "true" ]; then + 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-${{ steps.vars.outputs.short_sha }}.zip" - aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" "$ZIP.$$.tmp" + ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" + aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" { echo "### 🔁 Image set build forced" @@ -109,14 +141,14 @@ jobs: fi if aws s3api head-object \ - --bucket ${{ secrets.S3_BUCKET }} \ - --key "markers/images-sha-${{ steps.vars.outputs.short_sha }}" \ + --bucket "$S3_BUCKET" \ + --key "markers/images-sha-${SHORT_SHA}" \ > /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-${{ steps.vars.outputs.short_sha }}\`." + echo "The whole set was already built and promoted for \`sha-${SHORT_SHA}\`." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -126,9 +158,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-${{ steps.vars.outputs.short_sha }}.zip" + ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" if [ ! -f "$ZIP" ]; then - aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" "$ZIP.$$.tmp" + aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi fi @@ -156,12 +188,13 @@ jobs: echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}-${{ matrix.image }}" >> $GITHUB_ENV - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.sha != '' && inputs.sha || inputs.gh_ref }} - name: Login to Docker Registry - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} @@ -171,7 +204,7 @@ jobs: # images from DockerHub for unregistered users. # https://docs.docker.com/docker-hub/usage/ - name: Login to DockerHub Registry - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} @@ -183,7 +216,7 @@ jobs: # reuses the same PUB_DOCKER_* credentials as the DockerHub # login above. - name: Login to Docker Hardened Images registry (base image pull) - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: dhi.io username: ${{ secrets.PUB_DOCKER_USERNAME }} @@ -197,38 +230,41 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 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 }} + IMAGE: ${{ matrix.image }} run: | - ZIP="$BUNDLE_CACHE/penpot-sha-${{ needs.prepare.outputs.short_sha }}.zip" + ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" if [ ! -f "$ZIP" ]; then echo "Bundle not found in host cache; falling back to S3." mkdir -p "$BUNDLE_CACHE" - aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ needs.prepare.outputs.short_sha }}.zip" "$ZIP.$$.tmp" + aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi # Extract only the bundle this job needs. pushd docker/images - unzip -q "$ZIP" "penpot/${{ matrix.image }}/*" - mv "penpot/${{ matrix.image }}" "bundle-${{ matrix.image }}" + unzip -q "$ZIP" "penpot/${IMAGE}/*" + mv "penpot/${IMAGE}" "bundle-${IMAGE}" popd - name: Set up QEMU (stable) - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 with: platforms: linux/amd64,linux/arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Extract metadata (tags, labels) id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ matrix.image }} labels: | bundle_version=${{ needs.prepare.outputs.bundle_version }} - name: Build and push Docker image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: ./docker/images/ file: ./docker/images/Dockerfile.${{ matrix.image }} @@ -256,22 +292,26 @@ jobs: echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to Docker Registry - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Point branch tags to the new build key + env: + DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} + REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ - -t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \ - "${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.short_sha }}" + -t "${DOCKER_REGISTRY}/${image}:${REF_SLUG}" \ + "${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}" done # The marker is written LAST: its presence certifies that all five @@ -281,20 +321,27 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 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 }} run: | - echo "${{ github.run_id }}" | aws s3 cp - \ - "s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.short_sha }}" + echo "$GITHUB_RUN_ID" | aws s3 cp - \ + "s3://${S3_BUCKET}/markers/images-sha-${SHORT_SHA}" - name: Write step summary + env: + REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }} + COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }} run: | { echo "### ✅ Image set promoted" echo "" - echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)" - echo "- Commit: [\`${{ needs.prepare.outputs.short_sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.short_sha }}) — ${{ needs.prepare.outputs.commit_title }}" + echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + 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 \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.short_sha }}\`." + echo "All \`:${REF_SLUG}\` tags now point to \`sha-${SHORT_SHA}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 3b. Skip path: make sure THIS ref's tags point to the existing @@ -314,34 +361,43 @@ jobs: echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to Docker Registry - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Point branch tags to the existing build key + env: + DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} + REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ - -t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \ - "${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.short_sha }}" + -t "${DOCKER_REGISTRY}/${image}:${REF_SLUG}" \ + "${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}" done - name: Write step summary + env: + REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + 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: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)" - echo "- Commit: [\`${{ needs.prepare.outputs.short_sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.short_sha }}) — ${{ needs.prepare.outputs.commit_title }}" + echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + 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 \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.short_sha }}\`." + echo "All \`:${REF_SLUG}\` tags now point to \`sha-${SHORT_SHA}\`." } >> "$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 new file mode 100644 index 0000000000..6156aed086 --- /dev/null +++ b/.github/workflows/build-renovate.yml @@ -0,0 +1,256 @@ +name: Renovate Docker Image Check + +# Triggered by pushes to Renovate branches, not by pull_request: this is a +# public repository and these jobs run on the self-hosted runners, so a PR +# from a fork must never be able to start them. Only users with write +# access can push here. +on: + push: + branches: + - 'renovate-*' + +permissions: {} + +concurrency: + group: renovate-image-check-${{ github.ref_name }} + cancel-in-progress: true + +env: + BUNDLE_CACHE: /var/tmp/penpot-bundle-cache + +jobs: + # ── 1. Pick an existing develop bundle and detect what changed ──────── + prepare: + name: Prepare + runs-on: penpot-standar-runner + timeout-minutes: 10 + permissions: + contents: read + outputs: + short_sha: ${{ steps.bundle.outputs.short_sha }} + images: ${{ steps.changes.outputs.images }} + devenv: ${{ steps.changes.outputs.devenv }} + imagemagick: ${{ steps.changes.outputs.imagemagick }} + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Find the develop commit this branch is based on + id: base + run: | + echo "sha=$(git merge-base HEAD origin/develop)" >> "$GITHUB_OUTPUT" + + - name: Detect changed images + id: changes + env: + BASE_SHA: ${{ steps.base.outputs.sha }} + run: | + changed() { + git diff --quiet "$BASE_SHA" HEAD -- "$@" && echo false || echo true + } + { + # Anything outside .github can affect the production images. + echo "images=$(changed . ':!.github')" + echo "devenv=$(changed docker/devenv)" + echo "imagemagick=$(changed docker/imagemagick)" + } >> "$GITHUB_OUTPUT" + + # Renovate only changes Dockerfiles and package manager versions, so + # the most recent develop bundle is enough to build the images. + - name: Find the latest develop bundle + id: bundle + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + BASE_SHA: ${{ steps.base.outputs.sha }} + 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" + exit 0 + fi + done + echo "::error::No bundle found for the last 50 develop commits" + exit 1 + + # ── 2. Production images, built from the PR Dockerfiles ─────────────── + images: + name: Build ${{ matrix.image }} + runs-on: penpot-standar-runner + timeout-minutes: 60 + needs: prepare + if: needs.prepare.outputs.images == 'true' + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 3 + matrix: + image: [backend, frontend, exporter, storybook, mcp] + + steps: + - name: Set common environment variables + env: + MATRIX_IMAGE: ${{ matrix.image }} + run: | + echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}-${MATRIX_IMAGE}" >> "$GITHUB_ENV" + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Login to Docker Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ secrets.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Login to DockerHub Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + - name: Login to Docker Hardened Images registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: dhi.io + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + - name: Prepare Penpot bundle + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + 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 }} + IMAGE: ${{ matrix.image }} + run: | + ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" + if [ ! -f "$ZIP" ]; then + mkdir -p "$BUNDLE_CACHE" + aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" + mv "$ZIP.$$.tmp" "$ZIP" + fi + pushd docker/images + unzip -q "$ZIP" "penpot/${IMAGE}/*" + mv "penpot/${IMAGE}" "bundle-${IMAGE}" + popd + + - name: Set up QEMU + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + with: + platforms: linux/amd64,linux/arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Build Docker image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ./docker/images/ + file: ./docker/images/Dockerfile.${{ matrix.image }} + platforms: linux/amd64,linux/arm64 + push: false + cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache + + # ── 3. Devenv, only when the PR touches it ──────────────────────────── + devenv: + name: Build devenv + runs-on: penpot-standar-runner + timeout-minutes: 90 + needs: prepare + if: needs.prepare.outputs.devenv == 'true' + permissions: + contents: read + + steps: + - name: Set common environment variables + run: | + echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}" >> "$GITHUB_ENV" + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Login to DockerHub Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + - name: Login to Docker Hardened Images registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: dhi.io + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + with: + platforms: linux/amd64,linux/arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Build devenv image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ./docker/devenv/ + file: ./docker/devenv/Dockerfile + platforms: linux/amd64,linux/arm64 + push: false + cache-from: type=registry,ref=penpotapp/devenv:buildcache + + # ── 4. ImageMagick, only when the PR touches it ─────────────────────── + # amd64 only: compiling ImageMagick under QEMU takes too long for a check. + imagemagick: + name: Build imagemagick + runs-on: penpot-standar-runner + timeout-minutes: 60 + needs: prepare + if: needs.prepare.outputs.imagemagick == 'true' + permissions: + contents: read + + steps: + - name: Set common environment variables + run: | + echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}" >> "$GITHUB_ENV" + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Login to Docker Hardened Images registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: dhi.io + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Build imagemagick image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ./docker/imagemagick/ + file: ./docker/imagemagick/Dockerfile + platforms: linux/amd64 + push: false diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index 249ba39e40..2b8d7a8e4c 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -18,18 +18,39 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: true +permissions: {} + jobs: build-bundle: + permissions: + contents: read uses: ./.github/workflows/build-bundle.yml - secrets: inherit + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} with: gh_ref: "staging" force: ${{ inputs.force || false }} build-docker: needs: build-bundle + permissions: + contents: read uses: ./.github/workflows/build-docker.yml - secrets: inherit + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }} + PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} with: gh_ref: "staging" # Pin build-docker to the commit build-bundle actually bundled. @@ -37,8 +58,10 @@ jobs: force: ${{ inputs.force || false }} build-docker-admin-console: + permissions: {} uses: ./.github/workflows/build-docker-admin-console.yml - secrets: inherit + secrets: + ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }} with: gh_ref: "staging" force: ${{ inputs.force || false }} diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index ed79d398f9..c2fd48671d 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -21,31 +21,55 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: false +permissions: {} + jobs: build-bundle: + permissions: + contents: read uses: ./.github/workflows/build-bundle.yml - secrets: inherit + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} with: gh_ref: ${{ github.ref_name }} force: ${{ inputs.force || false }} build-docker: needs: build-bundle + permissions: + contents: read uses: ./.github/workflows/build-docker.yml - secrets: inherit + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }} + PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} with: gh_ref: ${{ github.ref_name }} force: ${{ inputs.force || false }} build-docker-admin-console: + permissions: {} uses: ./.github/workflows/build-docker-admin-console.yml - secrets: inherit + secrets: + ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }} with: gh_ref: ${{ github.ref_name }} force: ${{ inputs.force || false }} notify: name: Notifications + permissions: {} runs-on: ubuntu-latest needs: - build-docker @@ -66,7 +90,16 @@ jobs: needs: - build-docker - build-docker-admin-console + permissions: + contents: write uses: ./.github/workflows/release.yml - secrets: inherit + secrets: + AWS_REGION: ${{ secrets.AWS_REGION }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }} + PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }} with: gh_ref: ${{ github.ref_name }} diff --git a/.github/workflows/build-tmp-tokens.yml b/.github/workflows/build-tmp-tokens.yml index 838a97a617..28802b1ccc 100644 --- a/.github/workflows/build-tmp-tokens.yml +++ b/.github/workflows/build-tmp-tokens.yml @@ -2,23 +2,44 @@ name: _TMP TOKENS on: workflow_dispatch: - schedule: - - cron: '46 5-20 * * 1-5' + # schedule: + # - cron: '46 5-20 * * 1-5' concurrency: group: ${{ github.workflow }} cancel-in-progress: true +permissions: {} + jobs: build-bundle: + permissions: + contents: read uses: ./.github/workflows/build-bundle.yml - secrets: inherit + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} with: gh_ref: "hiru-tokens-in-libs" build-docker: needs: build-bundle + permissions: + contents: read uses: ./.github/workflows/build-docker.yml - secrets: inherit + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} + PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }} + PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} with: gh_ref: "hiru-tokens-in-libs" diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index 14c48a5850..15790e72a9 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -7,19 +7,16 @@ on: - reopened - synchronize - ready_for_review - pull_request_target: - types: - - opened - - edited - - reopened - - synchronize - - ready_for_review push: branches: - main - develop - staging +permissions: + contents: read + pull-requests: read + jobs: check-commit-message: if: ${{ !github.event.pull_request.draft }} @@ -27,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check Commit Type - uses: gsactions/commit-message-checker@v2 + uses: gsactions/commit-message-checker@16fa2d5de096ae0d35626443bcd24f1e756cafee # v2.0.0 with: pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert|Reapply).+[^.])$' flags: 'gm' @@ -59,7 +56,7 @@ jobs: # always the trusted version: PR commits are only read, never # executed. On push events this resolves to the pushed sha. - name: Checkout trusted code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 ref: ${{ github.event.pull_request.base.sha || github.sha }} diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml new file mode 100644 index 0000000000..a9ad7d055c --- /dev/null +++ b/.github/workflows/lint-workflows.yml @@ -0,0 +1,78 @@ +name: "CI: Workflows" + +# Static analysis of the GitHub Actions workflows themselves: +# - actionlint: syntax, expressions, runner labels, job/step references, +# and shellcheck on every `run:` block. +# - zizmor: security audits (template injection, dangerous triggers, +# credential persistence, ...). Configured in .github/zizmor.yml. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - '.github/workflows/**' + - '.github/actions/**' + - '.github/actionlint.yaml' + - '.github/zizmor.yml' + push: + branches: + - develop + - staging + - main + paths: + - '.github/workflows/**' + - '.github/actions/**' + - '.github/actionlint.yaml' + - '.github/zizmor.yml' + workflow_dispatch: + +concurrency: + group: lint-workflows-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +env: + ACTIONLINT_VERSION: '1.7.12' + ZIZMOR_VERSION: '1.31.1' + +jobs: + lint: + name: Lint workflows + if: ${{ !github.event.pull_request.draft }} + # PR code is untrusted: never run this on the self-hosted runners. + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install actionlint + run: | + curl -fsSL --retry 3 \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP" actionlint + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + + # Each finding is emitted as a `::error` annotation on the PR diff. + - name: Run actionlint + run: | + # shellcheck disable=SC2016 # Go template, not shell expansion. + actionlint -format '{{range $err := .}}::error file={{$err.Filepath}},line={{$err.Line}},col={{$err.Column}},title=actionlint ({{$err.Kind}})::{{$err.Message}}\n{{end}}' + + - name: Run zizmor + if: ${{ !cancelled() }} + env: + # Enables the online audits (known-vulnerable-actions, + # impostor-commit, ref-confusion, ...). + GH_TOKEN: ${{ github.token }} + run: | + pipx run "zizmor==${ZIZMOR_VERSION}" \ + --config .github/zizmor.yml \ + --format github \ + .github/workflows diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 4657740a58..f513ba6af9 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -33,43 +33,32 @@ jobs: steps: - name: Extract some useful variables id: vars + env: + GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT + echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 ref: ${{ steps.vars.outputs.gh_ref }} - # START: Setup Node and PNPM enabling cache + # No dependency cache here: a deploy must not restore a cache that + # other workflows can write to. - name: Setup pnpm + Node.js - uses: pnpm/setup@v2 + uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: working-directory: plugins - runtime: node@24.21.0 + runtime: node@24.22.1 install: false - - name: Get pnpm store path - id: pnpm-store - working-directory: ./plugins - shell: bash - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - - - name: Cache pnpm store - uses: actions/cache@v5 - with: - path: ${{ steps.pnpm-store.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - # END: Setup Node and PNPM enabling cache - - name: Install deps working-directory: ./plugins shell: bash run: | - pnpm install --no-frozen-lockfile; + pnpm install --frozen-lockfile; pnpm add -D -w wrangler@latest; - name: Build docs @@ -78,8 +67,9 @@ jobs: run: pnpm run build:doc - name: Select Worker name + env: + REF: ${{ steps.vars.outputs.gh_ref }} run: | - REF="${{ steps.vars.outputs.gh_ref }}" case "$REF" in main) echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV @@ -97,7 +87,7 @@ jobs: working-directory: plugins shell: bash run: | - sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml + sed -i "s/WORKER_URI/${WORKER_URI}/g" wrangler-penpot-plugins-api-doc.toml - name: Add noindex header and robots.txt files for non-production environments if: ${{ steps.vars.outputs.gh_ref != 'main' }} @@ -117,7 +107,7 @@ jobs: EOF - name: Deploy to Cloudflare Workers - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: workingDirectory: plugins apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index cb6cbc704c..c9ff731682 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -23,11 +23,17 @@ on: description: 'Name of the branch' type: string required: true - default: 'develop' plugin_name: description: 'Publig name (from plugins/apps/-plugin)' type: string required: true + secrets: + CLOUDFLARE_ACCOUNT_ID: + required: false + CLOUDFLARE_API_TOKEN: + required: false + MATTERMOST_WEBHOOK: + required: false permissions: contents: read @@ -35,72 +41,71 @@ permissions: jobs: deploy: runs-on: penpot-standar-runner + container: + image: penpotapp/devenv:latest + volumes: + # Persistent pnpm store shared by all runner instances on the host. + # pnpm's content-addressable store is safe for concurrent access. + - /var/cache/github-runner/pnpm-store:/root/.local/share/pnpm/store + defaults: + run: + shell: bash + env: + GH_REF: ${{ inputs.gh_ref }} + PLUGIN_NAME: ${{ inputs.plugin_name }} steps: + # plugin_name is a free-form string that ends up in paths, worker names + # and GITHUB_ENV; reject anything that is not a plain slug before it is + # used anywhere. + - name: Validate inputs + run: | + if ! [[ "$PLUGIN_NAME" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "::error::Invalid plugin_name: must match ^[a-z0-9][a-z0-9-]*$" + exit 1 + fi + - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 ref: ${{ inputs.gh_ref }} - # START: Setup Node and PNPM enabling cache - - name: Setup pnpm + Node.js - uses: pnpm/setup@v2 - with: - working-directory: plugins - runtime: node@24.21.0 - install: false - - - name: Get pnpm store path - id: pnpm-store - working-directory: ./plugins - shell: bash - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - - - name: Cache pnpm store - uses: actions/cache@v5 - with: - path: ${{ steps.pnpm-store.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - # END: Setup Node and PNPM enabling cache - - name: Install deps working-directory: ./plugins shell: bash run: | - pnpm install --no-frozen-lockfile; + pnpm install --frozen-lockfile; pnpm add -D -w wrangler@latest; - name: "Build package for ${{ inputs.plugin_name }}-plugin" working-directory: plugins shell: bash - run: pnpm --filter ${{ inputs.plugin_name }}-plugin build + run: pnpm --filter "${PLUGIN_NAME}-plugin" build - name: Select Worker name run: | - REF="${{ inputs.gh_ref }}" - case "$REF" in + case "$GH_REF" in main) - echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pro" >> $GITHUB_ENV - echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.app" >> $GITHUB_ENV ;; + echo "WORKER_NAME=${PLUGIN_NAME}-plugin-pro" >> $GITHUB_ENV + echo "WORKER_URI=${PLUGIN_NAME}.plugins.penpot.app" >> $GITHUB_ENV ;; staging) - echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pre" >> $GITHUB_ENV - echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.dev" >> $GITHUB_ENV ;; + echo "WORKER_NAME=${PLUGIN_NAME}-plugin-pre" >> $GITHUB_ENV + echo "WORKER_URI=${PLUGIN_NAME}.plugins.penpot.dev" >> $GITHUB_ENV ;; develop) - echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-hourly" >> $GITHUB_ENV - echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;; - *) echo "Unsupported branch ${REF}" && exit 1 ;; + echo "WORKER_NAME=${PLUGIN_NAME}-plugin-hourly" >> $GITHUB_ENV + echo "WORKER_URI=${PLUGIN_NAME}.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;; + *) echo "Unsupported branch ${GH_REF}" && exit 1 ;; esac - name: Set the custom url working-directory: plugins shell: bash run: | - sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" apps/${{ inputs.plugin_name }}-plugin/wrangler.toml + sed -i "s/WORKER_URI/${WORKER_URI}/g" "apps/${PLUGIN_NAME}-plugin/wrangler.toml" - name: Deploy to Cloudflare Workers - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: workingDirectory: plugins apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/plugins-deploy-packages.yml b/.github/workflows/plugins-deploy-packages.yml index 943e4b790d..40b71ede21 100644 --- a/.github/workflows/plugins-deploy-packages.yml +++ b/.github/workflows/plugins-deploy-packages.yml @@ -21,6 +21,9 @@ on: - staging - main +permissions: + contents: read + jobs: detect-changes: runs-on: ubuntu-latest @@ -36,9 +39,11 @@ jobs: # [For new plugins] # Add more outputs here steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - id: filter - uses: dorny/paths-filter@v4 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 with: filters: | colors_to_tokens: @@ -72,7 +77,10 @@ jobs: needs: detect-changes if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.colors_to_tokens == 'true' uses: ./.github/workflows/plugins-deploy-package.yml - secrets: inherit + secrets: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} with: gh_ref: "${{ inputs.gh_ref || github.ref_name }}" plugin_name: colors-to-tokens @@ -81,7 +89,10 @@ jobs: needs: detect-changes if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.contrast == 'true' uses: ./.github/workflows/plugins-deploy-package.yml - secrets: inherit + secrets: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} with: gh_ref: "${{ inputs.gh_ref || github.ref_name }}" plugin_name: contrast @@ -90,7 +101,10 @@ jobs: needs: detect-changes if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.create_palette == 'true' uses: ./.github/workflows/plugins-deploy-package.yml - secrets: inherit + secrets: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} with: gh_ref: "${{ inputs.gh_ref || github.ref_name }}" plugin_name: create-palette @@ -99,7 +113,10 @@ jobs: needs: detect-changes if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.icons == 'true' uses: ./.github/workflows/plugins-deploy-package.yml - secrets: inherit + secrets: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} with: gh_ref: "${{ inputs.gh_ref || github.ref_name }}" plugin_name: icons @@ -108,7 +125,10 @@ jobs: needs: detect-changes if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.lorem_ipsum == 'true' uses: ./.github/workflows/plugins-deploy-package.yml - secrets: inherit + secrets: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} with: gh_ref: "${{ inputs.gh_ref || github.ref_name }}" plugin_name: lorem-ipsum @@ -117,7 +137,10 @@ jobs: needs: detect-changes if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.rename_layers == 'true' uses: ./.github/workflows/plugins-deploy-package.yml - secrets: inherit + secrets: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} with: gh_ref: "${{ inputs.gh_ref || github.ref_name }}" plugin_name: rename-layers @@ -126,7 +149,10 @@ jobs: needs: detect-changes if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.table == 'true' uses: ./.github/workflows/plugins-deploy-package.yml - secrets: inherit + secrets: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + MATTERMOST_WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK }} with: gh_ref: "${{ inputs.gh_ref || github.ref_name }}" plugin_name: table diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 53a379b854..65ed5c80f3 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -31,43 +31,32 @@ jobs: steps: - name: Extract some useful variables id: vars + env: + GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT + echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 ref: ${{ steps.vars.outputs.gh_ref }} - # START: Setup Node and PNPM enabling cache + # No dependency cache here: a deploy must not restore a cache that + # other workflows can write to. - name: Setup pnpm + Node.js - uses: pnpm/setup@v2 + uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: working-directory: plugins - runtime: node@24.21.0 + runtime: node@24.22.1 install: false - - name: Get pnpm store path - id: pnpm-store - working-directory: ./plugins - shell: bash - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - - - name: Cache pnpm store - uses: actions/cache@v5 - with: - path: ${{ steps.pnpm-store.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - # END: Setup Node and PNPM enabling cache - - name: Install deps working-directory: ./plugins shell: bash run: | - pnpm install --no-frozen-lockfile; + pnpm install --frozen-lockfile; pnpm add -D -w wrangler@latest; - name: Build styles @@ -76,8 +65,9 @@ jobs: run: pnpm run build:styles-example - name: Select Worker name + env: + REF: ${{ steps.vars.outputs.gh_ref }} run: | - REF="${{ steps.vars.outputs.gh_ref }}" case "$REF" in main) echo "WORKER_NAME=penpot-plugins-styles-doc-pro" >> $GITHUB_ENV @@ -95,7 +85,7 @@ jobs: working-directory: plugins shell: bash run: | - sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml + sed -i "s/WORKER_URI/${WORKER_URI}/g" wrangler-penpot-plugins-styles-doc.toml - name: Add noindex header and robots.txt files for non-production environments if: ${{ steps.vars.outputs.gh_ref != 'main' }} @@ -115,7 +105,7 @@ jobs: EOF - name: Deploy to Cloudflare Workers - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: workingDirectory: plugins apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d54b33ec03..201150b826 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,21 @@ on: description: 'Tag to release' type: string required: true + secrets: + AWS_REGION: + required: false + DOCKER_PASSWORD: + required: false + DOCKER_REGISTRY: + required: false + DOCKER_USERNAME: + required: false + MATTERMOST_WEBHOOK: + required: false + PUB_DOCKER_PASSWORD: + required: false + PUB_DOCKER_USERNAME: + required: false permissions: contents: write @@ -26,18 +41,21 @@ jobs: steps: - name: Extract some useful variables id: vars + env: + GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT + echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 ref: ${{ steps.vars.outputs.gh_ref }} # --- Publicly release the docker images --- - name: Configure ECR credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 with: aws-access-key-id: ${{ secrets.DOCKER_USERNAME }} aws-secret-access-key: ${{ secrets.DOCKER_PASSWORD }} @@ -92,14 +110,19 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT # --- Create GitHub release --- + # Updates the release if it already exists, so re-running is safe. - name: Create GitHub release - uses: softprops/action-gh-release@v2 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.vars.outputs.gh_ref }} - name: ${{ steps.vars.outputs.gh_ref }} - body: ${{ steps.extract_release_notes.outputs.release_notes }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.vars.outputs.gh_ref }} + NOTES: ${{ steps.extract_release_notes.outputs.release_notes }} + run: | + if gh release view "$TAG" > /dev/null 2>&1; then + gh release edit "$TAG" --title "$TAG" --notes "$NOTES" + else + gh release create "$TAG" --verify-tag --title "$TAG" --notes "$NOTES" + fi - name: Notify Mattermost if: failure() diff --git a/.github/workflows/tests-backend.yml b/.github/workflows/tests-backend.yml index 02f839c062..40b99ff3c5 100644 --- a/.github/workflows/tests-backend.yml +++ b/.github/workflows/tests-backend.yml @@ -28,6 +28,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-backend: if: ${{ !github.event.pull_request.draft }} @@ -60,7 +63,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Fmt working-directory: ./backend @@ -75,6 +80,10 @@ jobs: - name: Tests working-directory: ./backend env: + # `clojure.core/assert` is elided at compile time, so without this + # flag the precondition asserts do not exist in the compiled code + # and the suite silently stops checking them. + PENPOT_FLAGS: "enable-backend-asserts" PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test" PENPOT_TEST_DATABASE_USERNAME: penpot_test PENPOT_TEST_DATABASE_PASSWORD: penpot_test diff --git a/.github/workflows/tests-common.yml b/.github/workflows/tests-common.yml index e513efc54f..f8168dbade 100644 --- a/.github/workflows/tests-common.yml +++ b/.github/workflows/tests-common.yml @@ -26,6 +26,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-common: if: ${{ !github.event.pull_request.draft }} @@ -39,7 +42,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Lint working-directory: ./common diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index a70008b4e4..167f00ddf8 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -86,6 +86,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: # ── 1. Build the frontend bundle once ────────────────────────────────── build-bundle: @@ -107,8 +110,9 @@ jobs: # An empty `ref` makes checkout fall back to its default (the PR merge # ref on pull_request, the pushed ref on push). - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.gh_ref }} # The cache key must come from the SHA actually checked out: on a manual @@ -120,7 +124,7 @@ jobs: - name: Restore Bundle Cache id: restore - uses: actions/cache/restore@v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.vars.outputs.bundle_key }} path: frontend/resources/public @@ -133,7 +137,7 @@ jobs: - name: Store Bundle Cache if: steps.restore.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.vars.outputs.bundle_key }} path: frontend/resources/public @@ -163,12 +167,13 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.gh_ref }} - name: Restore Cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ needs.build-bundle.outputs.bundle_key }} path: frontend/resources/public @@ -190,16 +195,18 @@ jobs: working-directory: ./frontend env: WORKERS: ${{ inputs.workers }} + SHARD: ${{ matrix.shard }} + SHARD_TOTAL: ${{ strategy.job-total }} run: | WORKERS=${WORKERS:-2} - echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers" + echo "Running shard ${SHARD}/${SHARD_TOTAL} with $WORKERS workers" pnpm exec playwright test --project default \ --workers="$WORKERS" \ - --shard=${{ matrix.shard }}/${{ strategy.job-total }} \ + --shard="${SHARD}/${SHARD_TOTAL}" \ --reporter=blob - name: Upload blob report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-blob-report-${{ matrix.shard }} @@ -208,7 +215,7 @@ jobs: retention-days: 3 - name: Upload test result - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-tests-result-${{ matrix.shard }} @@ -232,11 +239,13 @@ jobs: - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # The driver serves the prebuilt bundle from frontend/resources/public. - name: Restore Cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ needs.build-bundle.outputs.bundle_key }} path: frontend/resources/public @@ -270,11 +279,13 @@ jobs: - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # Mocked mode serves the prebuilt bundle from frontend/resources/public. - name: Restore Cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ needs.build-bundle.outputs.bundle_key }} path: frontend/resources/public @@ -316,8 +327,9 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.gh_ref }} - name: Install deps @@ -326,7 +338,7 @@ jobs: pnpm install --frozen-lockfile; - name: Download blob reports - uses: actions/download-artifact@v7 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: frontend/all-blob-reports pattern: integration-blob-report-* @@ -355,7 +367,7 @@ jobs: >> "$GITHUB_STEP_SUMMARY" - name: Upload JSON report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-json-report @@ -365,7 +377,7 @@ jobs: retention-days: 7 - name: Upload HTML report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-html-report diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml index 0c8317499c..ed0da17b28 100644 --- a/.github/workflows/tests-exporter.yml +++ b/.github/workflows/tests-exporter.yml @@ -9,6 +9,7 @@ on: paths: - 'exporter/**' - 'common/**' + - 'render-wasm/**' types: - opened @@ -23,11 +24,15 @@ on: paths: - 'exporter/**' - 'common/**' + - 'render-wasm/**' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-exporter: if: ${{ !github.event.pull_request.draft }} @@ -41,7 +46,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Lint working-directory: ./exporter @@ -54,3 +61,8 @@ jobs: working-directory: ./exporter run: | ./scripts/test + + - name: Check no invoke_* in exporter JS glue + working-directory: ./render-wasm + run: | + ./check-no-invoke-v export diff --git a/.github/workflows/tests-frontend.yml b/.github/workflows/tests-frontend.yml index f08f34054d..9a6ff9ae4b 100644 --- a/.github/workflows/tests-frontend.yml +++ b/.github/workflows/tests-frontend.yml @@ -30,6 +30,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-frontend: if: ${{ !github.event.pull_request.draft }} @@ -43,7 +46,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Lint working-directory: ./frontend diff --git a/.github/workflows/tests-library.yml b/.github/workflows/tests-library.yml index 84ab5c9dc9..76dbc0d079 100644 --- a/.github/workflows/tests-library.yml +++ b/.github/workflows/tests-library.yml @@ -28,6 +28,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-library: if: ${{ !github.event.pull_request.draft }} @@ -41,7 +44,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Lint working-directory: ./library diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index f51c56b8a3..8ccf4a083a 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -24,6 +24,9 @@ on: paths: - 'mcp/**' +permissions: + contents: read + jobs: test-mcp: if: ${{ !github.event.pull_request.draft }} @@ -33,7 +36,9 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup working-directory: ./mcp diff --git a/.github/workflows/tests-plugins.yml b/.github/workflows/tests-plugins.yml index 9f6c461da2..fff9c8002e 100644 --- a/.github/workflows/tests-plugins.yml +++ b/.github/workflows/tests-plugins.yml @@ -26,6 +26,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-plugins: if: ${{ !github.event.pull_request.draft }} @@ -38,7 +41,9 @@ jobs: - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install deps working-directory: ./plugins diff --git a/.github/workflows/tests-wasm.yml b/.github/workflows/tests-wasm.yml index 9f26175554..eac2d7ebf3 100644 --- a/.github/workflows/tests-wasm.yml +++ b/.github/workflows/tests-wasm.yml @@ -26,6 +26,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-render-wasm: if: ${{ !github.event.pull_request.draft }} @@ -39,7 +42,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Format working-directory: ./render-wasm @@ -55,3 +60,13 @@ jobs: working-directory: ./render-wasm run: | ./test + + - name: Build + working-directory: ./render-wasm + run: | + NODE_ENV=production ./build frontend + + - name: Check no invoke_*V in JS glue + working-directory: ./render-wasm + run: | + ./check-no-invoke-v diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000000..8f6e7f112d --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,39 @@ +# zizmor configuration — https://docs.zizmor.sh/configuration/ +# +# Every audit is enforced for every workflow. New workflows are checked with +# no exceptions. The entries below are the only remaining ones, and each is +# marked as either a pending clean-up or an accepted risk with its reason. +# Remove a file from a list in the same PR that fixes it, and the whole rule +# entry once its list is empty. +rules: + # Accepted: wrangler is added with `pnpm add wrangler@latest` before each + # deploy instead of being a dependency of the plugins workspace. Only used + # to publish to Cloudflare; not worth adding to the workspace. + adhoc-packages: + ignore: + - plugins-deploy-api-doc.yml + - plugins-deploy-package.yml + - plugins-deploy-styles-doc.yml + # Accepted: auto-label.yml needs pull_request_target so the GitHub App + # secrets are available for PRs from forks. It never checks out or runs + # PR code, it only reads ids from the event payload. + dangerous-triggers: + ignore: + - auto-label.yml + # Style nudge towards the `$/...` syntax; not worth enforcing. + self-repository: + disable: true + # Accepted: penpotapp/devenv is our own image, built and pushed by + # build-docker-devenv.yml, so the floating tag is intentional. + unpinned-images: + ignore: + - plugins-deploy-package.yml + - tests-backend.yml + - tests-common.yml + - tests-e2e.yml + - tests-exporter.yml + - tests-frontend.yml + - tests-library.yml + - tests-mcp.yml + - tests-plugins.yml + - tests-wasm.yml diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index f28a277b07..81fae7a791 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ @@ -66,8 +66,8 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.21.0 \ - PNPM_VERSION=12.5.1 \ +ENV NODE_VERSION=v24.22.1 \ + PNPM_VERSION=12.15.0 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -76,14 +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'; \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='84e1290e82c800acd406b6db27e9650e15db3c2344d12162cc305ea1f942c6ff'; \ + 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'; \ PNPM_ARCH='x64'; \ - PNPM_SHA256='5a397dfb6b3d4b07d3d7769586aeb471048faf04299a492e2808b95a9a1c701f'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -91,6 +93,7 @@ RUN set -eux; \ ;; \ esac; \ curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \ + echo "${NODE_SHA256} */tmp/nodejs.tar.gz" | sha256sum -c -; \ mkdir -p /opt/node; \ cd /opt/node; \ tar -xf /tmp/nodejs.tar.gz --strip-components=1; \ @@ -103,72 +106,13 @@ RUN set -eux; \ find /opt/node/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \; ; \ rm -f /tmp/nodejs.tar.gz /tmp/pnpm.tar.gz; -################################################################################ -## OPENCODE SETUP -################################################################################ - -FROM base AS setup-opencode - -ENV OPENCODE_VERSION=1.18.31 - -RUN set -ex; \ - ARCH="$(dpkg --print-architecture)"; \ - case "${ARCH}" in \ - aarch64|arm64) \ - BINARY_URL="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-arm64.tar.gz"; \ - ;; \ - amd64|x86_64) \ - BINARY_URL="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz"; \ - ;; \ - *) \ - echo "Unsupported arch: ${ARCH}"; exit 1; \ - ;; \ - esac; \ - curl -fsSL ${BINARY_URL} -o /tmp/opencode.tar.gz; \ - mkdir -p /tmp/opencode; \ - tar -xzf /tmp/opencode.tar.gz -C /tmp/opencode; \ - chmod +x /tmp/opencode/opencode; \ - rm -f /tmp/opencode.tar.gz; - -################################################################################ -## OPENCODE2 SETUP (V2, side-by-side with V1 `opencode`) -################################################################################ - -FROM base AS setup-opencode2 - -ENV OPENCODE2_VERSION=2.0.12 - -RUN set -eux; \ - ARCH="$(dpkg --print-architecture)"; \ - case "${ARCH}" in \ - aarch64|arm64) \ - BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-arm64/-/cli-linux-arm64-${OPENCODE2_VERSION}.tgz"; \ - ESUM='33f0dde9f0f055b6a3665d290371bcfc8c63daceade7a535c4b94d386f2bd917'; \ - ;; \ - amd64|x86_64) \ - BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-${OPENCODE2_VERSION}.tgz"; \ - ESUM='2a79beb2e24382cb2bdbb709237dbc22f498cf52a106d029a6a3a3243760c78b'; \ - ;; \ - *) \ - echo "Unsupported arch: ${ARCH}"; exit 1; \ - ;; \ - esac; \ - curl -LfsSo /tmp/opencode2.tgz "${BINARY_URL}"; \ - echo "${ESUM} */tmp/opencode2.tgz" | sha256sum -c -; \ - mkdir -p /tmp/opencode2; \ - tar -xzf /tmp/opencode2.tgz -C /tmp/opencode2 --strip-components=2 package/bin; \ - mv /tmp/opencode2/opencode /tmp/opencode2/opencode2; \ - chmod +x /tmp/opencode2/opencode2; \ - rm -f /tmp/opencode2.tgz; - - ################################################################################ ## CADDYSERVER SETUP ################################################################################ FROM base AS setup-caddy -ENV CADDY_VERSION=2.11.4 +ENV CADDY_VERSION=2.11.7 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ @@ -282,13 +226,13 @@ RUN set -eux; \ FROM base AS setup-utils -ENV CLJKONDO_VERSION=2026.08.04 \ - BABASHKA_VERSION=1.13.223 \ - CLJFMT_VERSION=0.16.5 \ - PIXI_VERSION=0.81.0 \ - GITHUB_CLI_VERSION=2.101.0 \ - AWS_CLI_VERSION=2.36.49 \ - UV_VERSION=0.12.17 \ +ENV CLJKONDO_VERSION=2026.10.20 \ + BABASHKA_VERSION=1.13.226 \ + CLJFMT_VERSION=0.16.6 \ + PIXI_VERSION=0.83.0 \ + GITHUB_CLI_VERSION=2.103.0 \ + AWS_CLI_VERSION=2.38.5 \ + UV_VERSION=0.13.5 \ UV_TOOL_DIR=/opt/uv/tools \ UV_TOOL_BIN_DIR=/opt/utils/bin \ UV_PYTHON_INSTALL_DIR=/opt/uv/python \ @@ -403,11 +347,11 @@ RUN set -ex; \ case "${ARCH}" in \ aarch64|arm64) \ BINARY_ARCH="aarch64"; \ - BINARY_SHA256="2dec26fdaff2f5e34961ea98f456297104f73d8409fc3599c8f2909c6e27a07e"; \ + BINARY_SHA256="07711a936e9d271045ab937d2c2c5390c25f419e4ba9e25adb9e996031e3f6be"; \ ;; \ amd64|x86_64) \ BINARY_ARCH="x86_64"; \ - BINARY_SHA256="6b51b62d232217350e72d8f2493f47d3461239d011d1d5eef960dfcd8cb07069"; \ + BINARY_SHA256="5def7d1b7e55d892f9a381bb2cb2081a685712464be0d36d5d706af0ac48d0d3"; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -476,6 +420,7 @@ RUN set -ex; \ nginx \ fd-find \ bat \ + gettext \ \ fontconfig \ woff-tools \ @@ -525,7 +470,6 @@ RUN set -ex; \ libjpeg62-turbo \ libtiff6 \ libwebp7 \ - libopenexr-3-1-30 \ libfreetype6 \ libfontconfig1 \ libglib2.0-0 \ @@ -575,7 +519,7 @@ ENV LANG='C.UTF-8' \ SERENA_CONTEXT="claude-code" \ PATH="/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" -COPY --from=penpotapp/imagemagick:7.1.2-27 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick COPY --from=setup-jvm /opt/jdk /opt/jdk COPY --from=setup-jvm /opt/clojure /opt/clojure COPY --from=setup-node /opt/node /opt/node @@ -586,8 +530,6 @@ COPY --from=setup-rust /opt/cargo /opt/cargo COPY --from=setup-rust /opt/rustup /opt/rustup COPY --from=setup-rust /opt/emsdk /opt/emsdk COPY --from=setup-caddy /usr/bin/caddy /usr/bin/caddy -COPY --from=setup-opencode /tmp/opencode/opencode /opt/utils/bin/opencode -COPY --from=setup-opencode2 /tmp/opencode2/opencode2 /opt/utils/bin/opencode2 COPY files/nginx.conf /etc/nginx/nginx.conf COPY files/nginx-mime.types /etc/nginx/mime.types diff --git a/docker/devenv/docker-compose.infra.yml b/docker/devenv/docker-compose.infra.yml index 6a876b36ad..a5c9a7d5b8 100644 --- a/docker/devenv/docker-compose.infra.yml +++ b/docker/devenv/docker-compose.infra.yml @@ -15,7 +15,7 @@ volumes: services: rustfs: - image: "rustfs/rustfs:1.0.0" + image: "rustfs/rustfs:1.0.3" command: /data volumes: @@ -68,7 +68,7 @@ services: - postgres valkey: - image: valkey/valkey:8.1 + image: valkey/valkey:8.1.10 restart: always command: valkey-server --save 120 1 --loglevel warning volumes: @@ -79,7 +79,7 @@ services: - valkey mailer: - image: axllent/mailpit:v1.31.1 + image: axllent/mailpit:v1.32.0 restart: always volumes: - "mailpit_data:/data" diff --git a/docker/devenv/docker-compose.main.yml b/docker/devenv/docker-compose.main.yml index e593ad1ba7..219e348a56 100644 --- a/docker/devenv/docker-compose.main.yml +++ b/docker/devenv/docker-compose.main.yml @@ -81,6 +81,11 @@ services: - PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD} - PENPOT_DATABASE_MAX_POOL_SIZE=${PENPOT_DATABASE_MAX_POOL_SIZE} - PENPOT_REDIS_URI=${PENPOT_REDIS_URI} + # Per-instance backend-test config. Values come from + # instance-env-overrides in manage.sh; the main database above stays + # shared by all instances on purpose. + - PENPOT_TEST_DATABASE_URI=${PENPOT_TEST_DATABASE_URI} + - PENPOT_TEST_REDIS_URI=${PENPOT_TEST_REDIS_URI} - PENPOT_OBJECTS_STORAGE_BACKEND=${PENPOT_OBJECTS_STORAGE_BACKEND} - PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT} - PENPOT_OBJECTS_STORAGE_S3_BUCKET=${PENPOT_OBJECTS_STORAGE_S3_BUCKET} @@ -93,7 +98,7 @@ services: # Agentic devenv: set to a PyPI release version/tag to update Serena on startup, # leave empty to skip update and use the version baked into the image. - - SERENA_UPDATE_VERSION=1.6.1 + - SERENA_UPDATE_VERSION= - SHADOW_SERVER_URL=${SHADOW_SERVER_URL} networks: diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index 5097ab010e..5fb66c9613 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -27,7 +27,7 @@ fi # pnpm export PNPM_HOME="/home/penpot/.local/share/pnpm" case ":$PATH:" in - *":$PNPM_HOME:"*) ;; - *) export PATH="$PNPM_HOME:$PATH" ;; + *":$PNPM_HOME/bin:"*) ;; + *) export PATH="$PNPM_HOME/bin:$PATH" ;; esac # pnpm end diff --git a/docker/devenv/files/entrypoint.sh b/docker/devenv/files/entrypoint.sh index d7a00a670a..193005d3fe 100755 --- a/docker/devenv/files/entrypoint.sh +++ b/docker/devenv/files/entrypoint.sh @@ -10,6 +10,10 @@ cp /root/.bashrc /home/penpot/.bashrc cp /root/.vimrc /home/penpot/.vimrc cp /root/.tmux.conf /home/penpot/.tmux.conf +chown -R penpot:penpot /home/penpot/.bashrc +chown -R penpot:penpot /home/penpot/.vimrc +chown -R penpot:penpot /home/penpot/.tmux.conf + # Seed SERENA_HOME with default config on first run mkdir -p ${SERENA_HOME} if [ ! -f "${SERENA_HOME}/serena_config.yml" ]; then @@ -21,6 +25,16 @@ chown penpot:users /home/penpot # we need to be able to install rust-analyzer and possibly other dependencies with rustup chown -R penpot:penpot /opt/rustup +# pnpm keeps its global dir (bin, global, store, package-manager-store) +# under ~/.local and its metadata cache under ~/.cache; opencode and other +# user-space tools write there too. Hand both trees back to penpot so +# packages, and pnpm itself, can be updated without root. Needed because the +# UID remap above can leave everything the image created owned by the old +# UID. ~/.config is deliberately excluded: the opencode overlay bind-mounts +# a host directory inside it. +mkdir -p /home/penpot/.local /home/penpot/.cache +chown -R penpot:penpot /home/penpot/.local /home/penpot/.cache + rsync -ar --chown=penpot:users /opt/cargo/ /home/penpot/.cargo/ export JAVA_OPTS="-Djava.net.preferIPv4Stack=true" diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 46246e5153..5a0a81f091 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -58,6 +58,13 @@ http { '' close; } + # Link preview crawlers; their requests for the application root + # are served with dynamic Open Graph metadata from the backend. + map $http_user_agent $penpot_link_preview_agent { + default 0; + "~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky|applebot|googlebot|bingbot|bingpreview|duckduckbot|mattermost-bot|opengraph[.]xyz|twitter card validator)" 1; + } + proxy_cache_path /tmp/cache/ levels=2:2 keys_zone=penpot:20m; proxy_cache_methods GET HEAD; proxy_cache_valid any 48h; @@ -125,6 +132,10 @@ http { add_header x-internal-redirect "$upstream_http_x_accel_redirect"; } + location = /link-preview { + proxy_pass http://127.0.0.1:6060/link-preview$is_args$args; + } + # On production, this is controlled by ELB location /api/export { proxy_pass http://127.0.0.1:6061; @@ -170,11 +181,6 @@ http { proxy_http_version 1.1; } - location /mcp/sse { - proxy_pass http://127.0.0.1:4401/sse; - proxy_http_version 1.1; - } - location /admin { proxy_pass http://127.0.0.1:6063/admin; } @@ -301,6 +307,10 @@ http { return 301 " /404"; } + if ($penpot_link_preview_agent) { + rewrite ^/$ /link-preview last; + } + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "no-store" always; try_files $uri /index.html$is_args$args /index.html =404; diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index 92ff766acb..f875c09a95 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -6,11 +6,10 @@ ENV LANG='C.UTF-8' \ DEBIAN_FRONTEND=noninteractive \ TZ=Etc/UTC -ARG IMAGEMAGICK_VERSION=7.1.2-27 +ARG IMAGEMAGICK_VERSION=7.1.2-32 RUN set -e; \ apt-get -qq update; \ - apt-get -qq upgrade; \ apt-get -qqy --no-install-recommends install init-system-helpers; \ apt-get -qqy --no-install-recommends install \ autoconf \ @@ -24,7 +23,6 @@ RUN set -e; \ liblcms2-dev \ libltdl-dev \ liblzma-dev \ - libopenexr-dev \ libxml2-dev \ libpng-dev \ librsvg2-dev \ @@ -39,7 +37,7 @@ RUN set -e; \ mkdir -p /tmp/magick; \ cd /tmp/magick; \ tar -xf /tmp/magick.tar.gz --strip-components=1; \ - ./configure --prefix=/opt/imagick; \ + ./configure --prefix=/opt/imagick --without-openexr; \ make -j 2; \ make install; \ rm -rf /opt/imagick/lib/libMagick++*; \ @@ -58,7 +56,6 @@ RUN set -ex; \ libheif1 \ libjpeg62-turbo \ liblcms2-2 \ - libopenexr-3-1-30 \ libopenjp2-7 \ libpng16-16 \ librsvg2-2 \ @@ -77,7 +74,7 @@ RUN set -ex; \ rm -rf /var/lib/apt/lists/* -FROM dhi.io/debian-base:trixie-debian13 AS image +FROM dhi.io/debian-base:trixie-debian13@sha256:de80b468d8c492508f24b30e529accfaa8c59fe4505ddd72e969b324d276ad21 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index c5b16e9756..32e8c2c1fa 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -9,11 +9,11 @@ ENV LANG='C.UTF-8' \ RUN set -ex; \ apt-get -qq update; \ - apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \ apt-get -qqy --no-install-recommends install \ binutils \ ca-certificates \ curl \ + gzip \ ; \ apt-get clean; \ rm -rf /var/lib/apt/lists/* @@ -27,12 +27,12 @@ RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ - ESUM='5b222fce0b7076a10ac7ae3b1009a6c2caf4f35bc4e81de72010af6750c5e146'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_aarch64.tar.gz'; \ + ESUM='8ff07320636ef5b30f0d7e16687932df52255c3ef3ffd920a6aae3723511ce7f'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.203-ca-jdk26.0.2.1-linux_aarch64.tar.gz'; \ ;; \ amd64|x86_64) \ - ESUM='4b7c114917aebd0fc6284fc7111245d7747a4d9603bd12d86b384b1abc9d575d'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_x64.tar.gz'; \ + ESUM='c6b810ca659e3293205444f47ac4af163396fd6d025c51fd5f767e628b2ceede'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.203-ca-jdk26.0.2.1-linux_x64.tar.gz'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -53,7 +53,7 @@ RUN set -eux; \ --output /opt/jre; -FROM dhi.io/debian-base:trixie-debian13-dev AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -68,7 +68,6 @@ RUN set -ex; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install passwd; \ useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ - apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \ apt-get -qqy --no-install-recommends install \ ca-certificates \ curl \ @@ -81,7 +80,6 @@ RUN set -ex; \ libheif1 \ libjpeg62-turbo \ liblcms2-2 \ - libopenexr-3-1-30 \ libopenjp2-7 \ libpng16-16 \ librsvg2-2 \ @@ -108,7 +106,7 @@ RUN set -ex; \ chown -R penpot:penpot /opt/data; COPY --from=build /opt/jre /opt/jre -COPY --from=penpotapp/imagemagick:7.1.2-27 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick COPY files/imagemagick-policy.xml /opt/imagick/etc/ImageMagick-7/policy.xml diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index a186917a97..97daca2dfe 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.20.0-debian13-dev +FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ @@ -11,12 +11,12 @@ RUN set -ex; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install passwd; \ useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ - apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \ apt-get -qqy --no-install-recommends install \ curl \ tzdata \ locales \ ca-certificates \ + gzip \ ; \ apt-get clean; \ rm -rf /var/lib/apt/lists/*; \ @@ -26,7 +26,7 @@ RUN set -ex; \ RUN set -ex; \ apt-get -qq update; \ - apt-get -qqy install \ + apt-get -qqy --no-install-recommends install \ \ xvfb \ fonts-noto-color-emoji \ @@ -37,10 +37,12 @@ RUN set -ex; \ xfonts-scalable \ fonts-liberation \ fonts-ipafont-gothic \ + fonts-ipafont-mincho \ fonts-wqy-zenhei \ fonts-tlwg-loma-otf \ fonts-freefont-ttf \ poppler-utils \ + poppler-data \ \ libasound2 \ libatk-bridge2.0-0 \ @@ -68,7 +70,6 @@ RUN set -ex; \ libheif1 \ libjpeg62-turbo \ liblcms2-2 \ - libopenexr-3-1-30 \ libopenjp2-7 \ libpng16-16 \ librsvg2-2 \ @@ -87,24 +88,24 @@ RUN set -ex; \ ARG BUNDLE_PATH="./bundle-exporter/" COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/exporter/ -COPY --from=penpotapp/imagemagick:7.1.2-27 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick 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.5.1 +ARG PNPM_VERSION=12.15.0 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='84e1290e82c800acd406b6db27e9650e15db3c2344d12162cc305ea1f942c6ff'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='5a397dfb6b3d4b07d3d7769586aeb471048faf04299a492e2808b95a9a1c701f'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index a134f15f27..26f84d4640 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.1-debian13-dev +FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:940ee353dd807f5c5e32720923bb9b34ef9e631ae567320f3eaea7797d6c7964 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -7,7 +7,6 @@ USER root RUN set -ex; \ apt-get -qq update; \ - apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \ apt-get -qqy -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" --no-install-recommends install bash gettext-base passwd; \ rm -rf /var/lib/apt/lists/*; \ groupadd -f -g 1001 penpot; \ @@ -27,11 +26,20 @@ COPY ./files/nginx.conf.template /tmp/nginx.conf.template COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template COPY ./files/nginx-admin-console-locations.conf.template /tmp/nginx-admin-console-locations.conf.template COPY ./files/nginx-mcp-locations.conf.template /tmp/nginx-mcp-locations.conf.template -COPY ./files/nginx-security-headers.conf /etc/nginx/nginx-security-headers.conf +COPY ./files/nginx-security-headers.conf.template /tmp/nginx-security-headers.conf.template COPY ./files/nginx-mime.types /etc/nginx/mime.types COPY ./files/nginx-external-locations.conf /etc/nginx/overrides/location.d/external-locations.conf COPY ./files/nginx-entrypoint.sh /entrypoint.sh +# The CSP hashes of the inline scripts of index.html are emitted by the +# frontend build. Move them out of the document root: nginx must read them, +# the browser has no reason to. +RUN if [ -f /var/www/app/csp-script-hashes.txt ]; then \ + mv /var/www/app/csp-script-hashes.txt /etc/nginx/csp-script-hashes.txt; \ + else \ + echo "WARNING: the frontend bundle does not provide csp-script-hashes.txt" >&2; \ + fi + RUN chown -R 1001:0 /var/cache/nginx; \ chmod -R g+w /var/cache/nginx; \ chown -R 1001:0 /etc/nginx; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index 4d62675bec..f018302d33 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.20.0-debian13-dev AS build +FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -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.5.1 +ARG PNPM_VERSION=12.15.0 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='84e1290e82c800acd406b6db27e9650e15db3c2344d12162cc305ea1f942c6ff'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='5a397dfb6b3d4b07d3d7769586aeb471048faf04299a492e2808b95a9a1c701f'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.20.0-debian13 AS image +FROM dhi.io/node:24.22.1-debian13@sha256:32a2fa6af5209698a9354cef0b19ada3dbfac3090b1010b70afa9f1a298ba2d9 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 a147b9d8b3..ee0694bc34 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -1,19 +1,16 @@ -FROM ubuntu:26.04 +# syntax=docker/dockerfile:1 +FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - NODE_VERSION=v24.20.0 \ - PNPM_VERSION=12.5.1 \ - DEBIAN_FRONTEND=noninteractive \ - PATH=/opt/node/bin:$PATH + DEBIAN_FRONTEND=noninteractive +# passwd provides useradd, not preinstalled on the DHI base image. RUN set -ex; \ - useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ - mkdir -p /etc/resolvconf/resolv.conf.d; \ - echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \ apt-get -qq update; \ - apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \ + apt-get -qqy --no-install-recommends install passwd; \ + useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ apt-get -qqy --no-install-recommends install \ curl \ tzdata \ @@ -35,9 +32,8 @@ RUN set -ex; \ \ libgomp1 \ libheif1 \ - libjpeg-turbo8 \ + libjpeg62-turbo \ liblcms2-2 \ - libopenexr-3-1-30 \ libopenjp2-7 \ libpng16-16 \ librsvg2-2 \ @@ -45,44 +41,12 @@ RUN set -ex; \ libwebp7 \ libwebpdemux2 \ libwebpmux3 \ - libxml2-16 \ + libxml2 \ libzip5 \ libzstd1 \ ; \ apt-get clean; \ - rm -rf /var/lib/apt/lists/*; - -RUN set -eux; \ - ARCH="$(dpkg --print-architecture)"; \ - case "${ARCH}" in \ - aarch64|arm64) \ - BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ - PNPM_ARCH='arm64'; \ - PNPM_SHA256='84e1290e82c800acd406b6db27e9650e15db3c2344d12162cc305ea1f942c6ff'; \ - ;; \ - amd64|x86_64) \ - BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ - PNPM_ARCH='x64'; \ - PNPM_SHA256='5a397dfb6b3d4b07d3d7769586aeb471048faf04299a492e2808b95a9a1c701f'; \ - ;; \ - *) \ - echo "Unsupported arch: ${ARCH}"; \ - exit 1; \ - ;; \ - esac; \ - curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \ - mkdir -p /opt/node; \ - cd /opt/node; \ - tar -xf /tmp/nodejs.tar.gz --strip-components=1; \ - chown -R root /opt/node; \ - rm -rf /tmp/nodejs.tar.gz; \ - PNPM_URL="https://github.com/pnpm/pnpm/releases/download/v${PNPM_VERSION}/pnpm-linux-${PNPM_ARCH}.tar.gz"; \ - curl -LfsSo /tmp/pnpm.tar.gz "${PNPM_URL}"; \ - echo "${PNPM_SHA256} */tmp/pnpm.tar.gz" | sha256sum -c -; \ - tar -xzf /tmp/pnpm.tar.gz -C /opt/node/bin pnpm; \ - chmod 755 /opt/node/bin/pnpm; \ - rm -f /tmp/pnpm.tar.gz; \ - pnpm --version; \ + rm -rf /var/lib/apt/lists/*; \ mkdir -p /opt/penpot; \ chown -R penpot:penpot /opt/penpot; @@ -90,8 +54,42 @@ ARG BUNDLE_PATH="./bundle-media-processor/" COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/media-processor/ 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 +RUN set -eux; \ + apt-get -qq update; \ + apt-get -qqy --no-install-recommends install gzip; \ + rm -rf /var/lib/apt/lists/*; \ + ARCH="$(dpkg --print-architecture)"; \ + case "${ARCH}" in \ + aarch64|arm64) \ + PNPM_ARCH='arm64'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + ;; \ + amd64|x86_64) \ + PNPM_ARCH='x64'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + ;; \ + *) \ + echo "Unsupported arch: ${ARCH}"; \ + exit 1; \ + ;; \ + esac; \ + curl -LfsSo /tmp/pnpm.tar.gz "https://github.com/pnpm/pnpm/releases/download/v${PNPM_VERSION}/pnpm-linux-${PNPM_ARCH}.tar.gz"; \ + echo "${PNPM_SHA256} */tmp/pnpm.tar.gz" | sha256sum -c -; \ + mkdir -p /usr/local/bin; \ + tar -xzf /tmp/pnpm.tar.gz -C /usr/local/bin pnpm; \ + chmod 755 /usr/local/bin/pnpm; \ + rm -f /tmp/pnpm.tar.gz; \ + pnpm --version; + +# Runs as root: this base image installs Node system-wide (symlinked into +# /usr/bin), so ./setup needs write access there. +RUN ./setup && chown -R penpot:penpot /opt/penpot/media-processor + USER penpot:penpot -RUN ./setup - CMD ["node", "dist/index.js"] diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 3904604a25..2b259ffb61 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.1-alpine3.22 +FROM dhi.io/nginx:1.31.7-alpine3.24@sha256:6d3811fbc76c5d0652752b39a003769759ccfeb2c1e874bacf99e3d44e5da3b9 LABEL maintainer="Penpot " ARG BUNDLE_PATH="./bundle-storybook/" diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index df846c43b4..4c69760e24 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -26,6 +26,9 @@ x-flags: &penpot-flags PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console +## Public URI where Penpot is reached. To serve Penpot from a subpath, include +## it with a trailing slash, e.g. https://example.com/penpot/. A reverse proxy in +## front must forward the subpath unchanged, without rewriting it. x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: http://localhost:9001 @@ -38,7 +41,7 @@ x-body-size: &penpot-http-body-size ## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems ## (eg http sessions, or invitations) are derived. ## -## We recommend to use a trully randomly generated +## We recommend to use a truly randomly generated ## 512 bits base64 encoded string here. You can generate one with: ## ## python3 -c "import secrets; print(secrets.token_urlsafe(64))" @@ -58,6 +61,7 @@ networks: volumes: penpot_postgres_v15: penpot_assets: + # penpot_mailpit_data: # penpot_traefik: services: @@ -109,6 +113,8 @@ services: # ## internet with HTTPS using traefik. # - "traefik.http.routers.penpot-https.rule=Host(``)" + # ## When Penpot is served from a subpath, match it too: + # - "traefik.http.routers.penpot-https.rule=Host(``) && PathPrefix(`/`)" # - "traefik.http.routers.penpot-https.entrypoints=websecure" # - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt" # - "traefik.http.routers.penpot-https.tls=true" @@ -139,6 +145,9 @@ services: networks: - penpot + ## Configuration environment variables for the backend container. + + environment: << : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key, *penpot-database] @@ -169,13 +178,13 @@ services: PENPOT_TELEMETRY_ENABLED: "true" PENPOT_TELEMETRY_REFERER: compose - ## Example SMTP/Email configuration. By default, emails are sent to the mailcatch + ## Example SMTP/Email configuration. By default, emails are sent to the Mailpit ## service, but for production usage it is recommended to setup a real SMTP ## provider. Emails are used to confirm user registrations & invitations. Look below - ## how the mailcatch service is configured. + ## how the Mailpit service is configured. PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com - PENPOT_SMTP_HOST: penpot-mailcatch + PENPOT_SMTP_HOST: penpot-mailpit PENPOT_SMTP_PORT: 1025 PENPOT_SMTP_USERNAME: PENPOT_SMTP_PASSWORD: @@ -224,9 +233,19 @@ services: # communicate with the frontend. PENPOT_INTERNAL_URI: http://penpot-frontend:8080 - ## Valkey (or previously Redis) is used for the websockets notifications. + ## Valkey (or previously Redis) is used for the websockets notifications + ## and for storing the state export jobs PENPOT_REDIS_URI: redis://penpot-valkey/0 + # PENPOT_EXPORTER_MAX_CONCURRENT_JOBS: 4 + # PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE: 2 + # PENPOT_EXPORTER_QUEUE_MAX: 64 + # PENPOT_EXPORTER_JOB_TTL: 3600 + # PENPOT_WASM_WORKER_POOL_MAX: 2 + # PENPOT_WASM_WORKER_POOL_MIN: 1 + # PENPOT_WASM_WORKER_IDLE_TIMEOUT: 300 + # PENPOT_WASM_WORKER_IMAGE_CACHE_SIZE: 134217728 + penpot-postgres: image: "postgres:15" restart: always @@ -270,16 +289,22 @@ services: # although this should not be necessary. - VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu - ## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the - ## port 1080 for read all emails the penpot platform has sent. Should be only used as a - ## temporal solution while no real SMTP provider is configured. + ## A Mailpit service, used as temporal SMTP server. You can access via HTTP to the + ## port 8025 to read all emails the penpot platform has sent. Should be only used as a + ## temporal solution while no real SMTP provider is configured. Messages are kept in + ## memory only and are lost when the container is recreated; uncomment the volume + ## below (and the one in the top-level volumes: section) to persist them instead. - penpot-mailcatch: - image: sj26/mailcatcher:latest + penpot-mailpit: + image: axllent/mailpit:v1 restart: always + # volumes: + # - penpot_mailpit_data:/data + # environment: + # - MP_DATABASE=/data/mailpit.db expose: - '1025' ports: - - "1080:1080" + - "8025:8025" networks: - penpot diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template index fead3783c9..a2a90913b0 100644 --- a/docker/images/files/nginx-admin-console-locations.conf.template +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -1,7 +1,11 @@ +# The Admin Console is served at the root of its own server. Keep proxy_pass +# without a URI: building it from $request_uri escapes the path twice. location /admin-console { + rewrite "^/admin-console(?:/(.*))?$" /$1 break; + proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $http_cf_connecting_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI; } diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 47a8a5544b..d60a5d691f 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -51,6 +51,23 @@ update_oidc_name /var/www/app/js/config.js ## Nginx Config ######################################### +# Path of PENPOT_PUBLIC_URI (e.g. /penpot/ for https://example.com/penpot/). +# Requests below it are rewritten to the root, so the proxy in front of this +# server forwards them unchanged. +PENPOT_PUBLIC_PATH="$(sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://[^/]*||; s|[?#].*$||' <<< "${PENPOT_PUBLIC_URI:-}")" +PENPOT_PUBLIC_PATH="/${PENPOT_PUBLIC_PATH#/}" +export PENPOT_PUBLIC_PATH="${PENPOT_PUBLIC_PATH%/}/" + +if [ "$PENPOT_PUBLIC_PATH" != "/" ]; then + # shellcheck disable=SC2016 # literal regex metacharacters + _public_path_regex="$(sed -E 's/[][\.*^$(){}+?|]/\\&/g' <<< "$PENPOT_PUBLIC_PATH")" + export PENPOT_PUBLIC_PATH_DIRECTIVES="location = ${PENPOT_PUBLIC_PATH%/} { absolute_redirect off; return 301 ${PENPOT_PUBLIC_PATH}\$is_args\$args; } + rewrite ^${_public_path_regex}(.*)\$ /\$1 last;" + unset _public_path_regex +else + export PENPOT_PUBLIC_PATH_DIRECTIVES="" +fi + export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB @@ -58,7 +75,7 @@ export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::] if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then export PENPOT_IPV6_LISTEN_DIRECTIVE="" fi -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE,\$PENPOT_PUBLIC_PATH,\$PENPOT_PUBLIC_PATH_DIRECTIVES" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then @@ -84,4 +101,124 @@ export PENPOT_INTERNAL_RESOLVER=${PENPOT_INTERNAL_RESOLVER:-$PENPOT_DEFAULT_INTE envsubst "\$PENPOT_INTERNAL_RESOLVER" \ < /tmp/resolvers.conf.template > /etc/nginx/overrides/http.d/resolvers.conf +######################################### +## Security Headers Config +######################################### + +# The default policy describes what a stock Penpot deployment actually +# needs: 'wasm-unsafe-eval' for the render engine, 'unsafe-inline' styles +# for the inline style attributes emitted by the UI, and blob:/data: for +# thumbnails, exports and font handling. Everything else is same-origin, +# because the Google Fonts and GitHub templates endpoints are reverse +# proxied by this very server. +# +# The hashes of the inline scripts of index.html are emitted by the frontend +# build and moved to /etc/nginx at image build time. A bundle predating that +# change simply yields no hashes, in which case those scripts would be +# reported (or blocked under enforce) as before. +# +# It ships in report-only mode because deployments with plugins enabled still +# report eval and remote fetch violations from the SES sandbox. Enforcing mode +# stays opt-in until that is resolved. +export PENPOT_CSP_MODE=${PENPOT_CSP_MODE:-report-only} + +if [ -r /etc/nginx/csp-script-hashes.txt ]; then + PENPOT_CSP_SCRIPT_HASHES=" $(tr -d '\n' < /etc/nginx/csp-script-hashes.txt)" +else + PENPOT_CSP_SCRIPT_HASHES="" +fi + +# Remember whether the policy comes from the deployment before the default +# is applied, so the warning below only fires for the default one. +if [ -n "${PENPOT_CSP_POLICY:-}" ]; then + PENPOT_CSP_POLICY_IS_CUSTOM="true" +else + PENPOT_CSP_POLICY_IS_CUSTOM="false" +fi + +# Directives a deployment may extend. The default policy carries hashes that +# change on every build, so a deployment that needs an extra origin cannot +# hardcode the whole policy without recomputing them at each release. These +# variables let it declare only what it adds. +# +# base-uri, form-action, object-src and frame-ancestors are deliberately not +# extensible: there is no legitimate reason to relax them, and doing so +# silently removes the protection they provide. A deployment that really +# needs it can still set PENPOT_CSP_POLICY and own the whole policy. +if [ "${PENPOT_CSP_POLICY_IS_CUSTOM}" = "false" ]; then + export PENPOT_CSP_POLICY="default-src 'self'\ +; base-uri 'self'\ +; object-src 'none'\ +; frame-ancestors 'self'\ +; form-action 'self'\ +; manifest-src 'self'\ +; script-src 'self' 'wasm-unsafe-eval'${PENPOT_CSP_SCRIPT_HASHES}${PENPOT_CSP_SCRIPT_SRC_EXTRA:+ ${PENPOT_CSP_SCRIPT_SRC_EXTRA}}\ +; style-src 'self' 'unsafe-inline'${PENPOT_CSP_STYLE_SRC_EXTRA:+ ${PENPOT_CSP_STYLE_SRC_EXTRA}}\ +; img-src 'self' data: blob:${PENPOT_CSP_IMG_SRC_EXTRA:+ ${PENPOT_CSP_IMG_SRC_EXTRA}}\ +; font-src 'self'${PENPOT_CSP_FONT_SRC_EXTRA:+ ${PENPOT_CSP_FONT_SRC_EXTRA}}\ +; connect-src 'self' blob: data:${PENPOT_CSP_CONNECT_SRC_EXTRA:+ ${PENPOT_CSP_CONNECT_SRC_EXTRA}}\ +; frame-src 'self'${PENPOT_CSP_FRAME_SRC_EXTRA:+ ${PENPOT_CSP_FRAME_SRC_EXTRA}}\ +; worker-src 'self' blob:\ +; media-src 'self' blob:${PENPOT_CSP_REPORT_URI:+; report-uri ${PENPOT_CSP_REPORT_URI}}" +else + for _var in SCRIPT_SRC_EXTRA STYLE_SRC_EXTRA IMG_SRC_EXTRA FONT_SRC_EXTRA \ + CONNECT_SRC_EXTRA FRAME_SRC_EXTRA REPORT_URI; do + eval "_value=\${PENPOT_CSP_${_var}:-}" + if [ -n "${_value}" ]; then + echo "penpot: WARNING: PENPOT_CSP_${_var} is ignored because PENPOT_CSP_POLICY defines the whole policy." >&2 + fi + done + unset _var _value + export PENPOT_CSP_POLICY +fi + +case "${PENPOT_CSP_MODE}" in + enforce) + export PENPOT_CSP_DIRECTIVE="add_header Content-Security-Policy \"${PENPOT_CSP_POLICY}\" always;" + # The plugin runtime initialises on every page load, whether or not a + # plugin is opened, and its sandbox needs 'unsafe-eval'. Look at the + # policy that will actually be served rather than at where it came + # from, since it can be granted through the default policy, through + # PENPOT_CSP_SCRIPT_SRC_EXTRA or through a policy of your own. + case "${PENPOT_CSP_POLICY}" in + *"'unsafe-eval'"*) ;; + *) + echo "penpot: WARNING: PENPOT_CSP_MODE=enforce degrades the plugin runtime." >&2 + echo "penpot: it initialises on every page load and needs 'unsafe-eval', which this policy does not grant." >&2 + echo "penpot: add it through PENPOT_CSP_SCRIPT_SRC_EXTRA if you need plugins, or keep report-only." >&2 + ;; + esac + ;; + report-only) + export PENPOT_CSP_DIRECTIVE="add_header Content-Security-Policy-Report-Only \"${PENPOT_CSP_POLICY}\" always;" + ;; + disabled) + export PENPOT_CSP_DIRECTIVE="" + ;; + *) + echo "penpot: invalid PENPOT_CSP_MODE '${PENPOT_CSP_MODE}'; expected one of: enforce, report-only, disabled" >&2 + exit 1 + ;; +esac + +# HSTS is only meaningful when the deployment is served over HTTPS, so it +# defaults to enabled when PENPOT_PUBLIC_URI declares an https scheme and +# to disabled otherwise. Set PENPOT_HSTS_VALUE explicitly to override it, +# for example to add includeSubDomains or preload, or to an empty value +# to disable it on an https deployment. +if [[ "${PENPOT_PUBLIC_URI:-}" == https://* ]]; then + export PENPOT_HSTS_VALUE=${PENPOT_HSTS_VALUE-"max-age=31536000"} +else + export PENPOT_HSTS_VALUE=${PENPOT_HSTS_VALUE-""} +fi + +if [ -n "${PENPOT_HSTS_VALUE}" ]; then + export PENPOT_HSTS_DIRECTIVE="add_header Strict-Transport-Security \"${PENPOT_HSTS_VALUE}\" always;" +else + export PENPOT_HSTS_DIRECTIVE="" +fi + +envsubst "\$PENPOT_CSP_DIRECTIVE,\$PENPOT_HSTS_DIRECTIVE" \ + < /tmp/nginx-security-headers.conf.template > /etc/nginx/nginx-security-headers.conf + exec "$@"; diff --git a/docker/images/files/nginx-external-locations.conf b/docker/images/files/nginx-external-locations.conf index c9c106864b..64f4dbe503 100644 --- a/docker/images/files/nginx-external-locations.conf +++ b/docker/images/files/nginx-external-locations.conf @@ -7,6 +7,14 @@ location ~ ^/github/penpot-files/(.+)$ { proxy_set_header User-Agent "curl/8.5.0"; proxy_set_header Host "raw.githubusercontent.com"; proxy_set_header Accept "*/*"; + # A third-party upstream must not dictate the security policy of our own + # origin: browsers enforce the intersection of every Content-Security-Policy + # they receive, and any Strict-Transport-Security lands on this host. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header Content-Security-Policy-Report-Only; + + include /etc/nginx/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; proxy_buffering off; } @@ -31,6 +39,14 @@ location ~ ^/internal/gfonts/font/(?.+) { proxy_cache penpot; + # A third-party upstream must not dictate the security policy of our own + # origin: browsers enforce the intersection of every Content-Security-Policy + # they receive, and any Strict-Transport-Security lands on this host. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header Content-Security-Policy-Report-Only; + + include /etc/nginx/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; add_header Cache-Control max-age=86400; add_header X-Cache-Status $upstream_cache_status; @@ -53,6 +69,14 @@ location ~ ^/internal/gfonts/css { proxy_cache penpot; + # A third-party upstream must not dictate the security policy of our own + # origin: browsers enforce the intersection of every Content-Security-Policy + # they receive, and any Strict-Transport-Security lands on this host. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header Content-Security-Policy-Report-Only; + + include /etc/nginx/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; add_header Cache-Control max-age=86400; add_header X-Cache-Status $upstream_cache_status; diff --git a/docker/images/files/nginx-mcp-locations.conf.template b/docker/images/files/nginx-mcp-locations.conf.template index 6ff6fda592..6538585927 100644 --- a/docker/images/files/nginx-mcp-locations.conf.template +++ b/docker/images/files/nginx-mcp-locations.conf.template @@ -11,9 +11,3 @@ location /mcp/stream { proxy_pass $mcp_stream_backend; proxy_http_version 1.1; } - -location /mcp/sse { - set $mcp_sse_backend $PENPOT_MCP_URI/sse$is_args$args; - proxy_pass $mcp_sse_backend; - proxy_http_version 1.1; -} diff --git a/docker/images/files/nginx-security-headers.conf.template b/docker/images/files/nginx-security-headers.conf.template new file mode 100644 index 0000000000..be41beda3e --- /dev/null +++ b/docker/images/files/nginx-security-headers.conf.template @@ -0,0 +1,6 @@ +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header X-Frame-Options SAMEORIGIN always; +${PENPOT_CSP_DIRECTIVE} +${PENPOT_HSTS_DIRECTIVE} diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 8e3b21d7e1..c8800b0503 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -67,6 +67,13 @@ http { '' close; } + # Link preview crawlers; their requests for the application root + # are served with dynamic Open Graph metadata from the backend. + map $http_user_agent $penpot_link_preview_agent { + default 0; + "~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky|applebot|googlebot|bingbot|bingpreview|duckduckbot|mattermost-bot|opengraph[.]xyz|twitter card validator)" 1; + } + proxy_cache_path /tmp/cache/ levels=2:2 keys_zone=penpot:20m; proxy_cache_methods GET HEAD; proxy_cache_valid any 48h; @@ -95,6 +102,8 @@ http { root /var/www/app/; + ${PENPOT_PUBLIC_PATH_DIRECTIVES} + location @handle_redirect { set $redirect_uri "$upstream_http_location"; set $redirect_host "$upstream_http_x_host"; @@ -137,6 +146,10 @@ http { add_header x-internal-redirect "$upstream_http_x_accel_redirect"; } + location = /link-preview { + proxy_pass $PENPOT_BACKEND_URI/link-preview$is_args$args; + } + location /api/export { proxy_set_header Host $proxy_host; proxy_pass $PENPOT_EXPORTER_URI; @@ -156,7 +169,7 @@ http { location /readyz { access_log off; proxy_set_header Host $proxy_host; - proxy_pass $PENPOT_BACKEND_URI$request_uri; + proxy_pass $PENPOT_BACKEND_URI$uri$is_args$args; } location /ws/notifications { @@ -187,7 +200,11 @@ http { } location ~ ^/[^/]+/(.*)$ { - return 301 " /404"; + return 301 " ${PENPOT_PUBLIC_PATH}404"; + } + + if ($penpot_link_preview_agent) { + rewrite ^/$ /link-preview last; } include /etc/nginx/nginx-security-headers.conf; From 2045fc490064839b0582fbfa8d0538500305758f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Fri, 23 Oct 2026 13:00:53 +0200 Subject: [PATCH 132/167] :bug: Keep text image fills in place when pasting (#12552) A text image fill was always laid over the whole text box, so a pasted image span showed another part of the picture in a box of another size. Text image fills now honour the image transform, as shape fills do: the image covers the transform rect instead of the whole box. Paste gives each pasted image span a transform that keeps the image at the same pixel offset from the span, like the gradients. The SVG export emits one linked image per span transform, rather than one per image id, and the style panel keeps the transform it reads back. AI-assisted-by: claude-opus-5-5 --- .../app/main/data/workspace/clipboard.cljs | 2 +- .../src/app/main/data/workspace/texts.cljs | 24 +++--- frontend/src/app/render_wasm/text_editor.cljs | 17 ++-- frontend/src/app/render_wasm/text_paste.cljs | 25 ++++-- .../render_wasm/text_paste_test.cljs | 39 +++++++-- render-wasm/src/render/images.rs | 10 +-- render-wasm/src/render/svg/fixtures.rs | 44 +++++++--- render-wasm/src/render/svg/tests.rs | 45 ++++++++++ render-wasm/src/render/svg/text.rs | 26 ++---- render-wasm/src/shapes/fills.rs | 86 ++++++++++++++++--- render-wasm/src/shapes/text.rs | 12 +-- 11 files changed, 239 insertions(+), 91 deletions(-) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 4796ab969c..6b560ccd6f 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -1116,7 +1116,7 @@ (dwsh/create-and-add-shape :text x y shape (when skip-edition? {:skip-edition? true}))) (if skip-edition? - (rx/of (dwtxt/move-new-text-gradients id payload) + (rx/of (dwtxt/move-new-text-fills id payload) (dwwt/resize-wasm-text-debounce id {:undo-group id :undo-id undo-id})) (rx/of (dwu/commit-undo-transaction undo-id)))))))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 8ac273e1d2..c50af92813 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -1708,8 +1708,8 @@ :fragment fragment})))) (defn- target-geometry - "Where the gradient spans of `pasted`, inserted at `start`, begin in `shape` with - `content`, laid over the box it gets once resized to its text; nil when none." + "Where the gradient and image spans of `pasted`, inserted at `start`, begin in `shape` + with `content`, laid over the box it gets once resized to its text; nil when none." [{:keys [id grow-type]} content pasted start] (when-let [geometry (wasm.text-editor/content-geometry id pasted start)] (if (= :fixed grow-type) @@ -1728,23 +1728,23 @@ :height height}))))] (update geometry :origins (partial mapv (partial mapv #(some-> % fit)))))))) -(defn- move-pasted-gradients - "`content`, the shape's after restyling the paste, with the pasted gradients - keeping their place on their spans. Pushes the result to WASM." +(defn- move-pasted-fills + "`content`, the shape's after restyling the paste, with the pasted gradients and + images keeping their place on their spans. Pushes the result to WASM." [shape content {:keys [geometry] :as paste} start] (let [pasted (:content paste) target (when (some? geometry) (target-geometry shape content pasted start))] (if (some? target) - (let [content (text-paste/move-gradients content pasted start geometry target)] + (let [content (text-paste/move-span-fills content pasted start geometry target)] (wasm.api/set-shape-text-content (:id shape) content) content) content))) -(defn move-new-text-gradients - "Event moving the gradients of `payload`, just pasted on the canvas as the text - `id`, so they keep their place on their spans." +(defn move-new-text-fills + "Event moving the gradients and images of `payload`, just pasted on the canvas as the + text `id`, so they keep their place on their spans." [id {:keys [geometry]}] - (ptk/reify ::move-new-text-gradients + (ptk/reify ::move-new-text-fills ptk/WatchEvent (watch [_ state _] (let [{:keys [content] :as shape} (dsh/lookup-shape state id) @@ -1756,7 +1756,7 @@ (if (some? target) (rx/of (dwsh/update-shapes [id] - #(update % :content text-paste/move-gradients content start geometry target) + #(update % :content text-paste/move-span-fills content start geometry target) {:attrs #{:content}})) (rx/empty)))))) @@ -1781,7 +1781,7 @@ (wasm.text-editor/text-editor-sync-content) (if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start penpot?)] (let [shape (dsh/lookup-shape state shape-id)] - (v3-update-editor-content shape-id (move-pasted-gradients shape content paste start))) + (v3-update-editor-content shape-id (move-pasted-fills shape content paste start))) (v3-sync-editor-content))) pending? diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 4348117ed8..ebfabdb07d 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -123,12 +123,17 @@ width (aget heap-i32 (+ fill-u32-offset 6)) height (aget heap-i32 (+ fill-u32-offset 7))] {:fill-opacity (u8->opacity alpha) - :fill-image {:id (uuid/from-unsigned-parts a b c d) - :width width - :height height - :mtype (types.fills.impl/decode-mtype mtype) - :keep-aspect-ratio (not (zero? (bit-and flags 0x01))) - :name "sample"}}) + :fill-image (cond-> {:id (uuid/from-unsigned-parts a b c d) + :width width + :height height + :mtype (types.fills.impl/decode-mtype mtype) + :keep-aspect-ratio (not (zero? (bit-and flags 0x01))) + :name "sample"} + (not (zero? (bit-and flags 0x02))) + (assoc :transform {:x (aget heap-f32 (+ fill-u32-offset 8)) + :y (aget heap-f32 (+ fill-u32-offset 9)) + :width (aget heap-f32 (+ fill-u32-offset 10)) + :height (aget heap-f32 (+ fill-u32-offset 11))}))}) nil))) diff --git a/frontend/src/app/render_wasm/text_paste.cljs b/frontend/src/app/render_wasm/text_paste.cljs index 4520327ce6..bbc5b5d512 100644 --- a/frontend/src/app/render_wasm/text_paste.cljs +++ b/frontend/src/app/render_wasm/text_paste.cljs @@ -127,7 +127,7 @@ content (rest (map-indexed vector fragment))))) -;; --- Gradients of text copied in Penpot +;; --- Gradients and images of text copied in Penpot (defn- move-gradient "`gradient`, in fractions of the `from` box, in fractions of the `to` box, with its @@ -146,22 +146,37 @@ (= :radial (:type gradient)) (update :width #(/ (* % w0 h1) (* h0 w1)))))) +(defn- move-image + "`image`, laid over its transform in the `from` box (the whole box without one), + with a transform in the `to` box at the same pixel offset from the span origin." + [image from to] + (let [{x0 :x y0 :y {w0 :width h0 :height} :box} from + {x1 :x y1 :y {w1 :width h1 :height} :box} to + {:keys [x y width height]} (get image :transform {:x 0 :y 0 :width 1 :height 1})] + (assoc image :transform {:x (/ (+ x1 (- (* x w0) x0)) w1) + :y (/ (+ y1 (- (* y h0) y0)) h1) + :width (/ (* width w0) w1) + :height (/ (* height h0) h1)}))) + (defn- move-fills [span from to] (update span :fills (fn [fills] (mapv #(cond-> % (some? (:fill-color-gradient %)) - (update :fill-color-gradient move-gradient from to)) + (update :fill-color-gradient move-gradient from to) + + (some? (:fill-image %)) + (update :fill-image move-image from to)) fills)))) (defn- sized? [{{:keys [width height]} :box}] (and (pos? width) (pos? height))) -(defn move-gradients - "`content` with the gradients of `pasted` (the content inserted at `start`) moved - from the `source` geometry to the `target` one (see `text-editor/content-geometry`)." +(defn move-span-fills + "`content` with the gradients and images of `pasted`, inserted at `start`, moved from + the `source` geometry to the `target` one (see `text-editor/content-geometry`)." [content pasted start source target] (let [entries (map vector (flatten (text-editor/span-ranges pasted start)) diff --git a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs index c9bc145647..a52c1f5e2e 100644 --- a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs @@ -213,8 +213,8 @@ (let [linear (gradient-fill {:start-x 0.25 :start-y 0 :end-x 0.75 :end-y 1}) pasted (content [(span "HELLO" linear)]) target (content [(span "abc") (span "HELLO" linear)]) - result (text-paste/move-gradients target pasted {:para 0 :offset 3} - source-geometry target-geometry)] + result (text-paste/move-span-fills target pasted {:para 0 :offset 3} + source-geometry target-geometry)] (t/testing "points move into the target box at the same pixel offsets" (t/is (= {:start-x 0.1 :start-y 0.5 :end-x 1.1 :end-y 1} (select-keys (pasted-gradient result) [:start-x :start-y :end-x :end-y])))) @@ -227,8 +227,8 @@ (let [radial (gradient-fill {:type :radial :start-x 0.5 :start-y 0.5 :end-x 0.5 :end-y 1}) pasted (content [(span "HELLO" radial)]) target (content [(span "abc") (span "HELLO" radial)]) - result (text-paste/move-gradients target pasted {:para 0 :offset 3} - source-geometry target-geometry)] + result (text-paste/move-span-fills target pasted {:para 0 :offset 3} + source-geometry target-geometry)] ;; 200x40 is 5:1 and 100x80 is 5:4, so the width grows 4 times. (t/is (= 4 (:width (pasted-gradient result)))))) @@ -238,12 +238,12 @@ pasted (content [(span "red" red) (span "grad" linear)]) target (content [(span "red" red) (span "grad" linear)])] (t/testing "a span with no origin keeps its gradient" - (t/is (= target (text-paste/move-gradients + (t/is (= target (text-paste/move-span-fills target pasted {:para 0 :offset 0} {:origins [[nil nil]]} {:origins [[nil nil]]})))) (t/testing "an empty box leaves the gradients as they were" - (t/is (= target (text-paste/move-gradients + (t/is (= target (text-paste/move-span-fills target pasted {:para 0 :offset 0} {:origins [[nil {:x 0 :y 0 :box source-box}]]} {:origins [[nil {:x 0 :y 0 :box {:width 0 :height 80}}]]})))))) @@ -256,9 +256,34 @@ [{:x 0 :y 0 :box {:width 200 :height 40}}]]} target {:origins [[{:x 0 :y 0 :box {:width 400 :height 80}}] [{:x 0 :y 40 :box {:width 400 :height 80}}]]} - result (text-paste/move-gradients pasted pasted {:para 0 :offset 0} source target) + result (text-paste/move-span-fills pasted pasted {:para 0 :offset 0} source target) points #(select-keys (-> % :fills first :fill-color-gradient) [:start-x :start-y :end-x :end-y])] (t/is (= [{:start-x 0.125 :start-y 0 :end-x 0.125 :end-y 0.25} {:start-x 0.25 :start-y 0.5 :end-x 0.25 :end-y 1}] (map (comp points first) (spans-of result)))))) + +;; --- Images + +(defn- image-fill [image] + {:fills [{:fill-image (merge {:id #uuid "00000000-0000-0000-0000-000000000042" + :width 400 :height 200 :mtype "image/png"} + image)}]}) + +(defn- pasted-image-transform [content] + (-> (spans-of content) first second :fills first :fill-image :transform)) + +(t/deftest images-keep-their-offset-from-the-span + (let [move (fn [image] + (let [fill (image-fill image)] + (pasted-image-transform + (text-paste/move-span-fills (content [(span "abc") (span "HELLO" fill)]) + (content [(span "HELLO" fill)]) + {:para 0 :offset 3} + source-geometry target-geometry))))] + (t/testing "an image over the whole source box gets a transform over the same pixels" + (t/is (= {:x -0.4 :y 0.5 :width 2 :height 0.5} + (move {})))) + (t/testing "an image with a transform keeps its pixel rect" + (t/is (= {:x 0.1 :y 0.5 :width 1 :height 0.5} + (move {:transform {:x 0.25 :y 0 :width 0.5 :height 1}})))))) diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 5b59b3620a..bcbf557d0c 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -21,15 +21,7 @@ pub fn get_dest_rect(container: &MathRect, delta: f32) -> MathRect { } pub fn get_image_dest_rect(container: &MathRect, image_fill: &ImageFill) -> MathRect { - match image_fill.transform() { - Some(tf) => MathRect::from_xywh( - container.left + tf.x * container.width(), - container.top + tf.y * container.height(), - tf.width * container.width(), - tf.height * container.height(), - ), - None => *container, - } + image_fill.dest_rect(container) } pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill) -> MathRect { diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index db7759102b..a33d5d258e 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -335,23 +335,39 @@ pub(super) fn add_text_with_fills( text: &str, font_size: f32, fills: Vec, +) { + add_text_with_spans(pool, id, (l, t, r, b), font_size, vec![(text, fills)]); +} + +/// Fixed text with one paragraph of `spans`, each a text and its fills. +pub(super) fn add_text_with_spans( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + font_size: f32, + spans: Vec<(&str, Vec)>, ) { let bounds = skia::Rect::from_ltrb(l, t, r, b); let mut content = TextContent::new(bounds, GrowType::Fixed); let line_height = 1.2; - let span = TextSpan::new( - text.to_string(), - FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), - font_size, - line_height, - 0.0, - None, - None, - TextDirection::LTR, - 400, - Uuid::nil(), - fills, - ); + let spans = spans + .into_iter() + .map(|(text, fills)| { + TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size, + line_height, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + fills, + ) + }) + .collect(); content.add_paragraph(Paragraph::new( TextAlign::Left, TextDirection::LTR, @@ -359,7 +375,7 @@ pub(super) fn add_text_with_fills( None, line_height, 0.0, - vec![span], + spans, )); let shape = pool.add_shape(id); diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 02eda33b79..87eeb29fb4 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -2957,6 +2957,51 @@ fn exports_image_fill_on_text() { insta::assert_snapshot!(svg); } +#[test] +fn exports_one_image_per_text_image_transform() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + let image = |x: f32| { + Fill::Image(ImageFill::new_with_transform( + image_id, + 255, + 200, + 100, + true, + Some(ImageFillTransform { + x, + y: 0.0, + width: 0.5, + height: 1.0, + }), + )) + }; + add_text_with_spans( + &mut pool, + id, + (0.0, 0.0, 400.0, 100.0), + 60.0, + vec![("HO", vec![image(0.0)]), ("LA", vec![image(0.5)])], + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_eq!(svg.matches(" skia_safe::Rect { - let image_width = image_fill.width() as f32; - let image_height = image_fill.height() as f32; - let scale_x = selrect.width() / image_width; - let scale_y = selrect.height() / image_height; - let scale = scale_x.max(scale_y); - let scaled_width = image_width * scale; - let scaled_height = image_height * scale; - let pos_x = selrect.left() - (scaled_width - selrect.width()) / 2.0; - let pos_y = selrect.top() - (scaled_height - selrect.height()) / 2.0; - skia_safe::Rect::from_xywh(pos_x, pos_y, scaled_width, scaled_height) -} - fn push_glyph_clip( builder: &mut SvgLayerCanvas, element: &Shape, @@ -365,12 +351,12 @@ fn linked_image_fills_at_layer<'a>( layer: usize, shared: &RenderResources, ) -> Vec<&'a ImageFill> { - let mut out = Vec::new(); - let mut seen = HashSet::new(); + // Spans can share an image with different transforms: one element each. + let mut out: Vec<&ImageFill> = Vec::new(); for paragraph in text_content.paragraphs() { for span in paragraph.children() { if let Some(Fill::Image(img)) = span.fills_from_bottom(layer) { - if shared.images.source_url(&img.id()).is_some() && seen.insert(img.id()) { + if shared.images.source_url(&img.id()).is_some() && !out.contains(&img) { out.push(img); } } @@ -412,7 +398,7 @@ fn emit_text_image_fill( let clip_id = builder.unique("txtimgclip"); let text_content = shape.get_text_content().new_bounds(shape.selrect()); let mut paragraph_builders = - text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill.id()); + text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill); let canvas = builder.new_fragment(); { @@ -428,7 +414,7 @@ fn emit_text_image_fill( emit_linked_image_element( builder, image_fill, - shape.selrect(), + image_fill.dest_rect(&shape.selrect()), &href, Some(&clip_id), true, diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index c819579de0..15afbbbba7 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -212,6 +212,35 @@ impl ImageFill { pub fn transform(&self) -> Option<&ImageFillTransform> { self.transform.as_ref() } + + /// The part of `container` the image is laid over: its transform, else all of it. + pub fn dest_rect(&self, container: &Rect) -> Rect { + match &self.transform { + Some(tf) => Rect::from_xywh( + container.left + tf.x * container.width(), + container.top + tf.y * container.height(), + tf.width * container.width(), + tf.height * container.height(), + ), + None => *container, + } + } + + /// Where the image is drawn to cover its dest rect in `container`, centered. + pub fn cover_rect(&self, container: &Rect) -> Rect { + let dest = self.dest_rect(container); + let image_width = self.width as f32; + let image_height = self.height as f32; + let scale = (dest.width() / image_width).max(dest.height() / image_height); + let scaled_width = image_width * scale; + let scaled_height = image_height * scale; + Rect::from_xywh( + dest.left - (scaled_width - dest.width()) / 2.0, + dest.top - (scaled_height - dest.height()) / 2.0, + scaled_width, + scaled_height, + ) + } } #[derive(Debug, Clone, PartialEq, Copy)] @@ -310,20 +339,14 @@ pub fn get_fill_shader(fill: &Fill, bounding_box: &Rect) -> Option let sampling_options = skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest); - // FIXME no image ratio applied, centered to the current rect + // Covers the dest rect (transform, else the whole box), centered. + // keep_aspect_ratio is not applied: the image always covers. let tile_modes = (skia::TileMode::Clamp, skia::TileMode::Clamp); - let image_width = image_fill.width as f32; - let image_height = image_fill.height as f32; - let scale_x = bounding_box.width() / image_width; - let scale_y = bounding_box.height() / image_height; - let scale = scale_x.max(scale_y); - let scaled_width = image_width * scale; - let scaled_height = image_height * scale; - let pos_x = bounding_box.left() - (scaled_width - bounding_box.width()) / 2.0; - let pos_y = bounding_box.top() - (scaled_height - bounding_box.height()) / 2.0; + let cover = image_fill.cover_rect(bounding_box); + let scale = cover.width() / image_fill.width as f32; let mut matrix = skia::Matrix::new_identity(); - matrix.pre_translate((pos_x, pos_y)); + matrix.pre_translate((cover.left, cover.top)); matrix.pre_scale((scale, scale), None); let opacity = image_fill.opacity(); @@ -394,3 +417,44 @@ pub fn set_paint_fill(paint: &mut Paint, fill: &Fill, bounding_box: &Rect, remov paint.set_shader(shader); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn image_fill(transform: Option) -> ImageFill { + ImageFill::new_with_transform(Uuid::nil(), 255, 100, 50, true, transform) + } + + #[test] + fn an_image_without_transform_covers_the_whole_container() { + let container = Rect::from_xywh(10.0, 20.0, 100.0, 100.0); + + assert_eq!(image_fill(None).dest_rect(&container), container); + // 100x50 scaled by 2 to fill the height, centered horizontally. + assert_eq!( + image_fill(None).cover_rect(&container), + Rect::from_xywh(-40.0, 20.0, 200.0, 100.0) + ); + } + + #[test] + fn an_image_with_transform_covers_its_part_of_the_container() { + let container = Rect::from_xywh(10.0, 20.0, 200.0, 100.0); + let fill = image_fill(Some(ImageFillTransform { + x: 0.5, + y: -0.5, + width: 0.5, + height: 1.0, + })); + + assert_eq!( + fill.dest_rect(&container), + Rect::from_xywh(110.0, -30.0, 100.0, 100.0) + ); + assert_eq!( + fill.cover_rect(&container), + Rect::from_xywh(60.0, -30.0, 200.0, 100.0) + ); + } +} diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 49b8fc4861..a8328be0d1 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -883,18 +883,18 @@ impl TextContent { } /// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is - /// the given image — used as an SVG `` for linked image fills. + /// `image_fill` — used as an SVG `` for linked image fills. pub fn paragraph_builder_group_opaque_for_image_layer( &self, layer_from_bottom: usize, - image_id: Uuid, + image_fill: &shapes::ImageFill, ) -> Vec { self.paragraph_builders( None, false, None, None, - Some((layer_from_bottom, image_id)), + Some((layer_from_bottom, image_fill)), None, ) } @@ -905,7 +905,7 @@ impl TextContent { opaque: bool, align_override: Option, fill_layer: Option, - opaque_image_layer: Option<(usize, Uuid)>, + opaque_image_layer: Option<(usize, &shapes::ImageFill)>, skip_image_ids: Option<&HashSet>, ) -> Vec { let fonts = get_font_collection(); @@ -920,7 +920,7 @@ impl TextContent { let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; for span in paragraph.children() { - let text_style = if let Some((layer, image_id)) = opaque_image_layer { + let text_style = if let Some((layer, image_fill)) = opaque_image_layer { let mut style = span.to_style( &self.bounds(), fallback_fonts, @@ -929,7 +929,7 @@ impl TextContent { ); let mut paint = paint::Paint::default(); match span.fills_from_bottom(layer) { - Some(shapes::Fill::Image(img)) if img.id() == image_id => { + Some(shapes::Fill::Image(img)) if img == image_fill => { paint.set_color(skia::Color::BLACK); paint.set_alpha(255); } From 4bf70de73caec6d097a0ccf4bc7e99d20dd7afe8 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Fri, 23 Oct 2026 13:32:39 +0200 Subject: [PATCH 133/167] :bug: Restrict per-side stroke width tokens to boards and rects in plugin API (#12332) * :bug: Fix restrict per-side stroke width tokens to rectangles in plugins API * :recycle: Reuse per-side stroke restriction check across plugin API Move the per-side stroke width restriction into the public `per-side-stroke-attrs-restricted?` helper, now taking `page-id`, and reuse it from both `shape.applyToken` and the token application path. Keep the error message in a single `per-side-stroke-widths-message` def used by all three rejection sites. Wait for token resolution in the uniform stroke width regression test instead of relying on a bare `settle`, which could assert before the applied token lands. AI-assisted-by: deepseek-v4.1-flash --------- Co-authored-by: Luis de Dios --- frontend/src/app/plugins/shape.cljs | 9 +- frontend/src/app/plugins/tokens.cljs | 47 ++++++++-- .../frontend_tests/plugins/tokens_test.cljs | 87 +++++++++++++++++++ plugins/CHANGELOG.md | 1 + .../src/tests/tokens.test.ts | 49 +++++++++++ plugins/libs/plugin-types/index.d.ts | 9 ++ 6 files changed, 193 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index b34b329598..817492536c 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -66,7 +66,7 @@ [app.plugins.strokes :as strokes] [app.plugins.system-events :as se] [app.plugins.text :as text] - [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens expand-token-attrs token-attr-plugin->token-attr token-attr? valid-token-resolution?]] + [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens expand-token-attrs per-side-stroke-attrs-restricted? per-side-stroke-widths-message token-attr-plugin->token-attr token-attr? valid-token-resolution?]] [app.plugins.utils :as u] [app.util.http :as http] [app.util.object :as obj] @@ -292,7 +292,7 @@ (and (not (ctt/per-side-stroke-shape? (:type (u/proxy->shape self)))) (some #(= :multiple (types.stroke/width-type %)) value)) - (u/not-valid plugin-id :strokes "Per-side stroke widths require a board or rectangle") + (u/not-valid plugin-id :strokes per-side-stroke-widths-message) :else (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value) options)))))) @@ -1840,6 +1840,11 @@ (some #(not (token-attr? %)) kw-attrs) (u/not-valid plugin-id :applyToken attrs) + ;; The uniform `strokeWidth` alias is exempt: it expands + ;; to a uniform width on every side. + (per-side-stroke-attrs-restricted? file-id page-id [id] attrs) + (u/not-valid plugin-id :applyToken per-side-stroke-widths-message) + :else (st/emit! (-> (dwta/toggle-token {:token token diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index f1895d7efc..4e21174bab 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -111,6 +111,20 @@ (or (= attr stroke-width-alias) (cto/token-attr? attr)))) +(def per-side-stroke-widths-message + "Per-side stroke widths require a board or rectangle") + +(defn per-side-stroke-attrs? + "Returns true when the plugin attrs name at least one explicit per-side stroke + width attribute (`strokeWidthTop/Right/Bottom/Left`). The uniform + `strokeWidth` alias is not considered per-side: it expands to all four sides + with the same token and always resolves to a uniform width." + [attrs] + (boolean + (some #(contains? cto/per-side-stroke-width-keys + (token-attr-plugin->token-attr %)) + attrs))) + (defn- token-name-schema [file-id set-id token] (let [tokens-lib (u/locate-tokens-lib file-id) @@ -181,23 +195,41 @@ (get (:name token)) (resolved-without-errors?)))) +(defn per-side-stroke-attrs-restricted? + "Returns true when `attrs` name an explicit per-side stroke width while some + target shape on `page-id` does not support independent sides. Only boards and + rectangles accept them. Shapes absent from the page are ignored, because + token application only touches the active page." + [file-id page-id shape-ids attrs] + (when (per-side-stroke-attrs? attrs) + (boolean + (some (fn [id] + (let [shape (u/locate-shape file-id page-id id)] + (and (some? shape) + (not (cto/per-side-stroke-shape? (:type shape)))))) + shape-ids)))) + (defn- apply-token-to-shapes - [plugin-id file-id set-id id shape-ids attrs] + [plugin-id code file-id set-id id shape-ids attrs] (cond (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id code "Plugin doesn't have 'content:write' permission") :else (let [token (u/locate-token file-id set-id id)] (cond (nil? token) - (u/not-valid plugin-id :applyToSelected id) + (u/not-valid plugin-id code id) (not (valid-token-resolution? file-id set-id id)) - (u/not-valid plugin-id :applyToSelected (:value token)) + (u/not-valid plugin-id code (:value token)) (some #(not (token-attr? %)) attrs) - (u/not-valid plugin-id :applyToSelected attrs) + (u/not-valid plugin-id code attrs) + + (per-side-stroke-attrs-restricted? file-id (:current-page-id @st/state) + shape-ids attrs) + (u/not-valid plugin-id code per-side-stroke-widths-message) :else (st/emit! @@ -443,14 +475,15 @@ [:vector [:fn shape-proxy?]] [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [shapes attrs] - (apply-token-to-shapes plugin-id file-id set-id id (map #(obj/get % "$id") shapes) attrs))} + (apply-token-to-shapes plugin-id :applyToShapes file-id set-id id + (map #(obj/get % "$id") shapes) attrs))} :applyToSelected {:enumerable false :schema [:tuple [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [attrs] (let [selected (get-in @st/state [:workspace-local :selected])] - (apply-token-to-shapes plugin-id file-id set-id id selected attrs)))})) + (apply-token-to-shapes plugin-id :applyToSelected file-id set-id id selected attrs)))})) ;; === Token Set diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index f731c98910..5f47fb92b3 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -545,6 +545,23 @@ (t/is (= #{:fill} (ptok/expand-token-attrs #{:fill}))) (t/is (= #{} (ptok/expand-token-attrs nil))))) +(t/deftest per-side-stroke-attrs?-distinguishes-sides-from-the-uniform-alias + (t/testing "the uniform alias is not per-side" + (t/is (false? (ptok/per-side-stroke-attrs? #{:stroke-width}))) + (t/is (false? (ptok/per-side-stroke-attrs? #{"stroke-width"})))) + + (t/testing "each explicit side is per-side, as keyword or string" + (t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-top}))) + (t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-right}))) + (t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-bottom}))) + (t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-left}))) + (t/is (true? (ptok/per-side-stroke-attrs? #{"stroke-width-left"})))) + + (t/testing "unrelated and empty attrs are not per-side" + (t/is (false? (ptok/per-side-stroke-attrs? #{:fill}))) + (t/is (false? (ptok/per-side-stroke-attrs? #{}))) + (t/is (false? (ptok/per-side-stroke-attrs? nil))))) + (t/deftest shape-apply-token-accepts-padding-top (t/async done @@ -646,6 +663,76 @@ (done))) 0)))) +;; Regression coverage for issue #12319. +;; +;; Per-side stroke widths are only supported on boards and rectangles, and the +;; `strokes` setter already rejects them elsewhere. The token path did not: +;; `shape.applyToken` / `token.applyToShapes` / `token.applyToSelected` bound a +;; per-side width token to an ellipse and left the stroke in the per-side state +;; the strokes setter refuses. The token path now rejects the four explicit side +;; properties on unsupported shapes while keeping the uniform `strokeWidth` +;; alias working. + +(t/deftest ^:async apply-token-rejects-per-side-stroke-width-on-unsupported-shape + (let [plugin-id "00000000-0000-0000-0000-000000000000" + set-id (cthi/new-id! :token-set) + token-id (cthi/new-id! :stroke-width-token) + theme-id (cthi/new-id! :theme) + file (-> (ctht/sample-file-with-tokens + :file-id :file1 + :lib-fn #(-> % + (ctob/add-set + (ctob/make-token-set :id set-id + :name "widths")) + (ctob/add-theme + (ctob/make-token-theme :id theme-id + :name "theme" + :sets #{"widths"})) + (ctob/add-token + set-id + (ctob/make-token :id token-id + :name "width.sm" + :type :stroke-width + :value 10))) + :status-fn #(ctos/set-tokens-status % #{theme-id} #{set-id}))) + store (ths/setup-store file) + _ (set! st/state store) + _ (set! st/stream (ptk/input-stream store)) + ^js context (api/create-context plugin-id) + ^js ellipse (.createEllipse context) + id (obj/get ellipse "$id") + ^js library (.-library context) + ^js local (.-local library) + ^js catalog (.-tokens local) + ^js token-set (.getSetById catalog (str set-id)) + ^js token (.getTokenById token-set (str token-id)) + path [:files (:id file) :data :pages-index (cthf/current-page-id file) + :objects id :applied-tokens]] + + (swap! store assoc-in [:plugins :flags plugin-id :throw-validation-errors] true) + + (t/testing "shape.applyToken rejects an explicit per-side width" + (t/is (thrown? js/Error (.applyToken ellipse token #js ["strokeWidthTop"]))) + (t/is (nil? (get-in @store path)))) + + (t/testing "token.applyToShapes rejects an explicit per-side width" + (t/is (thrown? js/Error (.applyToShapes token #js [ellipse] #js ["strokeWidthLeft"]))) + (t/is (nil? (get-in @store path)))) + + (t/testing "token.applyToSelected rejects an explicit per-side width" + (swap! store assoc-in [:workspace-local :selected] #{id}) + (t/is (thrown? js/Error (.applyToSelected token #js ["strokeWidthRight"]))) + (t/is (nil? (get-in @store path)))) + + ;; The uniform alias stays valid: it expands to the four sides with the same + ;; token and resolves to a uniform width, which the strokes setter accepts. + (t/testing "the uniform strokeWidth alias is still accepted" + (.applyToken ellipse token #js ["strokeWidth"]) + (await (async/wait-for + #(= "width.sm" (get-in @store (conj path :stroke-width-top))) + "uniform stroke width token applied")) + (t/is (= "width.sm" (get-in @store (conj path :stroke-width-top))))))) + (t/deftest token-attr?-rejects-unknown-input (t/is (false? (boolean (ptok/token-attr? :not-a-real-attr)))) (t/is (false? (boolean (ptok/token-attr? "not-a-real-attr")))) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index fbd46deec9..3ea8fd12c9 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -22,6 +22,7 @@ - **plugins-runtime**: The plugin modal no longer shows two resize grips in Firefox. Firefox now shows only its native grip. Closes #11795. - **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405. - **plugins-runtime**: Opening a plugin no longer drops running background plugins (`allowBackground`, such as the MCP plugin) from the runtime registry. They kept running, but their UI messages were no longer delivered to the plugin and they could no longer be unloaded, so MCP tasks timed out after another plugin was opened. +- **plugins-runtime**: `Shape.applyToken()`, `Token.applyToShapes()` and `Token.applyToSelected()` now reject an explicit per-side stroke width property (`strokeWidthTop`, `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) on shapes other than boards and rectangles, matching the `strokes` setter. The uniform `strokeWidth` property keeps working on every shape. Closes #12319. ## 1.5.0 (2026-07-08) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts index 095a86ac17..2614617c77 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts @@ -456,6 +456,55 @@ describe('Tokens', () => { expect(keys).not.toContain('strokeWidthRight'); }); + // Issue #12319: per-side widths are only supported on boards and + // rectangles. `applyToken` used to bind them on any shape, leaving an + // ellipse in the per-side state the `strokes` setter rejects. + test('applyToken rejects an explicit per-side width on an ellipse', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'borderWidth', + name: unique('width.'), + value: '4', + }); + const ellipse = ctx.penpot.createEllipse(); + ctx.board.appendChild(ellipse); + + expect(() => ellipse.applyToken(token, ['strokeWidthTop'])).toThrow(); + expect(ellipse.tokens.strokeWidthTop).toBeUndefined(); + }); + + test('applyToShapes rejects an explicit per-side width on an ellipse', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'borderWidth', + name: unique('width.'), + value: '4', + }); + const ellipse = ctx.penpot.createEllipse(); + ctx.board.appendChild(ellipse); + + expect(() => + token.applyToShapes([ellipse], ['strokeWidthLeft']), + ).toThrow(); + expect(ellipse.tokens.strokeWidthLeft).toBeUndefined(); + }); + + test('applyToken keeps accepting the uniform strokeWidth alias on an ellipse', async (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'borderWidth', + name: unique('width.'), + value: '4', + }); + const ellipse = ctx.penpot.createEllipse(); + ctx.board.appendChild(ellipse); + + ellipse.applyToken(token, ['strokeWidth']); + + await waitFor(() => ellipse.tokens.strokeWidth === token.name); + expect(ellipse.tokens.strokeWidth).toBe(token.name); + }); + for (const side of [ 'strokeWidthTop', 'strokeWidthRight', diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index aa7008dc13..6d5e8c5408 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -4174,6 +4174,11 @@ export interface ShapeBase extends PluginData { * several tokens with the same name in different sets, the actual token applied * and the value set to the attributes will depend on which sets are active * (and will change if different sets or themes are activated later). + * + * NOTE that the per-side stroke width properties (`strokeWidthTop`, + * `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + * boards and rectangles. Applying one to any other shape raises a validation + * error. The uniform `strokeWidth` property is accepted on every shape. */ applyToken(token: Token, properties?: TokenProperty[]): void; @@ -4662,6 +4667,10 @@ export interface TokenBase { * several tokens with the same name in different sets, the actual token applied * and the value set to the attributes will depend on which sets are active * (and will change if different sets or themes are activated later). + * + * NOTE that the per-side stroke width properties are only valid on boards and + * rectangles; applying one to any other shape raises a validation error. The + * uniform `strokeWidth` property is accepted on every shape. */ applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; From d362266694dcd3e5b4092bdf7ded2048231accca Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 23 Oct 2026 18:42:48 +0200 Subject: [PATCH 134/167] :book: Add rewrite-commit-message skill --- .../skills/rewrite-commit-message/SKILL.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .agents/skills/rewrite-commit-message/SKILL.md diff --git a/.agents/skills/rewrite-commit-message/SKILL.md b/.agents/skills/rewrite-commit-message/SKILL.md new file mode 100644 index 0000000000..3281d0fa75 --- /dev/null +++ b/.agents/skills/rewrite-commit-message/SKILL.md @@ -0,0 +1,38 @@ +--- +name: rewrite-commit-message +description: Rewrite HEAD's commit message (typically a squash that concatenated old messages) into one proper title plus description following Penpot commit conventions, preserving every distinct AI-assisted-by attribution. Amends in place, never pushes. +slash: true +--- + +# Skill: rewrite-commit-message + +HEAD's message is a concatenation of older messages (typical after a +squash): several subjects, bodies and repeated trailers stacked in a +single message. Replace it with one proper message and amend HEAD. + +## When to Use + +- The user asks to rewrite, retitle or fix the last commit message +- After a squash left HEAD with stacked messages and trailer repeats + +## Steps + +1. Read the full current message (`git log -1 --format=%B HEAD`) and + the branch diff (`git diff develop..HEAD --stat`, plus the code + when the summary needs grounding). +2. Collect every DISTINCT `AI-assisted-by:` value in the current + message. All of them survive into the new message, one line each: + attributions are never dropped in a rewrite, even when most of + the old text is. +3. Draft one message per `mem:workflow/creating-commits` (read it + end-to-end first): single subject (imperative, capitalized, no + trailing period, ≤80 chars), blank line, body wrapped at ≤80 + chars covering the whole diff (not the old per-commit slices), + then the preserved trailers. +4. Write it to a file (a heredoc keeps real newlines; inline `-m` + with `\n` does not) and `git commit --amend -F `. +5. Run `./scripts/check-commit` and require exit code 0. A non-zero + exit is a hard blocker: fix the message, amend again. +6. Report the new hash. Never push: the user pushes from their own + shell. Warn when local history was rewritten over pushed commits + (the push will need force — their call, not yours). From a0ef927faab4d4da7eadd03df3b452edb706b87a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 26 Oct 2026 07:05:13 +0100 Subject: [PATCH 135/167] :ambulance: Drop invoke_* glue checks from staging CI (#12623) * :ambulance: Drop invoke_* glue checks from staging CI The .github backport from develop added check-no-invoke-v to WASM and Exporter CI, but staging still ships glue that uses invoke_* wrappers. Remove those steps (and the WASM production build only used by the check) so staging CI matches the binary it builds. * :wrench: Disable credential persistence in commit-checker checkout zizmor fails CI: Workflows when actions/checkout omits persist-credentials: false. Match the rest of the workflows. --- .github/workflows/commit-checker.yml | 1 + .github/workflows/tests-exporter.yml | 5 ----- .github/workflows/tests-wasm.yml | 10 ---------- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index 15790e72a9..d245e6cd14 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -58,6 +58,7 @@ jobs: - name: Checkout trusted code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 ref: ${{ github.event.pull_request.base.sha || github.sha }} diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml index ed0da17b28..82b7b12f73 100644 --- a/.github/workflows/tests-exporter.yml +++ b/.github/workflows/tests-exporter.yml @@ -61,8 +61,3 @@ jobs: working-directory: ./exporter run: | ./scripts/test - - - name: Check no invoke_* in exporter JS glue - working-directory: ./render-wasm - run: | - ./check-no-invoke-v export diff --git a/.github/workflows/tests-wasm.yml b/.github/workflows/tests-wasm.yml index eac2d7ebf3..f3e1547321 100644 --- a/.github/workflows/tests-wasm.yml +++ b/.github/workflows/tests-wasm.yml @@ -60,13 +60,3 @@ jobs: working-directory: ./render-wasm run: | ./test - - - name: Build - working-directory: ./render-wasm - run: | - NODE_ENV=production ./build frontend - - - name: Check no invoke_*V in JS glue - working-directory: ./render-wasm - run: | - ./check-no-invoke-v From f44566963daf61fce4d8608e7c5e8c27c5853a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 26 Oct 2026 08:53:30 +0000 Subject: [PATCH 136/167] :rewind: Revert the subpath support brought by the docker/ backport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backport of docker/ from develop (2df5f417a) also brought the subpath support from #12358 and its fix (95bc48a08). It needs the Admin Console from 2.20, so with the current one every Admin Console URL fails. Restore the previous nginx configuration and docker-compose.yaml. AI-assisted-by: Claude Signed-off-by: David Barragán Merino --- docker/images/docker-compose.yaml | 5 ----- ...ginx-admin-console-locations.conf.template | 6 +----- docker/images/files/nginx-entrypoint.sh | 19 +------------------ docker/images/files/nginx.conf.template | 6 ++---- 4 files changed, 4 insertions(+), 32 deletions(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 4c69760e24..e667bc3049 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -26,9 +26,6 @@ x-flags: &penpot-flags PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console -## Public URI where Penpot is reached. To serve Penpot from a subpath, include -## it with a trailing slash, e.g. https://example.com/penpot/. A reverse proxy in -## front must forward the subpath unchanged, without rewriting it. x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: http://localhost:9001 @@ -113,8 +110,6 @@ services: # ## internet with HTTPS using traefik. # - "traefik.http.routers.penpot-https.rule=Host(``)" - # ## When Penpot is served from a subpath, match it too: - # - "traefik.http.routers.penpot-https.rule=Host(``) && PathPrefix(`/`)" # - "traefik.http.routers.penpot-https.entrypoints=websecure" # - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt" # - "traefik.http.routers.penpot-https.tls=true" diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template index a2a90913b0..fead3783c9 100644 --- a/docker/images/files/nginx-admin-console-locations.conf.template +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -1,11 +1,7 @@ -# The Admin Console is served at the root of its own server. Keep proxy_pass -# without a URI: building it from $request_uri escapes the path twice. location /admin-console { - rewrite "^/admin-console(?:/(.*))?$" /$1 break; - proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $http_cf_connecting_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_ADMIN_CONSOLE_URI; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; } diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index d60a5d691f..3ec97403a8 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -51,23 +51,6 @@ update_oidc_name /var/www/app/js/config.js ## Nginx Config ######################################### -# Path of PENPOT_PUBLIC_URI (e.g. /penpot/ for https://example.com/penpot/). -# Requests below it are rewritten to the root, so the proxy in front of this -# server forwards them unchanged. -PENPOT_PUBLIC_PATH="$(sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://[^/]*||; s|[?#].*$||' <<< "${PENPOT_PUBLIC_URI:-}")" -PENPOT_PUBLIC_PATH="/${PENPOT_PUBLIC_PATH#/}" -export PENPOT_PUBLIC_PATH="${PENPOT_PUBLIC_PATH%/}/" - -if [ "$PENPOT_PUBLIC_PATH" != "/" ]; then - # shellcheck disable=SC2016 # literal regex metacharacters - _public_path_regex="$(sed -E 's/[][\.*^$(){}+?|]/\\&/g' <<< "$PENPOT_PUBLIC_PATH")" - export PENPOT_PUBLIC_PATH_DIRECTIVES="location = ${PENPOT_PUBLIC_PATH%/} { absolute_redirect off; return 301 ${PENPOT_PUBLIC_PATH}\$is_args\$args; } - rewrite ^${_public_path_regex}(.*)\$ /\$1 last;" - unset _public_path_regex -else - export PENPOT_PUBLIC_PATH_DIRECTIVES="" -fi - export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB @@ -75,7 +58,7 @@ export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::] if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then export PENPOT_IPV6_LISTEN_DIRECTIVE="" fi -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE,\$PENPOT_PUBLIC_PATH,\$PENPOT_PUBLIC_PATH_DIRECTIVES" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index c8800b0503..0451e7a24a 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -102,8 +102,6 @@ http { root /var/www/app/; - ${PENPOT_PUBLIC_PATH_DIRECTIVES} - location @handle_redirect { set $redirect_uri "$upstream_http_location"; set $redirect_host "$upstream_http_x_host"; @@ -169,7 +167,7 @@ http { location /readyz { access_log off; proxy_set_header Host $proxy_host; - proxy_pass $PENPOT_BACKEND_URI$uri$is_args$args; + proxy_pass $PENPOT_BACKEND_URI$request_uri; } location /ws/notifications { @@ -200,7 +198,7 @@ http { } location ~ ^/[^/]+/(.*)$ { - return 301 " ${PENPOT_PUBLIC_PATH}404"; + return 301 " /404"; } if ($penpot_link_preview_agent) { From 904f456c61b396c5f9943335448cff73af3b2929 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:27:13 +0100 Subject: [PATCH 137/167] :recycle: Use DS icons in the color palette and its menu (#12444) * :recycle: Use DS icons in the color palette and its menu The scroll arrows of the color palette and the tick of the selected palette in its menu used deprecated icons; use the DS arrow and tick icons instead. The arrows are full-height scroll buttons with a fade, not DS button sizes, so they stay raw buttons. The color cells are swatches and stay as they are. Add a visual test that opens a file with 24 local colors, so the arrows show, and captures the palette before and after scrolling and with its menu open. Its fixture is the library fixture with more colors. AI-assisted-by: claude-opus-5-5 * :recycle: Move color palette styles off the deprecated styles Inline the button, flex and ellipsis mixins, move spacing, sizes and borders to DS tokens and logical properties, and resolve the color aliases to their DS tokens. The arrow and menu item colors that change on hover, disable or selection go through local variables, and the new icons take them through currentColor instead of svg selectors. The empty palette message and the menu items use the DS body-small typography, whose line-height is 1.3 instead of 1.4. The arrows keep their z-index, now commented: the left arrow comes before the positioned color list, so without it the colors would cover its fade. The menu keeps its legacy context menu z-index, also commented. color_palette.scss and color_palette_ctx_menu.scss no longer import refactor/. AI-assisted-by: claude-opus-5-5 --- .../data/workspace/get-file-many-colors.json | 423 ++++++++++++++++++ .../ui/visual-specs/workspace.spec.js | 44 ++ .../app/main/ui/workspace/color_palette.cljs | 10 +- .../app/main/ui/workspace/color_palette.scss | 92 ++-- .../ui/workspace/color_palette_ctx_menu.cljs | 8 +- .../ui/workspace/color_palette_ctx_menu.scss | 90 ++-- 6 files changed, 561 insertions(+), 106 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-file-many-colors.json diff --git a/frontend/playwright/data/workspace/get-file-many-colors.json b/frontend/playwright/data/workspace/get-file-many-colors.json new file mode 100644 index 0000000000..bd2c2816c2 --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-many-colors.json @@ -0,0 +1,423 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Many colors", + "~:revn": 2, + "~:modified-at": "~m1717512948250", + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bc", + "~:is-shared": true, + "~:version": 48, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1717512934704", + "~:data": { + "~:pages": [ + "~uc1249a66-fce0-8175-8004-7433fe4be8bd" + ], + "~:pages-index": { + "~uc1249a66-fce0-8175-8004-7433fe4be8bd": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~uc70224ec-c410-807b-8004-743400e00be8" + ] + } + }, + "~uc70224ec-c410-807b-8004-743400e00be8": { + "~#shape": { + "~:y": 255, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 279.0000000000001, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 523, + "~:y": 255 + } + }, + { + "~#point": { + "~:x": 802.0000000000001, + "~:y": 255 + } + }, + { + "~#point": { + "~:x": 802.0000000000001, + "~:y": 534 + } + }, + { + "~#point": { + "~:x": 523, + "~:y": 534 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~uc70224ec-c410-807b-8004-743400e00be8", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 523, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 523, + "~:y": 255, + "~:width": 279.0000000000001, + "~:height": 279, + "~:x1": 523, + "~:y1": 255, + "~:x2": 802.0000000000001, + "~:y2": 534 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 0, + "~:height": 279, + "~:flip-y": null + } + } + }, + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bd", + "~:name": "Page 1" + } + }, + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bc", + "~:options": { + "~:components-v2": true + }, + "~:recent-colors": [ + { + "~:color": "#187cd5", + "~:opacity": 1 + } + ], + "~:colors": { + "~uc70224ec-c410-807b-8004-74340616d000": { + "~:path": "", + "~:color": "#d84b4b", + "~:name": "color-01", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d000" + }, + "~uc70224ec-c410-807b-8004-74340616d001": { + "~:path": "", + "~:color": "#d86f4b", + "~:name": "color-02", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d001" + }, + "~uc70224ec-c410-807b-8004-74340616d002": { + "~:path": "", + "~:color": "#d8924b", + "~:name": "color-03", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d002" + }, + "~uc70224ec-c410-807b-8004-74340616d003": { + "~:path": "", + "~:color": "#d8b54b", + "~:name": "color-04", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d003" + }, + "~uc70224ec-c410-807b-8004-74340616d004": { + "~:path": "", + "~:color": "#d8d84b", + "~:name": "color-05", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d004" + }, + "~uc70224ec-c410-807b-8004-74340616d005": { + "~:path": "", + "~:color": "#b5d84b", + "~:name": "color-06", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d005" + }, + "~uc70224ec-c410-807b-8004-74340616d006": { + "~:path": "", + "~:color": "#92d84b", + "~:name": "color-07", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d006" + }, + "~uc70224ec-c410-807b-8004-74340616d007": { + "~:path": "", + "~:color": "#6fd84b", + "~:name": "color-08", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d007" + }, + "~uc70224ec-c410-807b-8004-74340616d008": { + "~:path": "", + "~:color": "#4bd84b", + "~:name": "color-09", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d008" + }, + "~uc70224ec-c410-807b-8004-74340616d009": { + "~:path": "", + "~:color": "#4bd86f", + "~:name": "color-10", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d009" + }, + "~uc70224ec-c410-807b-8004-74340616d00a": { + "~:path": "", + "~:color": "#4bd892", + "~:name": "color-11", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00a" + }, + "~uc70224ec-c410-807b-8004-74340616d00b": { + "~:path": "", + "~:color": "#4bd8b5", + "~:name": "color-12", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00b" + }, + "~uc70224ec-c410-807b-8004-74340616d00c": { + "~:path": "", + "~:color": "#4bd8d8", + "~:name": "color-13", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00c" + }, + "~uc70224ec-c410-807b-8004-74340616d00d": { + "~:path": "", + "~:color": "#4bb5d8", + "~:name": "color-14", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00d" + }, + "~uc70224ec-c410-807b-8004-74340616d00e": { + "~:path": "", + "~:color": "#4b92d8", + "~:name": "color-15", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00e" + }, + "~uc70224ec-c410-807b-8004-74340616d00f": { + "~:path": "", + "~:color": "#4b6fd8", + "~:name": "color-16", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00f" + }, + "~uc70224ec-c410-807b-8004-74340616d010": { + "~:path": "", + "~:color": "#4b4bd8", + "~:name": "color-17", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d010" + }, + "~uc70224ec-c410-807b-8004-74340616d011": { + "~:path": "", + "~:color": "#6f4bd8", + "~:name": "color-18", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d011" + }, + "~uc70224ec-c410-807b-8004-74340616d012": { + "~:path": "", + "~:color": "#924bd8", + "~:name": "color-19", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d012" + }, + "~uc70224ec-c410-807b-8004-74340616d013": { + "~:path": "", + "~:color": "#b54bd8", + "~:name": "color-20", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d013" + }, + "~uc70224ec-c410-807b-8004-74340616d014": { + "~:path": "", + "~:color": "#d84bd8", + "~:name": "color-21", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d014" + }, + "~uc70224ec-c410-807b-8004-74340616d015": { + "~:path": "", + "~:color": "#d84bb5", + "~:name": "color-22", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d015" + }, + "~uc70224ec-c410-807b-8004-74340616d016": { + "~:path": "", + "~:color": "#d84b92", + "~:name": "color-23", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d016" + }, + "~uc70224ec-c410-807b-8004-74340616d017": { + "~:path": "", + "~:color": "#d84b6f", + "~:name": "color-24", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d017" + } + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 92ac8dddb0..db5a1002aa 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -28,6 +28,50 @@ const setupFileWithAssets = async (workspace) => { return { fileId, pageId }; }; +test.describe("Color palette", () => { + test("Shows the scroll arrows and the palette menu", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-many-colors.json", + ); + await workspace.goToWorkspace({ + fileId: "c1249a66-fce0-8175-8004-7433fe4be8bc", + pageId: "c1249a66-fce0-8175-8004-7433fe4be8bd", + }); + + await workspace.clickColorPalette(); + await workspace.palette.locator('[class*="palette-actions"]').click(); + await page.getByText("File library").click(); + await expect( + workspace.palette.getByRole("button", { name: "color-01" }), + ).toBeVisible(); + await page.mouse.move(640, 300); + // The arrows are a small part of the palette: compare every pixel. + await expect(workspace.palette).toHaveScreenshot("color-palette.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await workspace.palette.locator('[class*="right-arrow"]').click(); + await page.mouse.move(640, 300); + await expect(workspace.palette).toHaveScreenshot( + "color-palette-scrolled.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + + await workspace.palette.locator('[class*="palette-actions"]').click(); + await expect(page.getByText("Recent colors")).toBeVisible(); + await page.mouse.move(640, 300); + await expect(page).toHaveScreenshot("color-palette-menu.png", { + clip: { x: 300, y: 400, width: 700, height: 320 }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Viewport", () => { test("Shows the workspace correctly for a blank file", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/color_palette.cljs b/frontend/src/app/main/ui/workspace/color_palette.cljs index 1c95587fc4..8b97d27034 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette.cljs @@ -18,9 +18,8 @@ [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.context :as ctx] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.utilities.swatch :refer [swatch*]] - [app.main.ui.icons :as deprecated-icon] [app.util.color :as uc] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -186,10 +185,12 @@ :on-click on-toggle-search :aria-label (tr "workspace.assets.search")}]]) + ;; Arrows for scrolling the color palette, those buttons no not form part of the DS (when show-arrows? [:button {:class (stl/css :left-arrow) :disabled (= offset 0) - :on-click on-left-arrow-click} deprecated-icon/arrow]) + :on-click on-left-arrow-click} + [:> icon* {:icon-id i/arrow :class (stl/css :arrow-icon)}]]) [:div {:class (stl/css :color-palette-content) :ref container :on-wheel on-scroll} @@ -212,7 +213,8 @@ (when show-arrows? [:button {:class (stl/css :right-arrow) :disabled (= offset max-offset) - :on-click on-right-arrow-click} deprecated-icon/arrow])])) + :on-click on-right-arrow-click} + [:> icon* {:icon-id i/arrow :class (stl/css :arrow-icon)}]])])) (mf/defc recent-colors-palette* {::mf/private true} diff --git a/frontend/src/app/main/ui/workspace/color_palette.scss b/frontend/src/app/main/ui/workspace/color_palette.scss index fb0af2c527..5ac73b02a6 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.scss +++ b/frontend/src/app/main/ui/workspace/color_palette.scss @@ -4,10 +4,12 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .color-palette { - height: 100%; + block-size: 100%; display: flex; } @@ -15,63 +17,60 @@ .palette-search-collapsed { display: flex; align-items: center; - padding-inline: deprecated.$s-4; + padding-inline: var(--sp-xs); } .palette-search { - gap: deprecated.$s-4; - width: deprecated.$s-192; - min-width: deprecated.$s-192; + gap: var(--sp-xs); + inline-size: $sz-192; + min-inline-size: $sz-192; } .palette-search-collapsed { - width: deprecated.$s-32; - min-width: deprecated.$s-32; + inline-size: $sz-32; + min-inline-size: $sz-32; } +// Raw buttons on purpose: full-height scroll arrows with a fade, not DS +// button sizes. .left-arrow, .right-arrow { - @include deprecated.button-style; - @include deprecated.flex-center; + --arrow-foreground-color: var(--color-foreground-secondary); + display: flex; + justify-content: center; + align-items: center; position: relative; - height: 100%; - width: deprecated.$s-24; + block-size: 100%; + inline-size: $sz-24; padding: 0; - z-index: deprecated.$z-index-5; + border: none; + background: none; + cursor: pointer; + color: var(--arrow-foreground-color); - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } + // Above the color list, which is positioned and comes after the left + // arrow, so the fade covers the colors that scroll under it. + z-index: 5; &::after { content: ""; position: absolute; - z-index: deprecated.$z-index-1; - bottom: 0; - left: calc(-1 * deprecated.$s-80); - height: 100%; - width: deprecated.$s-80; - background-image: linear-gradient( - to left, - var(--palette-button-shadow-initial) 0%, - var(--palette-button-shadow-final) 100% - ); + z-index: 1; + inset-block-end: 0; + inset-inline-start: calc(-1 * px2rem(80)); + block-size: 100%; + inline-size: px2rem(80); + background-image: linear-gradient(to left, var(--color-background-primary) 0%, transparent 100%); pointer-events: none; } &:hover { - svg { - stroke: var(--button-foreground-hover); - } + --arrow-foreground-color: var(--color-accent-primary); } &:disabled { - svg { - stroke: var(--button-foreground-color-disabled); - } + --arrow-foreground-color: var(--color-foreground-disabled); &::after { background-image: none; @@ -81,19 +80,15 @@ .left-arrow { &::after { - left: deprecated.$s-24; - background-image: linear-gradient( - to right, - var(--palette-button-shadow-initial) 0%, - var(--palette-button-shadow-final) 100% - ); + inset-inline-start: $sz-24; + background-image: linear-gradient(to right, var(--color-background-primary) 0%, transparent 100%); } &:disabled::after { background-image: none; } - svg { + .arrow-icon { transform: rotate(180deg); } } @@ -108,7 +103,7 @@ display: grid; grid-auto-flow: column; grid-auto-columns: var(--color-cell-width); - gap: deprecated.$s-8; + gap: var(--sp-s); } .color-cell { @@ -121,17 +116,18 @@ grid-template-columns: 100%; grid-template-rows: auto 1fr; justify-items: center; - height: 100%; + block-size: 100%; &.no-text { - @include deprecated.flex-center; - - width: deprecated.$s-32; + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-32; } } .color-palette-empty { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--palette-text-color); + color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs index 4a67f0cdb1..5085b9af12 100644 --- a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs @@ -12,7 +12,7 @@ [app.main.ui.components.color-bullet :as cb] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.context :as ctx] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) @@ -75,7 +75,7 @@ (dm/str "(" (:total-colors library) ")")]] (when (= selected id) [:span {:class (stl/css :icon-wrapper)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s"}]])] [:div {:class (stl/css :color-sample) :style {:--bullet-size "20px"}} (for [color colors] @@ -100,7 +100,7 @@ (when (= selected :file) [:span {:class (stl/css :icon-wrapper)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s"}]])] [:div {:class (stl/css :color-sample) :style {:--bullet-size "20px"}} (for [color local-colors] @@ -123,7 +123,7 @@ (when (= selected :recent) [:span {:class (stl/css :icon-wrapper)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s"}]])] [:div {:class (stl/css :color-sample) :style {:--bullet-size "20px"}} diff --git a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss index 48fc511406..bce2b5f6d0 100644 --- a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss +++ b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss @@ -4,101 +4,91 @@ // // 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 *; .palette-menu { position: absolute; - left: auto; - bottom: var(--height); - max-width: deprecated.$s-480; - padding: deprecated.$s-4; - margin: 0 0 deprecated.$s-4 0; - z-index: deprecated.$z-index-4; - border-radius: deprecated.$br-10; - background-color: var(--context-menu-background-color); + inset-inline-start: auto; + inset-block-end: var(--height); + max-inline-size: $sz-480; + padding: var(--sp-xs); + margin: 0 0 var(--sp-xs) 0; + + // Context menu level of the legacy scale, inside the palette. + z-index: 4; + border-radius: px2rem(10); + background-color: var(--color-background-tertiary); .palette-library, .recent-colors, .file-library { + --library-name-color: var(--color-foreground-secondary); + position: relative; display: flex; align-items: flex-start; - width: 100%; - padding: deprecated.$s-8; - border-radius: deprecated.$br-8; - margin-bottom: deprecated.$s-4; + inline-size: 100%; + padding: var(--sp-s); + border-radius: $br-8; + margin-block-end: var(--sp-xs); &:last-child { - margin-bottom: 0; + margin-block-end: 0; } .option-wrapper { - width: 100%; + inline-size: 100%; .library-name { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--context-menu-foreground-color); + color: var(--library-name-color); display: grid; - grid-template-columns: 1fr deprecated.$s-24; + grid-template-columns: 1fr $sz-24; .lib-name-wrapper { display: flex; - max-width: deprecated.$s-400; + max-inline-size: $sz-400; .lib-name { - @include deprecated.text-ellipsis; - - max-width: deprecated.$s-380; + display: block; + max-inline-size: px2rem(380); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .lib-num { - margin-left: deprecated.$s-4; + margin-inline-start: var(--sp-xs); } } .icon-wrapper { - margin-left: deprecated.$s-4; - - @include deprecated.flex-center; - - svg { - @extend %button-icon-small; - @include deprecated.flex-center; - - stroke: var(--icon-foreground); - } + display: flex; + justify-content: center; + align-items: center; + margin-inline-start: var(--sp-xs); } } .color-sample { display: flex; flex-direction: row; - gap: deprecated.$s-8; - margin-top: deprecated.$s-4; + gap: var(--sp-s); + margin-block-start: var(--sp-xs); } } &.selected, &:hover { - .option-wrapper .library-name { - color: var(--context-menu-foreground-color-selected); - - .icon-wrapper { - @include deprecated.flex-center; - - svg { - @include deprecated.flex-center; - @extend %button-icon-small; - - stroke: var(--context-menu-foreground-color-selected); - } - } - } + --library-name-color: var(--color-foreground-primary); } &:hover { - background-color: var(--context-menu-background-color-selected); + background-color: var(--color-background-quaternary); } } } From 29b00f85350868c2d261b72e420808799197f051 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:31:14 +0100 Subject: [PATCH 138/167] :recycle: Use DS icons in the text palette and its menu (#12452) * :recycle: Use DS icons in the text palette and its menu The scroll arrows of the text palette and the tick of the selected palette in its menu used deprecated icons; use the DS arrow and tick icons instead. The arrows are full-height scroll buttons with a fade, not DS button sizes, so they stay raw buttons. Add a visual test that opens a file with 16 local typographies, so the arrows show, and captures the palette at rest, with the pointer over the right arrow, after scrolling and with its menu open. Its fixture is the library fixture with more typographies. AI-assisted-by: claude-opus-5-5 * :recycle: Move text palette styles off the deprecated styles Inline the button, flex and ellipsis mixins, move spacing, sizes and borders to DS tokens and logical properties, and resolve the color aliases to their DS tokens. The arrow, item and menu colors that change on hover, disable or selection go through local variables, and the new icons take them through currentColor instead of svg selectors. The three typography lines share their ellipsis rule. The .disabled ::after rule of the left arrow never matched and goes away; :disabled::after already covers it. The items, the empty message and the menu use the DS body-small typography, whose line-height is 1.3 instead of 1.4. The arrows keep their z-index, now commented, and the menu its legacy context menu one. text_palette.scss and text_palette_ctx_menu.scss no longer import refactor/. AI-assisted-by: claude-opus-5-5 * :bug: Show the text palette scroll arrows at rest The enabled scroll arrows of the text palette had no color at rest, so they only showed on hover, while the color palette arrows show in the secondary color. Give them the same rest color. AI-assisted-by: claude-opus-5-5 --- .../workspace/get-file-many-typographies.json | 465 ++++++++++++++++++ .../ui/visual-specs/workspace.spec.js | 51 ++ .../app/main/ui/workspace/text_palette.cljs | 8 +- .../app/main/ui/workspace/text_palette.scss | 131 +++-- .../ui/workspace/text_palette_ctx_menu.cljs | 6 +- .../ui/workspace/text_palette_ctx_menu.scss | 87 ++-- 6 files changed, 621 insertions(+), 127 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-file-many-typographies.json diff --git a/frontend/playwright/data/workspace/get-file-many-typographies.json b/frontend/playwright/data/workspace/get-file-many-typographies.json new file mode 100644 index 0000000000..1ec32392eb --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-many-typographies.json @@ -0,0 +1,465 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Many typographies", + "~:revn": 2, + "~:modified-at": "~m1717512948250", + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bc", + "~:is-shared": true, + "~:version": 48, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1717512934704", + "~:data": { + "~:pages": [ + "~uc1249a66-fce0-8175-8004-7433fe4be8bd" + ], + "~:pages-index": { + "~uc1249a66-fce0-8175-8004-7433fe4be8bd": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~uc70224ec-c410-807b-8004-743400e00be8" + ] + } + }, + "~uc70224ec-c410-807b-8004-743400e00be8": { + "~#shape": { + "~:y": 255, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 279.0000000000001, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 523, + "~:y": 255 + } + }, + { + "~#point": { + "~:x": 802.0000000000001, + "~:y": 255 + } + }, + { + "~#point": { + "~:x": 802.0000000000001, + "~:y": 534 + } + }, + { + "~#point": { + "~:x": 523, + "~:y": 534 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~uc70224ec-c410-807b-8004-743400e00be8", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 523, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 523, + "~:y": 255, + "~:width": 279.0000000000001, + "~:height": 279, + "~:x1": 523, + "~:y1": 255, + "~:x2": 802.0000000000001, + "~:y2": 534 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 0, + "~:height": 279, + "~:flip-y": null + } + } + }, + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bd", + "~:name": "Page 1" + } + }, + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bc", + "~:options": { + "~:components-v2": true + }, + "~:recent-colors": [ + { + "~:color": "#187cd5", + "~:opacity": 1 + } + ], + "~:colors": { + "~uc70224ec-c410-807b-8004-74340616cffb": { + "~:path": "", + "~:color": "#187cd5", + "~:name": "test-color-187cd5", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616cffb" + } + }, + "~:typographies": { + "~ue117f7f6-433c-807e-8004-862ab6ae3000": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "12", + "~:font-weight": "400", + "~:name": "Type 01 Regular 12", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3000", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3001": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "12", + "~:font-weight": "700", + "~:name": "Type 02 Bold 12", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3001", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3002": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "14", + "~:font-weight": "400", + "~:name": "Type 03 Regular 14", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3002", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3003": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "14", + "~:font-weight": "700", + "~:name": "Type 04 Bold 14", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3003", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3004": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "16", + "~:font-weight": "400", + "~:name": "Type 05 Regular 16", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3004", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3005": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "16", + "~:font-weight": "700", + "~:name": "Type 06 Bold 16", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3005", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3006": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "18", + "~:font-weight": "400", + "~:name": "Type 07 Regular 18", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3006", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3007": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "18", + "~:font-weight": "700", + "~:name": "Type 08 Bold 18", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3007", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3008": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "20", + "~:font-weight": "400", + "~:name": "Type 09 Regular 20", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3008", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3009": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "20", + "~:font-weight": "700", + "~:name": "Type 10 Bold 20", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3009", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300a": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "22", + "~:font-weight": "400", + "~:name": "Type 11 Regular 22", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300a", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300b": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "22", + "~:font-weight": "700", + "~:name": "Type 12 Bold 22", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300b", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300c": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "24", + "~:font-weight": "400", + "~:name": "Type 13 Regular 24", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300c", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300d": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "24", + "~:font-weight": "700", + "~:name": "Type 14 Bold 24", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300d", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300e": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "26", + "~:font-weight": "400", + "~:name": "Type 15 Regular 26", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300e", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300f": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "26", + "~:font-weight": "700", + "~:name": "Type 16 Bold 26", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300f", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + } + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index db5a1002aa..807d1a45cd 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -772,6 +772,57 @@ test.describe("Nudge modal", () => { }); }); +test.describe("Text palette", () => { + test("Shows the scroll arrows and the palette menu", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-many-typographies.json", + ); + await workspace.goToWorkspace({ + fileId: "c1249a66-fce0-8175-8004-7433fe4be8bc", + pageId: "c1249a66-fce0-8175-8004-7433fe4be8bd", + }); + + await workspace.palette + .getByRole("button", { name: "Typographies" }) + .click(); + await expect( + workspace.palette.getByText("Type 01 Regular 12"), + ).toBeVisible(); + await page.mouse.move(640, 300); + // The arrows are a small part of the palette: compare every pixel. + await expect(workspace.palette).toHaveScreenshot("text-palette.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + const rightArrow = workspace.palette.locator('[class*="right-arrow"]'); + await rightArrow.hover(); + await expect(workspace.palette).toHaveScreenshot( + "text-palette-arrow-hover.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + + await rightArrow.click(); + await page.mouse.move(640, 300); + await expect(workspace.palette).toHaveScreenshot( + "text-palette-scrolled.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + + await workspace.palette.locator('[class*="palette-actions"]').click(); + await expect(page.getByText("File library")).toBeVisible(); + await page.mouse.move(640, 300); + await expect(page).toHaveScreenshot("text-palette-menu.png", { + clip: { x: 300, y: 450, width: 700, height: 270 }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Selected colors", () => { test("Shows the more colors button", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/text_palette.cljs b/frontend/src/app/main/ui/workspace/text_palette.cljs index b65d5842f7..e951b6120b 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette.cljs @@ -14,7 +14,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.object :as obj] @@ -144,7 +144,8 @@ (when show-arrows? [:button {:class (stl/css :left-arrow) :disabled (= offset 0) - :on-click on-left-arrow-click} deprecated-icon/arrow]) + :on-click on-left-arrow-click} + [:> icon* {:icon-id i/arrow :class (stl/css :arrow-icon)}]]) [:div {:class (stl/css :text-palette-content) :ref container @@ -173,7 +174,8 @@ (when show-arrows? [:button {:class (stl/css :right-arrow) :disabled (= offset max-offset) - :on-click on-right-arrow-click} deprecated-icon/arrow])])) + :on-click on-right-arrow-click} + [:> icon* {:icon-id i/arrow :class (stl/css :arrow-icon)}]])])) (mf/defc text-palette* {::mf/wrap [mf/memo]} diff --git a/frontend/src/app/main/ui/workspace/text_palette.scss b/frontend/src/app/main/ui/workspace/text_palette.scss index 61cf22e34b..32dd942dc9 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.scss +++ b/frontend/src/app/main/ui/workspace/text_palette.scss @@ -4,54 +4,56 @@ // // 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 *; .text-palette { - height: 100%; + block-size: 100%; display: flex; } +// Raw buttons on purpose: full-height scroll arrows with a fade, not DS +// button sizes. .left-arrow, .right-arrow { - @include deprecated.button-style; - @include deprecated.flex-center; + --arrow-foreground-color: var(--color-foreground-secondary); + display: flex; + justify-content: center; + align-items: center; position: relative; - height: 100%; - width: deprecated.$s-24; + block-size: 100%; + inline-size: $sz-24; padding: 0; - z-index: deprecated.$z-index-2; + border: none; + background: none; + cursor: pointer; + color: var(--arrow-foreground-color); - svg { - @extend %button-icon; - } + // Above the typography list, which is positioned and comes after the + // left arrow, so the fade covers the items that scroll under it. + z-index: 2; &::after { content: ""; position: absolute; - bottom: 0; - left: calc(-1 * deprecated.$s-80); - height: 100%; - width: deprecated.$s-80; - z-index: deprecated.$z-index-1; - background-image: linear-gradient( - to left, - var(--palette-button-shadow-initial) 0%, - var(--palette-button-shadow-final) 100% - ); + inset-block-end: 0; + inset-inline-start: calc(-1 * px2rem(80)); + block-size: 100%; + inline-size: px2rem(80); + z-index: 1; + background-image: linear-gradient(to left, var(--color-background-primary) 0%, transparent 100%); pointer-events: none; } &:hover { - svg { - stroke: var(--button-foreground-hover); - } + --arrow-foreground-color: var(--color-accent-primary); } &:disabled { - svg { - stroke: var(--button-foreground-color-disabled); - } + --arrow-foreground-color: var(--color-foreground-disabled); &::after { background-image: none; @@ -61,19 +63,11 @@ .left-arrow { &::after { - left: deprecated.$s-24; - background-image: linear-gradient( - to right, - var(--palette-button-shadow-initial) 0%, - var(--palette-button-shadow-final) 100% - ); + inset-inline-start: $sz-24; + background-image: linear-gradient(to right, var(--color-background-primary) 0%, transparent 100%); } - &.disabled ::after { - background-image: none; - } - - svg { + .arrow-icon { transform: rotate(180deg); } } @@ -85,55 +79,46 @@ .text-palette-inside { display: flex; - gap: deprecated.$s-8; + gap: var(--sp-s); } .typography-item { - @include deprecated.body-small-typography; + --typography-item-background-color: var(--color-background-tertiary); + + @include use-typography("body-small"); display: flex; flex-direction: column; justify-content: center; align-items: flex-start; - height: 100%; - width: deprecated.$s-136; - padding: deprecated.$s-8; - border-radius: deprecated.$br-8; - background-color: var(--palette-text-background-color); + block-size: 100%; + inline-size: px2rem(136); + padding: var(--sp-s); + border-radius: $br-8; + background-color: var(--typography-item-background-color); &:first-child { - margin-left: deprecated.$s-8; + margin-inline-start: var(--sp-s); + } + + .typography-name, + .typography-font, + .typography-data { + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + block-size: $sz-16; + inline-size: $sz-120; + color: var(--color-foreground-secondary); } .typography-name { - @include deprecated.text-ellipsis; - - height: deprecated.$s-16; - width: deprecated.$s-120; - color: var(--palette-text-color-selected); - } - - .typography-font { - @include deprecated.text-ellipsis; - - height: deprecated.$s-16; - width: deprecated.$s-120; - color: var(--palette-text-color); - } - - .typography-data { - @include deprecated.text-ellipsis; - - height: deprecated.$s-16; - width: deprecated.$s-120; - color: var(--palette-text-color); + color: var(--color-foreground-primary); } &.mid-item { - .typography-name { - height: deprecated.$s-16; - } - .typography-data { display: none; } @@ -147,12 +132,12 @@ } &:hover { - background-color: var(--palette-text-background-color-hover); + --typography-item-background-color: var(--color-background-quaternary); } } .text-palette-empty { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--palette-text-color); + color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs index cdc13170f2..fd4ef95f3b 100644 --- a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs @@ -10,7 +10,7 @@ [app.common.data.macros :as dm] [app.main.refs :as refs] [app.main.ui.components.dropdown :refer [dropdown]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) @@ -37,7 +37,7 @@ (when (= selected (:id cur-library)) [:span {:class (stl/css :icon-wrapper)} - deprecated-icon/tick])])) + [:> icon* {:icon-id i/tick :size "s"}]])])) [:li {:class (stl/css-case :file-library true @@ -51,4 +51,4 @@ (dm/str "(" (count typographies) ")")]] (when (= selected :file) [:span {:class (stl/css :icon-wrapper)} - deprecated-icon/tick])]]])) + [:> icon* {:icon-id i/tick :size "s"}]])]]])) diff --git a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss index 9cf70b7e93..bc1ca05c25 100644 --- a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss +++ b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss @@ -4,86 +4,77 @@ // // 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 *; .text-context-menu { position: absolute; - left: auto; - bottom: var(--height); - max-width: deprecated.$s-480; - padding: deprecated.$s-4; - margin: 0 0 deprecated.$s-4 0; - z-index: deprecated.$z-index-4; - border-radius: deprecated.$br-10; - background-color: var(--context-menu-background-color); + inset-inline-start: auto; + inset-block-end: var(--height); + max-inline-size: $sz-480; + padding: var(--sp-xs); + margin: 0 0 var(--sp-xs) 0; + + // Context menu level of the legacy scale, inside the palette. + z-index: 4; + border-radius: px2rem(10); + background-color: var(--color-background-tertiary); .palette-library, .file-library { + --library-foreground-color: var(--color-foreground-secondary); + position: relative; display: flex; justify-content: space-between; - width: 100%; - gap: deprecated.$s-8; - padding: deprecated.$s-8; - margin-bottom: deprecated.$s-4; - border-radius: deprecated.$br-8; + inline-size: 100%; + gap: var(--sp-s); + padding: var(--sp-s); + margin-block-end: var(--sp-xs); + border-radius: $br-8; &:last-child { - margin-bottom: 0; + margin-block-end: 0; } .library-name { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--context-menu-foreground-color); + color: var(--library-foreground-color); display: grid; - grid-template-columns: 1fr deprecated.$s-24; - max-width: deprecated.$s-400; + grid-template-columns: 1fr $sz-24; + max-inline-size: $sz-400; .lib-name { - @include deprecated.text-ellipsis; - - max-width: deprecated.$s-380; + display: block; + max-inline-size: px2rem(380); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .lib-num { - margin-left: deprecated.$s-4; + margin-inline-start: var(--sp-xs); } } .icon-wrapper { - margin-left: deprecated.$s-4; - - @include deprecated.flex-center; - - svg { - @include deprecated.flex-center; - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } + display: flex; + justify-content: center; + align-items: center; + margin-inline-start: var(--sp-xs); + color: var(--library-foreground-color); } &.selected, &:hover { - .icon-wrapper { - @include deprecated.flex-center; - - svg { - @include deprecated.flex-center; - @extend %button-icon-small; - - stroke: var(--context-menu-foreground-color-selected); - } - } - - .library-name { - color: var(--context-menu-foreground-color-selected); - } + --library-foreground-color: var(--color-foreground-primary); } &:hover { - background-color: var(--context-menu-background-color-selected); + background-color: var(--color-background-quaternary); } } } From ac08e38c1a61b108f98cb5086b009aafccd3c07a Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:31:34 +0100 Subject: [PATCH 139/167] :recycle: Use DS buttons and icons in the export assets modal (#12517) * :recycle: Use DS buttons and icons in the export assets modal The export modal close button becomes a ghost DS icon button and the progress toast close button an action one, which fits its 24px grid column; both are labelled "Close". Cancel and Export become DS buttons. The checkbox ticks and the toast icons use DS icons. The selection rows stay raw buttons, since they are list rows, and so does the link-styled retry/cancel button of the toast. The export frames e2e spec now finds the Export button by role, and a visual test covers the modal with all, some and no rows selected. AI-assisted-by: claude-opus-5-5 * :recycle: Make the export progress ring a button The export progress ring in the header opens the export details, but it was a div with a click handler. It is now a button named after the export status, with aria-expanded, and keeps its look. AI-assisted-by: claude-opus-5-5 * :recycle: Move export assets modal styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties, and the button placeholders go away. The classes left without use are removed: the old modal buttons, the old close icon, and the file export entries, which live in files.scss. The refactor/ import stays only for the shared modal and checkbox placeholders, which move to the DS with the modals and the checkboxes. The checkbox placeholder shrinks a ticked icon to 12px, which pushed the DS tick off center: the tick keeps its 16px box now, and does not shrink inside the 14px flex box either. AI-assisted-by: claude-opus-5-5 --- .../playwright/ui/specs/export-frames.spec.js | 5 +- .../ui/visual-specs/workspace.spec.js | 55 +++ frontend/src/app/main/ui/exports/assets.cljs | 68 ++-- frontend/src/app/main/ui/exports/assets.scss | 366 ++++++++---------- 4 files changed, 246 insertions(+), 248 deletions(-) diff --git a/frontend/playwright/ui/specs/export-frames.spec.js b/frontend/playwright/ui/specs/export-frames.spec.js index fee390204d..aa34953a83 100644 --- a/frontend/playwright/ui/specs/export-frames.spec.js +++ b/frontend/playwright/ui/specs/export-frames.spec.js @@ -142,7 +142,10 @@ test.describe("Export frames to PDF", () => { await expect(page.getByText("1 of 2 elements selected")).toBeVisible(); // The export button should still be enabled - const exportButton = page.locator("input[type='button'][value='Export']"); + const exportButton = page.getByRole("button", { + name: "Export", + exact: true, + }); await expect(exportButton).toBeEnabled(); }); diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 807d1a45cd..48e715b175 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -908,3 +908,58 @@ test.describe("Grid cell options", () => { }); }); }); + +test.describe("Export modal", () => { + test("Shows the export frames modal with every selection state", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-export-frames.json", + ); + await workspace.mockRPC( + "get-file-object-thumbnails?file-id=*", + "workspace/get-file-object-thumbnails-blank.json", + ); + await workspace.mockRPC( + "get-file-fragment?file-id=*", + "workspace/get-file-fragment-blank.json", + ); + await workspace.goToWorkspace({ + fileId: "8d102c33-b98f-81f6-8006-fdd4ea9780d7", + pageId: "8d102c33-b98f-81f6-8006-fdd4ea9780d8", + }); + + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + await page.locator("#file-menu-export-frames").click(); + const modal = page.getByText("Export as PDF").locator("../.."); + await expect(page.getByText("2 of 2 elements selected")).toBeVisible(); + await page.mouse.move(0, 0); + // Every row checked. + await expect(modal).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await page.getByRole("button", { name: "Board 1" }).click(); + await expect(page.getByText("1 of 2 elements selected")).toBeVisible(); + await page.mouse.move(0, 0); + // Mixed: the header checkbox shows the "some selected" mark. + await expect(modal).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await page.getByRole("button", { name: "Board 2" }).click(); + await expect(page.getByText("0 of 2 elements selected")).toBeVisible(); + await page.mouse.move(0, 0); + // Nothing checked: the export button is disabled. + await expect(modal).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index 83140cb47c..eb842c1f6b 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -16,7 +16,9 @@ [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.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.workspace.shapes :refer [shape-wrapper]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr c]] @@ -25,15 +27,6 @@ [cuerdas.core :as str] [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 close-icon - (deprecated-icon/icon-xref :close (stl/css :close-icon))) - (mf/defc export-multiple-dialog* {::mf/private true} [{:keys [exports title cmd no-selection origin name]}] @@ -83,9 +76,11 @@ [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} title] - [:button {:class (stl/css :modal-close-btn) - :on-click cancel-fn} - deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click cancel-fn}]] [:* [:div {:class (stl/css :modal-content)} @@ -98,12 +93,12 @@ (cond all-checked? [:span {:class (stl/css-case :checkobox-tick true :global/checked true)} - deprecated-icon/tick] + [:> icon* {:icon-id i/tick :size "s"}]] all-unchecked? [:span {:class (stl/css-case :checkobox-tick true :global/uncheked true)}] :else [:span {:class (stl/css-case :checkobox-tick true :global/intermediate true)} - deprecated-icon/remove-icon])]] + [:> icon* {:icon-id i/remove :size "s"}]])]] [:div {:class (stl/css :selection-title)} (tr "dashboard.export-multiple.selected" (c (count enabled-exports)) @@ -122,7 +117,7 @@ (if (:enabled export) [:span {:class (stl/css-case :checkobox-tick true :global/checked true)} - deprecated-icon/tick] + [:> icon* {:icon-id i/tick :size "s"}]] [:span {:class (stl/css-case :checkobox-tick true :global/uncheked true)}])] @@ -158,19 +153,16 @@ (when (> (count all-exports) 0) [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click cancel-fn}] + [:> button* {:variant "secondary" + :on-click cancel-fn} + (tr "labels.cancel")] - [:input {:class (stl/css-case :accept-btn true - :btn-disabled (or in-progress? all-unchecked?)) - :disabled (or in-progress? all-unchecked?) - :type "button" - :value (if in-progress? - (tr "workspace.options.exporting-object") - (tr "labels.export")) - :on-click (when-not in-progress? accept-fn)}]]])]]])) + [:> button* {:variant "primary" + :disabled (or in-progress? all-unchecked?) + :on-click (when-not in-progress? accept-fn)} + (if in-progress? + (tr "workspace.options.exporting-object") + (tr "labels.export"))]]])]]])) (mf/defc shapes-no-selection [] [:div {:class (stl/css :no-selection)} @@ -291,8 +283,11 @@ [:* (when widget-visible? - [:div {:class (stl/css :export-progress-widget) - :on-click toggle-detail-visibility} + [:button {:type "button" + :class (stl/css :export-progress-widget) + :aria-label title + :aria-expanded (boolean detail-visible?) + :on-click toggle-detail-visibility} [:svg {:width "24" :height "24"} [:circle {:r "10" :cx "12" @@ -314,9 +309,8 @@ (when detail-visible? [:div {:class (stl/css-case :export-progress-modal true :has-error error?)} - (if error? - error-icon - neutral-icon) + [:> icon* {:icon-id (if error? i/delete-text i/msg-neutral) + :class (stl/css :icon)}] [:div {:class (stl/css :export-progress-title)} [:div {:class (stl/css :title-text)} title] @@ -342,9 +336,11 @@ [:span {:class (stl/css :progress)} (dm/str progress " / " total)])] - [:button {:class (stl/css :progress-close-button) - :on-click toggle-detail-visibility} - close-icon] + [:> icon-button* {:variant "action" + :tooltip-class (stl/css :progress-close-button) + :aria-label (tr "labels.close") + :icon i/close + :on-click toggle-detail-visibility}] (when-not error? [:svg {:class (stl/css :progress-bar) diff --git a/frontend/src/app/main/ui/exports/assets.scss b/frontend/src/app/main/ui/exports/assets.scss index 2780d53ea7..db3ec2d612 100644 --- a/frontend/src/app/main/ui/exports/assets.scss +++ b/frontend/src/app/main/ui/exports/assets.scss @@ -4,66 +4,75 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared modal and checkbox placeholders, which move to the DS +// with the modals and the checkboxes. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; // PROGRESS WIDGET .export-progress-widget { - @include deprecated.flex-center; - - width: deprecated.$s-28; - height: deprecated.$s-28; + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-28; + block-size: $sz-28; + padding: 0; + border: none; + background: none; + cursor: pointer; } // PROGRESS MODAL .export-progress-modal { - --export-modal-bg-color: var(--alert-background-color-default); - --export-modal-fg-color: var(--alert-text-foreground-color-default); - --export-modal-icon-color: var(--alert-icon-foreground-color-default); - --export-modal-border-color: var(--alert-border-color-default); + --export-modal-bg-color: var(--color-background-primary); + --export-modal-fg-color: var(--color-foreground-primary); + --export-modal-icon-color: var(--color-foreground-primary); + --export-modal-border-color: var(--color-background-quaternary); position: absolute; - right: deprecated.$s-16; - top: deprecated.$s-48; + inset-inline-end: var(--sp-l); + inset-block-start: $sz-48; display: grid; - grid-template-columns: deprecated.$s-24 1fr deprecated.$s-24; + grid-template-columns: $sz-24 1fr $sz-24; grid-template-areas: "icon text close" "bar bar bar"; - gap: deprecated.$s-4 deprecated.$s-8; - padding-block-start: deprecated.$s-8; + gap: var(--sp-xs) var(--sp-s); + padding-block-start: var(--sp-s); background-color: var(--export-modal-bg-color); - border: deprecated.$s-1 solid var(--export-modal-border-color); - border-radius: deprecated.$br-8; - z-index: deprecated.$z-index-modal; + border: $b-1 solid var(--export-modal-border-color); + border-radius: $br-8; + z-index: var(--z-index-set); overflow: hidden; } .has-error { - --export-modal-bg-color: var(--alert-background-color-error); - --export-modal-fg-color: var(--alert-text-foreground-color-error); - --export-modal-icon-color: var(--alert-icon-foreground-color-error); - --export-modal-border-color: var(--alert-border-color-error); + --export-modal-bg-color: var(--color-background-error); + --export-modal-icon-color: var(--color-accent-error); + --export-modal-border-color: var(--color-accent-error); grid-template-areas: "icon text close"; - gap: deprecated.$s-8; - padding-block: deprecated.$s-8; + gap: var(--sp-s); + padding-block: var(--sp-s); } .icon { - @extend %button-icon; - grid-area: icon; align-self: center; - margin-inline-start: deprecated.$s-8; - stroke: var(--export-modal-icon-color); + margin-inline-start: var(--sp-s); + color: var(--export-modal-icon-color); } .export-progress-title { - @include deprecated.body-medium-typography; + @include use-typography("body-medium"); display: grid; grid-template-columns: auto 1fr; - gap: deprecated.$s-8; + gap: var(--sp-s); grid-area: text; align-self: center; padding: 0; @@ -72,130 +81,137 @@ } .progress { - @include deprecated.body-medium-typography; + @include use-typography("body-medium"); - padding-left: deprecated.$s-8; + padding-inline-start: var(--sp-s); margin: 0; align-self: center; - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } +// Link-styled text button: stays a raw button. .retry-btn { - @include deprecated.button-style; - @include deprecated.body-small-typography; + @include use-typography("body-small"); display: inline; - text-align: left; - color: var(--modal-link-foreground-color); + text-align: start; + color: var(--color-accent-primary); margin: 0; padding: 0; + border: none; + background: none; + cursor: pointer; } .progress-close-button { - @include deprecated.button-style; - - padding: 0; - margin-inline-end: deprecated.$s-8; -} - -.close-icon { - @extend %button-icon; - - stroke: var(--export-modal-icon-color); + margin-inline-end: var(--sp-s); } .progress-bar { - margin-top: 0; + margin-block-start: 0; grid-area: bar; } // EXPORT MODAL .modal-overlay { @extend %modal-overlay-base; - - &.transparent { - background-color: transparent; - } } .modal-container { @extend %modal-container-base; - max-height: calc(10 * deprecated.$s-80); + max-block-size: px2rem(800); } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.headline-medium-typography; + @include use-typography("headline-medium"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content, .no-selection { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); .modal-link { - @include deprecated.body-large-typography; + @include use-typography("body-large"); text-decoration: none; cursor: pointer; - color: var(--modal-link-foreground-color); + color: var(--color-accent-primary); } .selection-header { - @include deprecated.flex-row; - - height: deprecated.$s-32; - margin-bottom: deprecated.$s-4; + display: flex; + align-items: center; + gap: var(--sp-xs); + block-size: $sz-32; + margin-block-end: var(--sp-xs); .selection-btn { - @include deprecated.button-style; @extend %input-checkbox; - @include deprecated.flex-center; - height: deprecated.$s-24; - width: deprecated.$s-24; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; padding: 0; - margin-left: deprecated.$s-16; + margin-inline-start: var(--sp-l); + border: none; + background: none; + cursor: pointer; span { @extend %checkbox-icon; + + // The DS icon draws its 12px glyph centered in a 16px box: keep that + // box, unshrunk, instead of the 12px the placeholder gives a legacy + // icon. + &:global(.checked) svg, + &:global(.intermediate) svg { + flex-shrink: 0; + inline-size: $sz-16; + block-size: $sz-16; + } } } .selection-title { - @include deprecated.body-large-typography; + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } } .selection-wrapper { position: relative; - width: 100%; - height: fit-content; + inline-size: 100%; + block-size: fit-content; } .selection-shadow { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; &::after { position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 50px; + inset-block-end: 0; + inset-inline-start: 0; + inline-size: 100%; + block-size: px2rem(50); background: linear-gradient(to top, rgb(24 24 26 / 1) 0%, rgb(24 24 26 / 0) 100%); content: ""; pointer-events: none; @@ -203,84 +219,99 @@ } .selection-list { - @include deprecated.flex-column; - - max-height: deprecated.$s-400; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + max-block-size: $sz-400; overflow-y: auto; - padding-bottom: deprecated.$s-12; + padding-block-end: var(--sp-m); .selection-row { - @include deprecated.flex-row; - - background-color: var(--entry-background-color); - min-height: deprecated.$s-40; - border-radius: deprecated.$br-8; + display: flex; + align-items: center; + gap: var(--sp-xs); + background-color: var(--color-background-tertiary); + min-block-size: $sz-40; + border-radius: $br-8; + // A list row, not a DS button: the whole row toggles its export. .selection-btn { - @include deprecated.button-style; - display: grid; grid-template-columns: min-content auto 1fr auto auto; align-items: center; - width: 100%; - height: 10%; - gap: deprecated.$s-8; - padding: 0 deprecated.$s-16; + inline-size: 100%; + block-size: 10%; + gap: var(--sp-s); + padding: 0 var(--sp-l); + border: none; + background: none; + cursor: pointer; .checkbox-wrapper { @extend %input-checkbox; - @include deprecated.flex-center; - height: deprecated.$s-24; - width: deprecated.$s-24; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; padding: 0; .checkobox-tick { @extend %checkbox-icon; + + // The DS icon draws its 12px glyph centered in a 16px box: keep that + // box, unshrunk, instead of the 12px the placeholder gives a + // legacy icon. + &:global(.checked) svg, + &:global(.intermediate) svg { + flex-shrink: 0; + inline-size: $sz-16; + block-size: $sz-16; + } } } - .selection-name { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; + .selection-name, + .selection-scale, + .selection-extension { + @include use-typography("body-large"); + @include text-ellipsis; + color: var(--color-foreground-secondary); + } + + .selection-name { flex-grow: 1; - color: var(--modal-text-foreground-color); text-align: start; } .selection-scale { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-108; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); + min-inline-size: px2rem(108); + padding: var(--sp-m); } .selection-extension { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-72; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); + min-inline-size: px2rem(72); + padding: var(--sp-m); } } .image-wrapper { - @include deprecated.flex-center; - - min-height: deprecated.$s-32; - min-width: deprecated.$s-32; - background-color: var(--app-white); - border-radius: deprecated.$br-6; + display: flex; + justify-content: center; + align-items: center; + min-block-size: $sz-32; + min-inline-size: $sz-32; + background-color: var(--color-static-white); + border-radius: $br-6; margin: auto 0; + // The thumbnail markup has no class of its own. img, svg { object-fit: contain; - max-height: deprecated.$s-40; + max-block-size: $sz-40; } } } @@ -288,101 +319,14 @@ } .action-buttons { - @extend %modal-action-btns; -} - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } + display: flex; + justify-content: flex-end; + gap: var(--sp-l); } .modal-scd-msg, -.modal-subtitle, .modal-msg { - @include deprecated.body-large-typography; + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); -} - -.export-option { - @extend %input-checkbox; - - width: 100%; - align-items: flex-start; - - label { - align-items: flex-start; - - .modal-subtitle { - @include deprecated.body-large-typography; - - color: var(--modal-title-foreground-color); - } - } - - span { - margin-top: deprecated.$s-8; - } -} - -.option-content { - @include deprecated.flex-column; - @include deprecated.body-large-typography; -} - -.file-entry { - .file-name { - @include deprecated.flex-row; - - .file-icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--input-foreground); - } - } - - .file-name-label { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - } - } - - &.loading { - .file-name { - color: var(--modal-text-foreground-color); - } - } - - &.error { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - } - } - - &.success { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - } - } + color: var(--color-foreground-secondary); } From 7ff8df6ffb583b62d36e687214ab5b3228d3a997 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:31:51 +0100 Subject: [PATCH 140/167] :recycle: Name the grid track expand buttons and make the annotation toggle a button (#12489) * :recycle: Name the grid track expand buttons The menu button that expands the columns or rows of a grid layout had no accessible name. Label it "Columns" or "Rows" and expose its state with aria-expanded. The track title next to it also toggles on click; it stays a div, as a larger pointer target for the same action, so the keyboard does not stop twice on it. Add the workspace.layout-grid.editor.columns and rows keys (en, es). AI-assisted-by: claude-opus-5-5 * :recycle: Make the annotation title toggle a button The annotation title of a component toggled from a div with a click handler, and it holds the edit, delete, save and discard buttons, so it cannot become a single button. Move the toggle to a button with the arrow and the "Annotation" name, with aria-expanded, that takes the whole free width of the title. While editing or creating, the title stays text and no longer collapses the annotation on click, as its cursor already suggested. Extend the annotation visual test with the collapsed state; it looks the same as before, pixel for pixel. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 10 ++++++++++ .../sidebar/options/menus/component.cljs | 11 ++++++---- .../sidebar/options/menus/component.scss | 20 +++++++++++++++---- .../options/menus/layout_container.cljs | 9 ++++++++- frontend/translations/en.po | 8 ++++++++ frontend/translations/es.po | 8 ++++++++ 6 files changed, 57 insertions(+), 9 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 48e715b175..763fc4cfb1 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -374,6 +374,16 @@ test.describe("Component options", () => { "annotation-saved.png", { maxDiffPixelRatio: 0, threshold: 0 }, ); + + // Collapsed by clicking its title. + await title.click(); + await expect( + workspace.rightSidebar.getByText("An annotation"), + ).toBeHidden(); + await expect(workspace.rightSidebar).toHaveScreenshot( + "annotation-collapsed.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); }); }); 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 00660a3dfb..9f30b24036 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 @@ -175,9 +175,7 @@ :editing editing? :creating creating?)} [:div {:class (stl/css-case :annotation-title true - :expandeable (not (or editing? creating?)) - :expanded expanded?) - :on-click on-toggle-expand} + :expanded expanded?)} (if (or editing? creating?) [:span {:class (stl/css :annotation-title-name)} @@ -185,7 +183,12 @@ (tr "workspace.options.component.edit-annotation") (tr "workspace.options.component.create-annotation"))] - [:* + ;; Only the arrow and the name toggle: the actions next to them + ;; are buttons too, and a button cannot hold other buttons. + [:button {:type "button" + :class (stl/css :annotation-title-toggle) + :aria-expanded (boolean expanded?) + :on-click on-toggle-expand} [:> icon* {:icon-id (if expanded? i/arrow-down i/arrow-right) :class (stl/css :annotation-title-icon-arrow) :size "s"}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index acbe025003..77403735b7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -73,10 +73,6 @@ border-bottom: $b-1 solid var(--color-background-quaternary); } - &.expandeable { - cursor: pointer; - } - &:hover { .annotation-title-icon-action { display: flex; @@ -84,6 +80,22 @@ } } +// Takes the whole free width of the title, so the clickable area stays +// as it was when the whole title toggled. +.annotation-title-toggle { + display: flex; + flex-grow: 1; + align-items: center; + align-self: stretch; + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; +} + .annotation-title-name { flex-grow: 1; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index f8dcee251d..53b53d711c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -1037,7 +1037,14 @@ [:div {:class (stl/css :grid-tracks) :data-testid testid} [:div {:class (stl/css :grid-track-header)} - [:button {:class (stl/css :expand-icon) :on-click toggle} + ;; The track title next to it toggles too, as a larger pointer + ;; target; this button is the one for the keyboard. + [:button {:class (stl/css :expand-icon) + :aria-label (if is-column + (tr "workspace.layout-grid.editor.columns") + (tr "workspace.layout-grid.editor.rows")) + :aria-expanded (boolean expanded?) + :on-click toggle} [:> icon* {:icon-id i/menu :class (stl/css :expand-icon-svg)}]] [:div {:class (stl/css :track-title) :on-click toggle} [:div {:class (stl/css :track-name) :title track-name} track-name] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fe90880cc5..e971e9decc 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6747,6 +6747,14 @@ msgstr "Full screen" msgid "workspace.header.zoom-selected" msgstr "Zoom to selected" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.columns" +msgstr "Columns" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.rows" +msgstr "Rows" + #: src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:294, src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:296, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:854 msgid "workspace.layout-grid.editor.options.edit-grid" msgstr "Edit grid" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 2d2b8109bd..65ab208de5 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -6831,6 +6831,14 @@ msgstr "Pantalla completa" msgid "workspace.header.zoom-selected" msgstr "Zoom a selección" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.columns" +msgstr "Columnas" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.rows" +msgstr "Filas" + #: src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:294, src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:296, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:854 msgid "workspace.layout-grid.editor.options.edit-grid" msgstr "Editar grid" From 9911f82087079527e36e5b29c77e747f4fe7d5f0 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:32:06 +0100 Subject: [PATCH 141/167] :recycle: Use a DS icon in the grid editor track options button (#12486) * :recycle: Use a DS icon in the grid editor track options button The options button of each grid track on the canvas drew a deprecated icon and had no accessible name. Use the DS menu icon and label the button "Track options". The button stays raw: it lives on the canvas, where the track editor keeps a fixed screen size at any zoom. The DS icon has a fixed 16px size, so its class scales it down with the zoom (transform: scale(1 / zoom)), as the old svg rule did with its width. It looks the same, but for a few antialiasing pixels. Add the workspace.layout-grid.editor.track-options key (en, es) and a visual test of a track at two zoom levels. AI-assisted-by: claude-opus-5-5 * :recycle: Move grid editor styles off the deprecated styles Move grid_layout_editor.scss to DS sizes, borders, spacing tokens, colors and logical properties, and drop the refactor import. The canvas sizes keep their division by the zoom. The grid editor color aliases resolve to their DS tokens, and the unused close-btn class, the last user of the deprecated button placeholders here, goes away. The track editor and the editing bar look the same as before. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 57 ++++++++++ .../viewport/grid_layout_editor.cljs | 12 +- .../viewport/grid_layout_editor.scss | 106 +++++++++--------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 5 files changed, 128 insertions(+), 55 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 763fc4cfb1..2423ac72e0 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -136,6 +136,63 @@ test.describe("Grid layout", () => { }); }); +test.describe("Grid editor", () => { + test("Shows the track options button on the canvas", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9042.json"); + await workspace.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-fragment-9042.json", + ); + await workspace.goToWorkspace({ + fileId: "af2494d0-39ba-8184-8005-230696f6df5c", + pageId: "af2494d0-39ba-8184-8005-230696f6df5d", + }); + + await workspace.clickLeafLayer("Board"); + // Tracks need 110px or more on screen to show their options button. + await page.keyboard.press("Control+="); + await page.keyboard.press("Control+="); + await page + .getByTestId("inspect-layout") + .getByRole("button", { name: "Edit grid" }) + .click(); + + const shotTrack = async () => { + const track = page.getByTestId("grid-track-editor-wrapper").first(); + await track.hover(); + await expect( + page.getByTestId("grid-track-options-btn").first(), + ).toBeVisible(); + // Wait until the zoom animation leaves the track in place. + let box = await track.boundingBox(); + await expect + .poll(async () => { + const prev = box; + box = await track.boundingBox(); + return prev.x === box.x && prev.width === box.width; + }) + .toBe(true); + await expect(page).toHaveScreenshot({ + clip: { + x: Math.floor(box.x) - 8, + y: Math.floor(box.y) - 8, + width: Math.ceil(box.width) + 16, + height: Math.ceil(box.height) + 16, + }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + }; + + // The track editors draw at a fixed screen size at any zoom. + await shotTrack(); + await page.keyboard.press("Control+="); + await shotTrack(); + }); +}); + test.describe("Design tab", () => { test("Shows the boolean options when selecting two shapes", async ({ page, diff --git a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs index d8e1314dc4..2f92b9e19e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs @@ -29,8 +29,8 @@ [app.main.store :as st] [app.main.ui.css-cursors :as cur] [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.formats :as fmt] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.util.debug :as dbg] [app.util.dom :as dom] @@ -918,9 +918,15 @@ :on-key-down handle-keydown-track-input :on-blur handle-blur-track-input}] (when (and (not medium?) (not small?)) - [:button {:class (stl/css :grid-editor-button) + ;; Raw button on purpose: it lives on the canvas and keeps a + ;; fixed screen size at any zoom. + [:button {:type "button" + :class (stl/css :grid-editor-button) :data-testid "grid-track-options-btn" - :on-click handle-show-track-menu} deprecated-icon/menu])]])] + :aria-label (tr "workspace.layout-grid.editor.track-options") + :on-click handle-show-track-menu} + [:> icon* {:icon-id i/menu + :class (stl/css :grid-editor-button-icon)}]])]])] [:g {:transform (when (= type :row) (dm/fmt "rotate(-90 % %)" (:x marker-p) (:y marker-p)))} [:& track-marker diff --git a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss index 008f4e8b0d..6215876b48 100644 --- a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss +++ b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss @@ -4,17 +4,23 @@ // // 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/colors.scss" as *; @use "ds/z-index.scss" as *; +// The track editor lives on the canvas: its sizes are divided by the zoom +// so it keeps the same size on screen. + .grid-track-marker { .marker-shape { - fill: var(--grid-editor-marker-color); + fill: var(--color-accent-quaternary); } .marker-text { - fill: var(--app-white); - font-size: calc(deprecated.$s-12 / var(--zoom)); + fill: $white; + font-size: calc(#{px2rem(12)} / var(--zoom)); font-family: "worksans", "vazirmatn", sans-serif; } } @@ -22,8 +28,8 @@ .grid-editor-wrapper { cursor: grab; position: relative; - width: 100%; - height: 80%; + inline-size: 100%; + block-size: 80%; display: flex; flex-direction: column; justify-content: center; @@ -31,21 +37,20 @@ } .grid-editor-header-hover { - fill: var(--grid-editor-marker-color); + fill: var(--color-accent-quaternary); } .grid-editor-label { flex: 1; background: none; border: 0; - color: var(--grid-editor-marker-text); + color: var(--color-accent-quaternary); font-family: "worksans", "vazirmatn", sans-serif; - font-size: calc(deprecated.$fs-12 / var(--zoom)); + font-size: calc(#{px2rem(12)} / var(--zoom)); font-weight: 400; margin: 0; - max-width: calc(deprecated.$s-60 / var(--zoom)); - padding: 0; - padding: calc(deprecated.$s-4 / var(--zoom)); + max-inline-size: calc(#{px2rem(60)} / var(--zoom)); + padding: calc(var(--sp-xs) / var(--zoom)); text-align: center; &:focus { @@ -60,21 +65,23 @@ margin: 0; padding: 0; position: absolute; - top: calc(deprecated.$s-6 / var(--zoom)); - right: calc(deprecated.$s-20 / var(--zoom)); - width: calc(deprecated.$s-20 / var(--zoom)); - height: calc(deprecated.$s-20 / var(--zoom)); + inset-block-start: calc(#{px2rem(6)} / var(--zoom)); + inset-inline-end: calc(#{px2rem(20)} / var(--zoom)); + inline-size: calc(#{px2rem(20)} / var(--zoom)); + block-size: calc(#{px2rem(20)} / var(--zoom)); opacity: 0; pointer-events: none; +} - svg { - position: absolute; - top: 0; - left: 0; - width: calc(deprecated.$s-16 / var(--zoom)); - height: auto; - stroke: var(--grid-editor-marker-color); - } +// The DS icon has a fixed 16px size: scale it down with the zoom, as the +// rest of the track editor, so it keeps the same size on screen. +.grid-editor-button-icon { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + transform: scale(calc(1 / var(--zoom))); + transform-origin: top left; + color: var(--color-accent-quaternary); } .grid-editor-wrapper:hover .grid-editor-button { @@ -83,7 +90,7 @@ } .grid-frame { - fill: var(--grid-editor-marker-color); + fill: var(--color-accent-quaternary); fill-opacity: 0.1; } @@ -92,13 +99,13 @@ opacity: 0.5; .grid-plus-shape { - fill: var(--grid-editor-plus-btn-background); - stroke: var(--grid-editor-plus-btn-background); - stroke-width: calc(deprecated.$s-1 / var(--zoom)); + fill: var(--color-accent-quaternary); + stroke: var(--color-accent-quaternary); + stroke-width: calc(#{px2rem(1)} / var(--zoom)); } .grid-plus-icon { - stroke: var(--grid-editor-plus-btn-foreground); + stroke: $white; } &:hover { @@ -108,7 +115,7 @@ .grid-cell-outline { fill: transparent; - stroke: var(--grid-editor-line-color); + stroke: var(--color-accent-quaternary); stroke-opacity: 0.5; stroke-width: calc(1 / var(--zoom)); @@ -122,37 +129,32 @@ .grid-actions { pointer-events: none; position: absolute; - top: deprecated.$s-44; - left: 50%; + inset-block-start: px2rem(44); + inset-inline-start: 50%; z-index: var(--z-index-notifications); } .grid-actions-container { - @include deprecated.flex-row; - - background: var(--panel-background-color); - border-radius: deprecated.$br-12; - box-shadow: 0 0 deprecated.$s-12 0 var(--menu-shadow-color); - gap: deprecated.$s-8; - height: deprecated.$s-48; - margin-left: -50%; - padding: deprecated.$s-8; + display: flex; + align-items: center; + gap: var(--sp-s); + background: var(--color-background-primary); + border-radius: $br-12; + box-shadow: 0 0 px2rem(12) 0 var(--color-shadow-dark); + block-size: $sz-48; + margin-inline-start: -50%; + padding: var(--sp-s); cursor: initial; pointer-events: initial; - width: deprecated.$s-512; + inline-size: $sz-512; } .grid-actions-title { flex: 1; - font-size: deprecated.$fs-12; + + // TODO: No exact DS typography match found. + // Closest match: body-small. + font-size: px2rem(12); color: var(--color-foreground-secondary); - padding-left: deprecated.$s-8; -} - -.close-btn { - @extend %button-tertiary; - - svg { - @extend %button-icon; - } + padding-inline-start: var(--sp-s); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index e971e9decc..4d87a2c487 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6791,6 +6791,10 @@ msgstr "Top padding" msgid "workspace.layout-grid.editor.padding.vertical" msgstr "Vertical padding" +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs +msgid "workspace.layout-grid.editor.track-options" +msgstr "Track options" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:61 msgid "workspace.layout-grid.editor.title" msgstr "Editing grid" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 65ab208de5..d40ca22260 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -6875,6 +6875,10 @@ msgstr "Padding superior" msgid "workspace.layout-grid.editor.padding.vertical" msgstr "Padding vertical" +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs +msgid "workspace.layout-grid.editor.track-options" +msgstr "Opciones de la pista" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:61 msgid "workspace.layout-grid.editor.title" msgstr "Editando rejilla" From ef17aea39eb0c56e66a506470ddc40e8a08efe9f Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:33:11 +0100 Subject: [PATCH 142/167] :recycle: Use DS icon buttons in the viewer thumbnails panel (#12518) * :recycle: Use DS icon buttons in the viewer thumbnails panel The expand, close and scroll buttons of the viewer thumbnails panel become ghost DS icon buttons. They had no accessible name; they are now labelled, and the expand button says whether the panel is expanded. The rotated legacy arrow gives way to the DS arrow icons. The thumbnails stay raw buttons, since they are list items. New keys: labels.expand and viewer.thumbnails.scroll-left/right (en, es). A visual test covers the panel collapsed, on hover and expanded. AI-assisted-by: claude-opus-5-5 * :recycle: Move viewer thumbnails panel styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties, and the file no longer imports refactor/. The scroll buttons keep their 60px height through --button-height and still show only on hover; their tooltip wrappers take the grid cells. The DS body-small typography differs from the old one only in a more compact line-height, so the counter and the board names sit 1px higher. The panel z-index (10) has no DS token and keeps a TODO. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 39 ++++++ .../src/app/main/ui/viewer/thumbnails.cljs | 37 +++-- .../src/app/main/ui/viewer/thumbnails.scss | 127 ++++++++---------- frontend/translations/en.po | 12 ++ frontend/translations/es.po | 12 ++ 5 files changed, 145 insertions(+), 82 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 8361c263fb..9fb96ad4b3 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -151,3 +151,42 @@ test("User opens Share modal", async ({ page }) => { ).toBeVisible(); await expect(viewerPage.page).toHaveScreenshot(); }); + +test.describe("Thumbnails panel", () => { + test("Shows the thumbnails panel collapsed and expanded", async ({ + page, + }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithMultipleBoards(); + await viewerPage.goToViewer({ + fileId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb0", + pageId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb3", + }); + + await page.locator("#current-frame").click(); + const panel = page.locator("section").filter({ hasText: /boards/i }); + await expect(panel).toBeVisible(); + await page.mouse.move(640, 600); + // Collapsed: the summary with its expand and close buttons. + await expect(panel).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + // The scroll handlers only show on hover. + await panel.locator("button").nth(3).hover(); + await expect(panel).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await panel.locator("button").first().click(); + await page.mouse.move(640, 700); + // Expanded: the expand arrow points up. + await expect(panel).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/viewer/thumbnails.cljs b/frontend/src/app/main/ui/viewer/thumbnails.cljs index d7af5b5d41..d23c5009b8 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.cljs +++ b/frontend/src/app/main/ui/viewer/thumbnails.cljs @@ -14,7 +14,8 @@ [app.main.data.viewer :as dv] [app.main.render :as render] [app.main.store :as st] - [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.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.object :as obj] @@ -63,10 +64,18 @@ [:div {:class (stl/css :thumbnails-list-expanded)} children]] [:div {:class (stl/css :thumbnails-content)} - [:button {:class (stl/css :left-scroll-handler) - :on-click on-left-arrow-click} deprecated-icon/arrow] - [:button {:class (stl/css :right-scroll-handler) - :on-click on-right-arrow-click} deprecated-icon/arrow] + [:> icon-button* {:variant "ghost" + :class (stl/css :scroll-handler) + :tooltip-class (stl/css :left-scroll-handler) + :aria-label (tr "viewer.thumbnails.scroll-left") + :icon i/arrow-left + :on-click on-left-arrow-click}] + [:> icon-button* {:variant "ghost" + :class (stl/css :scroll-handler) + :tooltip-class (stl/css :right-scroll-handler) + :aria-label (tr "viewer.thumbnails.scroll-right") + :icon i/arrow-right + :on-click on-right-arrow-click}] [:div {:class (stl/css :thumbnails-list) :ref container @@ -76,15 +85,22 @@ children]]]))) (mf/defc thumbnails-summary* - [{:keys [on-toggle-expand on-close total]}] + [{:keys [on-toggle-expand on-close total is-expanded]}] [:div {:class (stl/css :thumbnails-summary)} [:span {:class (stl/css :counter)} (tr "labels.num-of-frames" (i18n/c total))] [:span {:class (stl/css :actions)} - [:button {:class (stl/css :expand-btn) - :on-click on-toggle-expand} deprecated-icon/arrow] - [:button {:class (stl/css :close-btn) - :on-click on-close} deprecated-icon/close]]]) + [:> icon-button* {:variant "ghost" + :aria-label (if is-expanded + (tr "labels.collapse") + (tr "labels.expand")) + :aria-expanded (boolean is-expanded) + :icon (if is-expanded i/arrow-up i/arrow-down) + :on-click on-toggle-expand}] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close}]]]) (mf/defc thumbnail-item* {::mf/wrap [mf/memo @@ -137,6 +153,7 @@ [:> thumbnails-summary* {:on-toggle-expand toggle-expand :on-close on-close + :is-expanded expanded? :total (count frames)}] [:> thumbnails-content* {:is-expanded expanded? :total (count frames)} diff --git a/frontend/src/app/main/ui/viewer/thumbnails.scss b/frontend/src/app/main/ui/viewer/thumbnails.scss index 63dc586a67..fdea0a3443 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.scss +++ b/frontend/src/app/main/ui/viewer/thumbnails.scss @@ -4,65 +4,52 @@ // // 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/mixins.scss" as *; +@use "ds/typography.scss" as *; .viewer-thumbnails { - background-color: var(--viewer-background-color); + background-color: var(--color-background-secondary); grid-row: 1 / span 1; grid-column: 1 / span 1; overflow: hidden; display: flex; flex-direction: column; - z-index: deprecated.$z-index-10; + + // TODO: no DS z-index token for 10; it keeps the panel above the + // viewer content (z-index 2). + z-index: 10; } .expanded { grid-row: 1 / span 2; - - .expand-btn svg { - transform: rotate(-90deg); - } } .thumbnails-summary { display: flex; justify-content: space-between; align-items: center; - height: deprecated.$s-32; - margin: deprecated.$s-24 deprecated.$s-24 0 deprecated.$s-24; + block-size: $sz-32; + margin: var(--sp-xxl) var(--sp-xxl) 0 var(--sp-xxl); } .counter { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--viewer-thumbnails-control-foreground-color); + color: var(--color-foreground-secondary); } .actions { - @include deprecated.flex-row; - - width: deprecated.$s-60; -} - -.expand-btn, -.close-btn { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - } -} - -.expand-btn svg { - transform: rotate(90deg); + display: flex; + align-items: center; + gap: var(--sp-xs); } .thumbnails-content { display: grid; - grid-template-columns: deprecated.$s-40 auto deprecated.$s-40; + grid-template-columns: $sz-40 auto $sz-40; grid-template-rows: auto; } @@ -74,36 +61,28 @@ overflow: hidden; } +// Tooltip wrappers of the scroll buttons: they take the grid cell. .right-scroll-handler, .left-scroll-handler { - @extend %button-tertiary; - @include deprecated.flex-center; - grid-column: 3 / span 1; grid-row: 1 / span 1; - width: deprecated.$s-32; - height: deprecated.$s-60; margin: auto 0; - z-index: deprecated.$z-index-10; - opacity: 0; - - &:hover { - opacity: 1; - } - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } + z-index: 10; } .left-scroll-handler { grid-column: 1 / span 1; - grid-row: 1 / span 1; +} - svg { - transform: rotate(180deg); +// The scroll buttons are as tall as a thumbnail row and only show on hover. +.thumbnails-content .scroll-handler { + --button-height: #{px2rem(60)}; + + opacity: 0; + + &:hover, + &:focus-visible { + opacity: 1; } } @@ -120,45 +99,49 @@ position: relative; } +// A list item, not a DS button. .thumbnail-item { - @include deprecated.button-style; - display: flex; flex-direction: column; - padding: deprecated.$s-16; + padding: var(--sp-l); + border: none; + background: none; + cursor: pointer; } .thumbnail-preview { - @include deprecated.flex-center; - - width: deprecated.$s-132; - min-height: deprecated.$s-132; - height: deprecated.$s-132; - padding: deprecated.$s-4; + display: flex; + justify-content: center; + align-items: center; + inline-size: px2rem(132); + min-block-size: px2rem(132); + block-size: px2rem(132); + padding: var(--sp-xs); + // The board preview markup has no class of its own. svg { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; } &.selected { - background-color: var(--viewer-thumbnail-background-color-selected); - border-radius: deprecated.$br-8; + background-color: var(--color-accent-primary-muted); + border-radius: $br-8; } &:hover { - border: deprecated.$s-1 solid var(--viewer-thumbnail-border-color); - border-radius: deprecated.$br-8; + border: $b-1 solid var(--color-accent-primary); + border-radius: $br-8; } } .thumbnail-info { - @include deprecated.body-small-typography; - @include deprecated.text-ellipsis; + @include use-typography("body-small"); + @include text-ellipsis; text-align: center; - color: var(--viewer-thumbnails-control-foreground-color); - padding: deprecated.$s-8 0; - width: 100%; - max-width: deprecated.$s-132; + color: var(--color-foreground-secondary); + padding: var(--sp-s) 0; + inline-size: 100%; + max-inline-size: px2rem(132); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 4d87a2c487..577cc740a1 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2948,6 +2948,10 @@ msgstr "Error" msgid "labels.exit" msgstr "Exit" +#: src/app/main/ui/viewer/thumbnails.cljs:96 +msgid "labels.expand" +msgstr "Expand" + #: src/app/main/ui/dashboard/team.cljs:782 msgid "labels.expired-invitation" msgstr "Expired" @@ -6127,6 +6131,14 @@ msgstr "Show interactions on click" msgid "viewer.header.sitemap" msgstr "Sitemap" +#: src/app/main/ui/viewer/thumbnails.cljs:70 +msgid "viewer.thumbnails.scroll-left" +msgstr "Scroll left" + +#: src/app/main/ui/viewer/thumbnails.cljs:76 +msgid "viewer.thumbnails.scroll-right" +msgstr "Scroll right" + #: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:76 msgid "webgl.modals.webgl-unavailable.cta-dashboard" msgstr "Go to dashboard" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index d40ca22260..434c88d4ba 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2986,6 +2986,10 @@ msgstr "Error" msgid "labels.exit" msgstr "Salir" +#: src/app/main/ui/viewer/thumbnails.cljs:96 +msgid "labels.expand" +msgstr "Expandir" + #: src/app/main/ui/dashboard/team.cljs:782 msgid "labels.expired-invitation" msgstr "Expirada" @@ -6208,6 +6212,14 @@ msgstr "Mostrar interacciones al hacer click" msgid "viewer.header.sitemap" msgstr "Mapa del sitio" +#: src/app/main/ui/viewer/thumbnails.cljs:70 +msgid "viewer.thumbnails.scroll-left" +msgstr "Desplazar a la izquierda" + +#: src/app/main/ui/viewer/thumbnails.cljs:76 +msgid "viewer.thumbnails.scroll-right" +msgstr "Desplazar a la derecha" + #: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:76 msgid "webgl.modals.webgl-unavailable.cta-dashboard" msgstr "Ir al panel" From bac62a27bf23bbd047e98d320dc467e45d7391f6 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 11:36:46 +0100 Subject: [PATCH 143/167] :recycle: Use DS icons in the viewer comments filter (#12519) * :recycle: Use DS icons in the viewer comments filter The "Comments" filter trigger in the viewer header was a div with a click handler, and its menu lived inside it. It is now a button with aria-expanded and aria-haspopup, and the menu sits next to it in a wrapper, since a button cannot hold the menu entries. The arrow and the ticks use DS icons. The menu entries stay as they are. A visual test covers the filter closed and open. AI-assisted-by: claude-opus-5-5 * :recycle: Move viewer comments styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties. The DS icons take their color from the text and no longer shrink, so the old icon placeholders go away. The DS body-small line-height is more compact, so "Comments" sits 1px higher. The refactor/ import stays only for the shared menu placeholders. The z-index values (1 and 10) have no DS token and keep a TODO. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 32 +++++ frontend/src/app/main/ui/viewer/comments.cljs | 31 ++--- frontend/src/app/main/ui/viewer/comments.scss | 115 ++++++++---------- 3 files changed, 99 insertions(+), 79 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 9fb96ad4b3..95b3e58edc 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -190,3 +190,35 @@ test.describe("Thumbnails panel", () => { }); }); }); + +test.describe("Comments filter", () => { + test("Shows the comments filter closed and open", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithComments(); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + await viewerPage.showComments(); + + const trigger = page.getByTestId("viewer-comments-dropdown"); + await expect(trigger).toBeVisible(); + await page.mouse.move(640, 600); + // Closed: the "Comments" trigger with its arrow. + await expect(trigger).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await trigger.click(); + await expect(page.getByText("Show comments list")).toBeVisible(); + await page.mouse.move(640, 600); + // Open: the filter menu with the selected option ticked. + const menu = page.getByText("Show comments list").locator("../.."); + await expect(menu).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/viewer/comments.cljs b/frontend/src/app/main/ui/viewer/comments.cljs index aac174d880..9e56e7f4e8 100644 --- a/frontend/src/app/main/ui/viewer/comments.cljs +++ b/frontend/src/app/main/ui/viewer/comments.cljs @@ -20,7 +20,7 @@ [app.main.store :as st] [app.main.ui.comments :as cmt] [app.main.ui.components.dropdown :refer [dropdown]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.workspace.comments :as wc] [app.main.ui.workspace.viewport.utils :as utils] [app.util.dom :as dom] @@ -66,11 +66,17 @@ (parse-boolean))] (st/emit! (dcm/update-options {:show-sidebar? (not mode)})))))] - [:div {:class (stl/css :view-options) - :data-testid "viewer-comments-dropdown" - :on-click toggle-dropdown} - [:span {:class (stl/css :dropdown-title)} (tr "labels.comments")] - [:span {:class (stl/css :icon-dropdown)} deprecated-icon/arrow] + [:div {:class (stl/css :view-options-wrapper)} + [:button {:type "button" + :class (stl/css :view-options) + :data-testid "viewer-comments-dropdown" + :aria-expanded (deref show-dropdown?) + :aria-haspopup "menu" + :on-click toggle-dropdown} + [:span {:class (stl/css :dropdown-title)} (tr "labels.comments")] + [:> icon* {:icon-id i/arrow-down + :size "s" + :class (stl/css :icon-dropdown)}]] [:& dropdown {:show @show-dropdown? :on-close hide-dropdown} @@ -83,7 +89,7 @@ :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-all-comments")] (when (or (= :all cmode) (nil? cmode)) - [:span {:class (stl/css :icon)} deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] [:li {:class (stl/css-case :dropdown-element true @@ -92,8 +98,7 @@ :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-your-comments")] (when (= :yours cmode) - [:span {:class (stl/css :icon)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] [:li {:class (stl/css-case :dropdown-element true @@ -102,8 +107,7 @@ :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-mentions")] (when (= :mentions cmode) - [:span {:class (stl/css :icon)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] [:li {:class (stl/css :separator)}] @@ -114,8 +118,7 @@ :on-click update-show} [:span {:class (stl/css :label)} (tr "labels.hide-resolved-comments")] (when (= :pending cshow) - [:span {:class (stl/css :icon)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] [:li {:class (stl/css :separator)}] @@ -126,7 +129,7 @@ :on-click update-options} [:span {:class (stl/css :label)} (tr "labels.show-comments-list")] (when show-sidebar? - [:span {:class (stl/css :icon)} deprecated-icon/tick])]]]])) + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])]]]])) (defn- update-thread-position diff --git a/frontend/src/app/main/ui/viewer/comments.scss b/frontend/src/app/main/ui/viewer/comments.scss index c254f0b9ba..09ed1d98f7 100644 --- a/frontend/src/app/main/ui/viewer/comments.scss +++ b/frontend/src/app/main/ui/viewer/comments.scss @@ -4,91 +4,71 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "ds/_borders.scss" as *; +// Only for the shared menu placeholders, which move to the DS with the +// menus and dropdowns. @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 *; // COMMENT DROPDOWN ON HEADER +.view-options-wrapper { + position: relative; +} + .view-options { - @include deprecated.body-small-typography; + @include use-typography("body-small"); display: flex; align-items: center; - position: relative; - gap: deprecated.$s-4; - height: deprecated.$s-32; - padding: deprecated.$s-8; - border-radius: deprecated.$br-8; - background-color: var(--input-background-color); + border: none; + gap: var(--sp-xs); + block-size: $sz-32; + padding: var(--sp-s); + border-radius: $br-8; + background-color: var(--color-background-tertiary); cursor: pointer; } .dropdown { @extend %menu-dropdown; - right: deprecated.$s-2; - top: calc(deprecated.$s-2 + deprecated.$s-48); - width: deprecated.$s-272; - padding: deprecated.$s-6; + inset-inline-end: px2rem(2); + inset-block-start: px2rem(50); + inline-size: px2rem(272); + padding: px2rem(6); } .dropdown-title { - @include deprecated.body-small-typography; + @include use-typography("body-small"); flex-grow: 1; - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } .label { flex-grow: 1; - color: var(--input-foreground-color); + color: var(--color-foreground-secondary); } .icon, .icon-dropdown { - @include deprecated.flex-center; - - height: 100%; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } -} - -.icon-dropdown svg { - transform: rotate(90deg); + flex-shrink: 0; + color: var(--color-foreground-secondary); } .dropdown-element { @extend %dropdown-element-base; - .icon { - @include deprecated.flex-center; - - height: 100%; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } - } - &:hover .label { - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } } .dropdown-element.selected { .label { - color: var(--input-foreground-color-active); - } - - .icon svg { - stroke: var(--input-foreground-color); + color: var(--color-foreground-primary); } } @@ -96,36 +76,41 @@ 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); } // FLOATING COMMENT .viewer-comments-container { position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: deprecated.$z-index-1; + inset-block-start: 0; + inset-inline-start: 0; + inline-size: 100%; + block-size: 100%; + + // TODO: no DS z-index token for 1 (floating elements). + z-index: 1; } .threads { position: absolute; - top: 0; - left: 0; + inset-block-start: 0; + inset-inline-start: 0; } // COMMENT SIDEBAR .comments-sidebar { position: absolute; - right: 0; - top: deprecated.$s-44; - width: var(--right-sidebar-width); - height: calc(100vh - deprecated.$s-48); - z-index: deprecated.$z-index-10; - background-color: var(--panel-background-color); + inset-inline-end: 0; + inset-block-start: px2rem(44); + inline-size: var(--right-sidebar-width); + block-size: calc(100vh - #{$sz-48}); + background-color: var(--color-background-primary); + + // TODO: no DS z-index token for 10; it keeps the list above the viewer + // content. + z-index: 10; } .settings-bar-inside { @@ -133,5 +118,5 @@ } .comments-section { - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); } From a5fd2df1f053d92ffbf266fd737d9beceee852e2 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 11:38:33 +0100 Subject: [PATCH 144/167] :recycle: Make the grid track direction icon and the swap groups buttons (#12476) * :recycle: Make the grid track direction icon a button The direction icon at the start of each grid track value field selected that column or row from a div with a click handler: no button role, no name and no keyboard access. Make it a button labeled "Select column" or "Select row", keeping its class and handler, and reset the browser border so it looks the same. It stays a raw button: it is a segment of the track field, not a DS button. Add the workspace.layout-grid.editor.select-column and select-row keys (en, es). AI-assisted-by: claude-opus-5-5 * :recycle: Make the component swap panel groups buttons The groups of the component swap panel opened from a div with a click handler: no button role and no keyboard access. Make each group a button, keeping its class, title and handler, and reset the browser button styles so it looks the same. Its name is the visible group name. Add a visual test with a fixture whose components sit in two groups, so the swap panel lists them. AI-assisted-by: claude-opus-5-5 --- .../workspace/get-file-9066-swap-groups.json | 1935 +++++++++++++++++ .../ui/visual-specs/workspace.spec.js | 25 + .../sidebar/options/menus/component.cljs | 7 +- .../sidebar/options/menus/component.scss | 5 + .../options/menus/layout_container.cljs | 10 +- .../options/menus/layout_container.scss | 1 + frontend/translations/en.po | 8 + frontend/translations/es.po | 8 + 8 files changed, 1994 insertions(+), 5 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-file-9066-swap-groups.json diff --git a/frontend/playwright/data/workspace/get-file-9066-swap-groups.json b/frontend/playwright/data/workspace/get-file-9066-swap-groups.json new file mode 100644 index 0000000000..d20df8426c --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-9066-swap-groups.json @@ -0,0 +1,1935 @@ +{ + "~: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, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 10", + "~:revn": 10, + "~:modified-at": "~m1729604566305", + "~:id": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:is-shared": false, + "~:version": 55, + "~:project-id": "~u3ffbd505-2f26-800f-8004-f34da98bdad8", + "~:created-at": "~m1729594560852", + "~:data": { + "~:pages": [ + "~ue179d9df-de35-80bf-8005-283bbd5516b1" + ], + "~:pages-index": { + "~ue179d9df-de35-80bf-8005-283bbd5516b1": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bca" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd": { + "~#shape": { + "~:y": 589.9999999999999, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "E", + "~:width": 291.0000000000002, + "~:type": "~:frame", + "~:touched": { + "~#set": [] + }, + "~:points": [ + { + "~#point": { + "~:x": 1408.3799999999999, + "~:y": 589.9999999999999 + } + }, + { + "~#point": { + "~:x": 1699.38, + "~:y": 589.9999999999999 + } + }, + { + "~#point": { + "~:x": 1699.38, + "~:y": 784.0000000000003 + } + }, + { + "~#point": { + "~:x": 1408.3799999999999, + "~:y": 784.0000000000003 + } + } + ], + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe39bb51", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 1408.3799999999999, + "~:main-instance": true, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1408.3799999999999, + "~:y": 589.9999999999999, + "~:width": 291.0000000000002, + "~:height": 194.00000000000045, + "~:x1": 1408.3799999999999, + "~:y1": 589.9999999999999, + "~:x2": 1699.38, + "~:y2": 784.0000000000003 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 194.00000000000045, + "~:component-file": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd3", + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd4" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc": { + "~#shape": { + "~:y": 793.0000000000007, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "F", + "~:width": 176, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1465.88, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 969.0000000000009 + } + }, + { + "~#point": { + "~:x": 1465.88, + "~:y": 969.0000000000009 + } + } + ], + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 1465.88, + "~:main-instance": true, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1465.88, + "~:y": 793.0000000000007, + "~:width": 176, + "~:height": 176.00000000000023, + "~:x1": 1465.88, + "~:y1": 793.0000000000007, + "~:x2": 1641.88, + "~:y2": 969.0000000000009 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 176.00000000000023, + "~:component-file": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd2" + ] + } + }, + "~u9b0db134-3c1b-800f-8005-285b833b92a1": { + "~#shape": { + "~:y": 599.0000000000002, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "A", + "~:width": 176, + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:name-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 881.1199999999999, + "~:y": 599.0000000000002 + } + }, + { + "~#point": { + "~:x": 1057.12, + "~:y": 599.0000000000002 + } + }, + { + "~#point": { + "~:x": 1057.12, + "~:y": 775.0000000000005 + } + }, + { + "~#point": { + "~:x": 881.1199999999999, + "~:y": 775.0000000000005 + } + } + ], + "~:component-root": true, + "~:shape-ref": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u9b0db134-3c1b-800f-8005-285b833b92a1", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 881.1199999999999, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 881.1199999999999, + "~:y": 599.0000000000002, + "~:width": 176, + "~:height": 176.00000000000023, + "~:x1": 881.1199999999999, + "~:y1": 599.0000000000002, + "~:x2": 1057.12, + "~:y2": 775.0000000000005 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 176.00000000000023, + "~:component-file": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:flip-y": null, + "~:shapes": [ + "~u9b0db134-3c1b-800f-8005-285b833b92a2" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf": { + "~#shape": { + "~:y": 599, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "C", + "~:width": 176, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1173.5, + "~:y": 599 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 599 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 775.0000000000005 + } + }, + { + "~#point": { + "~:x": 1173.5, + "~:y": 775.0000000000005 + } + } + ], + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3a9014", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 1173.5, + "~:main-instance": true, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1173.5, + "~:y": 599, + "~:width": 176, + "~:height": 176.00000000000045, + "~:x1": 1173.5, + "~:y1": 599, + "~:x2": 1349.5, + "~:y2": 775.0000000000005 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 176.00000000000045, + "~:component-file": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd6" + ] + } + }, + "~u9b0db134-3c1b-800f-8005-285b833b92a2": { + "~#shape": { + "~:y": 599.0000000000002, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 881.1199999999999, + "~:y": 599.0000000000002 + } + }, + { + "~#point": { + "~:x": 1057.12, + "~:y": 599.0000000000002 + } + }, + { + "~#point": { + "~:x": 1057.12, + "~:y": 775.0000000000005 + } + }, + { + "~#point": { + "~:x": 881.1199999999999, + "~:y": 775.0000000000005 + } + } + ], + "~:shape-ref": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd2", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u9b0db134-3c1b-800f-8005-285b833b92a2", + "~:parent-id": "~u9b0db134-3c1b-800f-8005-285b833b92a1", + "~:frame-id": "~u9b0db134-3c1b-800f-8005-285b833b92a1", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#e1c1dc", + "~:stroke-opacity": 1, + "~:stroke-width": 1 + } + ], + "~:x": 881.1199999999999, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 881.1199999999999, + "~:y": 599.0000000000002, + "~:width": 176, + "~:height": 176.00000000000023, + "~:x1": 881.1199999999999, + "~:y1": 599.0000000000002, + "~:x2": 1057.12, + "~:y2": 775.0000000000005 + } + }, + "~:fills": [ + { + "~:fill-color": "#f4f6ff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176.00000000000023, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce": { + "~#shape": { + "~:y": 793.0000000000007, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "D", + "~:width": 176, + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:name-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1173.5, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 969.0000000000007 + } + }, + { + "~#point": { + "~:x": 1173.5, + "~:y": 969.0000000000007 + } + } + ], + "~:component-root": true, + "~:shape-ref": "~u6ad3e6b9-c5a0-80cf-8005-2830309d281e", + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283030a2d6be", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 1173.5, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1173.5, + "~:y": 793.0000000000007, + "~:width": 176, + "~:height": 176, + "~:x1": 1173.5, + "~:y1": 793.0000000000007, + "~:x2": 1349.5, + "~:y2": 969.0000000000007 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 176, + "~:component-file": "~ue179d9df-de35-80bf-8005-28302f57b1b5", + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd5" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb": { + "~#shape": { + "~:y": 589.9999999999999, + "~:layout-grid-columns": [ + { + "~:type": "~:percent", + "~:value": 33 + }, + { + "~:type": "~:percent", + "~:value": 33 + }, + { + "~:type": "~:percent", + "~:value": 33 + } + ], + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:layout": "~:grid", + "~:hide-in-viewer": true, + "~:name": "Group", + "~:layout-align-items": "~:center", + "~:width": 886, + "~:layout-grid-cells": { + "~ud874937d-e901-8094-8005-280b10c40285": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud874937d-e901-8094-8005-280b10c40285", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 1, + "~:row-span": 1, + "~:shapes": [ + "~u9b0db134-3c1b-800f-8005-285b833b92a1" + ] + }, + "~ud874937d-e901-8094-8005-280b1e40abf0": { + "~:justify-self": "~:auto", + "~:column": 3, + "~:id": "~ud874937d-e901-8094-8005-280b1e40abf0", + "~:position": "~:manual", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 2, + "~:row-span": 1, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc" + ] + }, + "~ud874937d-e901-8094-8005-280b1e40abef": { + "~:justify-self": "~:auto", + "~:column": 3, + "~:id": "~ud874937d-e901-8094-8005-280b1e40abef", + "~:position": "~:manual", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 1, + "~:row-span": 1, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd" + ] + }, + "~ud874937d-e901-8094-8005-280b1dbe21db": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud874937d-e901-8094-8005-280b1dbe21db", + "~:position": "~:manual", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 1, + "~:row-span": 1, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf" + ] + }, + "~ud874937d-e901-8094-8005-280b10c44c06": { + "~:justify-self": "~:center", + "~:column": 1, + "~:id": "~ud874937d-e901-8094-8005-280b10c44c06", + "~:position": "~:manual", + "~:column-span": 1, + "~:align-self": "~:center", + "~:row": 2, + "~:row-span": 1, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0" + ] + }, + "~ud874937d-e901-8094-8005-280b1dbe21dc": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud874937d-e901-8094-8005-280b1dbe21dc", + "~:position": "~:manual", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 2, + "~:row-span": 1, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce" + ] + } + }, + "~:layout-padding-type": "~:simple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 818.5, + "~:y": 590 + } + }, + { + "~#point": { + "~:x": 1704.5, + "~:y": 590 + } + }, + { + "~#point": { + "~:x": 1704.5, + "~:y": 978.0000000000011 + } + }, + { + "~#point": { + "~:x": 818.5, + "~:y": 978.0000000000011 + } + } + ], + "~:show-content": true, + "~:layout-item-h-sizing": "~:fix", + "~:proportion-lock": false, + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:layout-item-v-sizing": "~:auto", + "~:layout-justify-content": "~:center", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:layout-justify-items": "~:center", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bca", + "~:layout-align-content": "~:center", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bca", + "~:strokes": [], + "~:x": 818.5, + "~:proportion": 1, + "~:layout-grid-rows": [ + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + } + ], + "~:selrect": { + "~#rect": { + "~:x": 818.5, + "~:y": 589.9999999999999, + "~:width": 886, + "~:height": 388.000000000001, + "~:x1": 818.5, + "~:y1": 589.9999999999999, + "~:x2": 1704.5, + "~:y2": 978.0000000000009 + } + }, + "~:fills": [], + "~:layout-grid-dir": "~:column", + "~:flip-x": null, + "~:height": 388.000000000001, + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce", + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf", + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0", + "~u9b0db134-3c1b-800f-8005-285b833b92a1" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bca": { + "~#shape": { + "~:y": 553, + "~:layout-grid-columns": [ + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + } + ], + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 975, + "~:layout-grid-cells": { + "~ud48fcd31-2d57-8057-8005-2711ee36df37": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee36df37", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 7, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ed8cabd4": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ed8cabd4", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 4, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ed8cabd3": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ed8cabd3", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 4, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ef8afebd": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ef8afebd", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 10, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711e762253b": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711e762253b", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 1, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711e762253a": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711e762253a", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 1, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711eea7b59a": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711eea7b59a", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 9, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711eea7b599": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711eea7b599", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 9, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ee36df38": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee36df38", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 7, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ef8b1898": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ef8b1898", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 10, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711e7627244": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711e7627244", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 2, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711e7627243": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711e7627243", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 2, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711edc8124d": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711edc8124d", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 5, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ed0fc9ed": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ed0fc9ed", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 3, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711edc8124c": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711edc8124c", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 5, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ed0fc9ec": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ed0fc9ec", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 3, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ee71bbca": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee71bbca", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 8, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ee02246a": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee02246a", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 6, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ee71bbc9": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee71bbc9", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 8, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ee022469": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee022469", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 6, + "~:row-span": 1, + "~:shapes": [] + } + }, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 774, + "~:y": 553 + } + }, + { + "~#point": { + "~:x": 1749, + "~:y": 553 + } + }, + { + "~#point": { + "~:x": 1749, + "~:y": 1015 + } + }, + { + "~#point": { + "~:x": 774, + "~:y": 1015 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bca", + "~:layout-justify-items": "~:start", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 774, + "~:proportion": 1, + "~:layout-grid-rows": [ + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + } + ], + "~:selrect": { + "~#rect": { + "~:x": 774, + "~:y": 553, + "~:width": 975, + "~:height": 462, + "~:x1": 774, + "~:y1": 553, + "~:x2": 1749, + "~:y2": 1015 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:layout-grid-dir": "~:row", + "~:flip-x": null, + "~:height": 462, + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd5": { + "~#shape": { + "~:y": 793.0000000000007, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1173.5, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 969.0000000000007 + } + }, + { + "~#point": { + "~:x": 1173.5, + "~:y": 969.0000000000007 + } + } + ], + "~:shape-ref": "~u6ad3e6b9-c5a0-80cf-8005-2830309d2825", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd5", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce", + "~:strokes": [], + "~:x": 1173.5, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1173.5, + "~:y": 793.0000000000007, + "~:width": 176, + "~:height": 176, + "~:x1": 1173.5, + "~:y1": 793.0000000000007, + "~:x2": 1349.5, + "~:y2": 969.0000000000007 + } + }, + "~:fills": [ + { + "~:fill-color": "#c1c9e1", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd4": { + "~#shape": { + "~:y": 598.9999999999999, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176.00000000000023, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1465.8799999999999, + "~:y": 598.9999999999999 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 598.9999999999999 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 775.0000000000003 + } + }, + { + "~#point": { + "~:x": 1465.8799999999999, + "~:y": 775.0000000000003 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd4", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:strokes": [], + "~:x": 1465.8799999999999, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1465.8799999999999, + "~:y": 598.9999999999999, + "~:width": 176.00000000000023, + "~:height": 176.00000000000045, + "~:x1": 1465.8799999999999, + "~:y1": 598.9999999999999, + "~:x2": 1641.88, + "~:y2": 775.0000000000003 + } + }, + "~:fills": [ + { + "~:fill-color": "#f4f6ff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176.00000000000045, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd7": { + "~#shape": { + "~:y": 793.0000000000006, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 881.1200000000001, + "~:y": 793.0000000000006 + } + }, + { + "~#point": { + "~:x": 1057.1200000000001, + "~:y": 793.0000000000006 + } + }, + { + "~#point": { + "~:x": 1057.1200000000001, + "~:y": 969.0000000000006 + } + }, + { + "~#point": { + "~:x": 881.1200000000001, + "~:y": 969.0000000000006 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd7", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0", + "~:strokes": [], + "~:x": 881.1200000000001, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 881.1200000000001, + "~:y": 793.0000000000006, + "~:width": 176, + "~:height": 176, + "~:x1": 881.1200000000001, + "~:y1": 793.0000000000006, + "~:x2": 1057.1200000000001, + "~:y2": 969.0000000000006 + } + }, + "~:fills": [ + { + "~:fill-color": "#c1c9e1", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd6": { + "~#shape": { + "~:y": 599, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1173.5, + "~:y": 599 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 599 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 775.0000000000005 + } + }, + { + "~#point": { + "~:x": 1173.5, + "~:y": 775.0000000000005 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd6", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf", + "~:strokes": [], + "~:x": 1173.5, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1173.5, + "~:y": 599, + "~:width": 176, + "~:height": 176.00000000000045, + "~:x1": 1173.5, + "~:y1": 599, + "~:x2": 1349.5, + "~:y2": 775.0000000000005 + } + }, + "~:fills": [ + { + "~:fill-color": "#c9e1c1", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176.00000000000045, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0": { + "~#shape": { + "~:y": 793.0000000000006, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "B", + "~:width": 176, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 881.1200000000001, + "~:y": 793.0000000000006 + } + }, + { + "~#point": { + "~:x": 1057.1200000000001, + "~:y": 793.0000000000006 + } + }, + { + "~#point": { + "~:x": 1057.1200000000001, + "~:y": 969.0000000000006 + } + }, + { + "~#point": { + "~:x": 881.1200000000001, + "~:y": 969.0000000000006 + } + } + ], + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3b1793", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 881.1200000000001, + "~:main-instance": true, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 881.1200000000001, + "~:y": 793.0000000000006, + "~:width": 176, + "~:height": 176, + "~:x1": 881.1200000000001, + "~:y1": 793.0000000000006, + "~:x2": 1057.1200000000001, + "~:y2": 969.0000000000006 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 176, + "~:component-file": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd7" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd3": { + "~#shape": { + "~:y": 589.9999999999999, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 291.0000000000002, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 1408.3799999999999, + "~:y": 589.9999999999999 + } + }, + { + "~#point": { + "~:x": 1699.38, + "~:y": 589.9999999999999 + } + }, + { + "~#point": { + "~:x": 1699.38, + "~:y": 784.0000000000003 + } + }, + { + "~#point": { + "~:x": 1408.3799999999999, + "~:y": 784.0000000000003 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd3", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:strokes": [], + "~:x": 1408.3799999999999, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1408.3799999999999, + "~:y": 589.9999999999999, + "~:width": 291.0000000000002, + "~:height": 194.00000000000045, + "~:x1": 1408.3799999999999, + "~:y1": 589.9999999999999, + "~:x2": 1699.38, + "~:y2": 784.0000000000003 + } + }, + "~:fills": [ + { + "~:fill-color": "#e1c1dc", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 0, + "~:height": 194.00000000000045, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd2": { + "~#shape": { + "~:y": 793.0000000000007, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1465.88, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 969.0000000000009 + } + }, + { + "~#point": { + "~:x": 1465.88, + "~:y": 969.0000000000009 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd2", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#e1c1dc", + "~:stroke-opacity": 1, + "~:stroke-width": 1, + "~:color": "#e1c1dc", + "~:opacity": 1 + } + ], + "~:x": 1465.88, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1465.88, + "~:y": 793.0000000000007, + "~:width": 176, + "~:height": 176.00000000000023, + "~:x1": 1465.88, + "~:y1": 793.0000000000007, + "~:x2": 1641.88, + "~:y2": 969.0000000000009 + } + }, + "~:fills": [ + { + "~:fill-color": "#f4f6ff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176.00000000000023, + "~:flip-y": null + } + } + }, + "~:id": "~ue179d9df-de35-80bf-8005-283bbd5516b1", + "~:name": "Page 1" + } + }, + "~:id": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:options": { + "~:components-v2": true + }, + "~:components": { + "~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8": { + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8", + "~:name": "F", + "~:path": "", + "~:modified-at": "~m1729604566311", + "~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1" + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe39bb51": { + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe39bb51", + "~:name": "E", + "~:path": "Letters", + "~:modified-at": "~m1729604566311", + "~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1" + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe3a9014": { + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3a9014", + "~:name": "C", + "~:path": "Others", + "~:modified-at": "~m1729604566311", + "~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf", + "~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1" + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe3b1793": { + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3b1793", + "~:name": "B", + "~:path": "", + "~:modified-at": "~m1729604566311", + "~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0", + "~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1" + } + } + } +} diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 2423ac72e0..498663ae09 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -393,6 +393,31 @@ test.describe("Component options", () => { }); }); + test("Shows the groups of the swap panel", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-9066-swap-groups.json", + ); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickToggableLayer("Board"); + await workspace.clickToggableLayer("Group"); + await workspace.layers.getByText("A", { exact: true }).click(); + await workspace.rightSidebar.getByRole("button", { name: /^A / }).click(); + + await expect(workspace.rightSidebar.getByText("Letters")).toBeVisible(); + await workspace.rightSidebar.getByText("Letters").hover(); + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); + test("Shows the annotation actions of a main component", async ({ page }) => { const workspace = new WorkspacePage(page); await workspace.setupEmptyFile(); 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 9f30b24036..5b4f23d1db 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 @@ -625,9 +625,10 @@ [{:keys [item on-enter-group]}] (let [group-name (:name item) on-group-click #(on-enter-group group-name)] - [:div {:class (stl/css :swap-group) - :on-click on-group-click - :title group-name} + [:button {:type "button" + :class (stl/css :swap-group) + :on-click on-group-click + :title group-name} [:span {:class (stl/css :swap-group-name)} (cpn/last-path group-name)] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index 77403735b7..5fd8e97fc0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -330,6 +330,11 @@ .swap-group { @include t.use-typography("body-small"); + inline-size: 100%; + padding: 0; + border: none; + background: none; + text-align: start; cursor: pointer; display: grid; grid-template-columns: 1fr var(--sp-m); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 53b53d711c..430f46526f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -986,8 +986,14 @@ :on-pointer-leave pointer-leave} [:div {:class (stl/css :track-info-container)} - [:div {:class (stl/css :track-info-dir-icon) - :on-click handle-select-track} + ;; Raw button on purpose: it is the start segment of the track + ;; value field, not a DS button. + [:button {:type "button" + :class (stl/css :track-info-dir-icon) + :aria-label (if is-column + (tr "workspace.layout-grid.editor.select-column") + (tr "workspace.layout-grid.editor.select-row")) + :on-click handle-select-track} [:> icon* {:icon-id (if is-column i/flex-vertical i/flex-horizontal) :class (stl/css :track-info-dir)}]] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index c92abb2dc9..e3a1f34117 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -235,6 +235,7 @@ display: flex; align-items: center; cursor: pointer; + border: none; border-radius: $br-8 0 0 $br-8; background-color: var(--color-background-tertiary); padding: 0 var(--sp-s); diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 577cc740a1..63fcfeddb3 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6803,6 +6803,14 @@ msgstr "Top padding" msgid "workspace.layout-grid.editor.padding.vertical" msgstr "Vertical padding" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.select-column" +msgstr "Select column" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.select-row" +msgstr "Select row" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs msgid "workspace.layout-grid.editor.track-options" msgstr "Track options" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 434c88d4ba..195a1912eb 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -6887,6 +6887,14 @@ msgstr "Padding superior" msgid "workspace.layout-grid.editor.padding.vertical" msgstr "Padding vertical" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.select-column" +msgstr "Seleccionar columna" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.select-row" +msgstr "Seleccionar fila" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs msgid "workspace.layout-grid.editor.track-options" msgstr "Opciones de la pista" From 577cbe43e425677136706335cca93bbe95338259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 26 Oct 2026 12:36:13 +0100 Subject: [PATCH 145/167] :bug: Validate the text payload before pasting it (#12612) Text copied in Penpot carries its content in a data attribute of the clipboard HTML, which plugins and other apps can rewrite. The v3 editor and canvas paste used it as is, so bad data could reach WASM and the file and break the shape. The payload is now dropped, and the paste falls back to plain text in the caret style, unless its content passes the text shape schema, its span geometry holds only safe numbers and its file and team ids are uuids. Payloads in HTML over the paste size limit are not decoded. Copied text shapes pasted into the editor get the same checks, plus a numeric position; when they fail, nothing is pasted. Closes #12167 AI-assisted-by: claude-opus-5-5 --- .../src/app/main/data/workspace/texts.cljs | 41 ++++++++++------- frontend/src/app/util/text/clipboard.cljs | 37 ++++++++++++++-- .../util_text_clipboard_test.cljs | 44 +++++++++++++++++++ 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index c50af92813..bc077f92c4 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -22,6 +22,7 @@ [app.common.types.fills :as types.fills] [app.common.types.modifiers :as ctm] [app.common.types.shape.layout :as ctl] + [app.common.types.shape.text :as cts] [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.data.changes :as dch] @@ -1617,9 +1618,6 @@ (rx/catch on-clipboard-error)) (rx/empty))))) -;; Bigger HTML is pasted as plain text rather than walked. -(def ^:private max-paste-html-length 1000000) - (defn payload-content "The content of a Penpot text `payload` without what the current file cannot reach. A payload without team counts as coming from the current team." @@ -1636,7 +1634,10 @@ [text] (when (str/starts-with? (str/trim (or text "")) "{") (let [data (ex/ignoring (t/decode-str text))] - (when (and (map? data) (= :copied-shapes (:type data))) + (when (and (map? data) + (= :copied-shapes (:type data)) + (map? (:objects data)) + (set? (:selected data))) data)))) (defn copied-text-geometry @@ -1660,22 +1661,30 @@ (mapv #(mapv (constantly nil) (:children %)) (-> content :children first :children)))) +(defn- pasteable-text? + "True when a copied text shape, which other apps or plugins may have written, can + be pasted into the text editor." + [{:keys [x y content]}] + (and (number? x) (number? y) (cts/valid-content? content))) + (defn- copied-shapes->payload "A text payload joining the content of the copied text shapes, top to bottom and - left to right; nil when none of the copied shapes is a text." + left to right; nil when none of the copied shapes is a text or it is not valid." [{:keys [objects selected file-id team-id text-geometry]}] (let [texts (->> selected (keep #(get objects %)) - (filter cfh/text-shape?) - (sort-by (juxt :y :x)))] - (when (seq texts) - (cond-> {:file-id file-id - :team-id team-id - :content (assoc-in (:content (first texts)) - [:children 0 :children] - (into [] (mapcat #(-> % :content :children first :children)) texts))} - (some? text-geometry) - (assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)}))))) + (filter cfh/text-shape?))] + (when (and (seq texts) (every? pasteable-text? texts)) + (let [texts (sort-by (juxt :y :x) texts) + payload (cond-> {:file-id file-id + :team-id team-id + :content (assoc-in (:content (first texts)) + [:children 0 :children] + (into [] (mapcat #(-> % :content :children first :children)) texts))} + (some? text-geometry) + (assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)}))] + (when (text-clipboard/valid-payload? payload) + payload))))) (defn- penpot-paste [state payload] @@ -1700,7 +1709,7 @@ (some->> (copied-shapes->payload shapes) (penpot-paste state)) :else - (when-let [fragment (or (when (and html-paste? (< 0 (count html) max-paste-html-length)) + (when-let [fragment (or (when (and html-paste? (< 0 (count html) text-clipboard/max-paste-html-length)) (some-> (text-clipboard/html->fragment html) (text-clipboard/without-overrides))) (text-clipboard/text->fragment text))] diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index 35df340afe..f8368aa34a 100644 --- a/frontend/src/app/util/text/clipboard.cljs +++ b/frontend/src/app/util/text/clipboard.cljs @@ -11,7 +11,9 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.fonts :as cfnt] + [app.common.schema :as sm] [app.common.transit :as t] + [app.common.types.shape.text :as cts] [app.common.types.text :as txt] [app.common.types.typography :as ctt] [cuerdas.core :as str])) @@ -277,6 +279,29 @@ (def ^:private payload-re #"data-penpot-text=\"([A-Za-z0-9+/=]*)\"") +;; Bigger HTML is pasted as plain text rather than read. +(def max-paste-html-length 1000000) + +(def ^:private schema:origin + [:map {:closed true} + [:x ::sm/safe-number] + [:y ::sm/safe-number] + [:box [:map {:closed true} + [:width ::sm/safe-number] + [:height ::sm/safe-number]]]]) + +(def ^:private valid-geometry? + (sm/lazy-validator + [:map {:closed true} + [:origins [:vector [:vector [:maybe schema:origin]]]]])) + +(defn valid-payload? + "True when the `content` and `geometry` of a payload, which other apps or plugins + may have written, are safe to paste into a text shape." + [{:keys [content geometry]}] + (and (cts/valid-content? content) + (or (nil? geometry) (valid-geometry? geometry)))) + (defn- encode-base64 [s] (->> (.encode (js/TextEncoder.) s) @@ -307,11 +332,15 @@ (defn html->payload "The Penpot payload in `html`, or nil when it has none." [html] - (when-let [[_ encoded] (some->> html (re-find payload-re))] - (let [payload (ex/ignoring (t/decode-str (decode-base64 encoded)))] + (when-let [[_ encoded] (when (< (count html) max-paste-html-length) + (some->> html (re-find payload-re)))] + (let [{:keys [type file-id team-id] :as payload} + (ex/ignoring (t/decode-str (decode-base64 encoded)))] (when (and (map? payload) - (= :copied-text (:type payload)) - (map? (:content payload))) + (= :copied-text type) + (uuid? file-id) + (or (nil? team-id) (uuid? team-id)) + (valid-payload? payload)) payload)))) ;; --- Pasting into another file or team diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs index e5534a1517..88b1cbdcfe 100644 --- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -228,6 +228,50 @@ (t/is (nil? (clipboard/html->payload ""))) (t/is (nil? (clipboard/html->payload nil))))) +(defn- tampered-html + "Payload markup carrying `payload` as is, as a plugin could write it." + [{:keys [content file-id team-id geometry]}] + (clipboard/payload->html content file-id team-id geometry)) + +(def ^:private valid-payload + {:content (text-content gradient-span) :file-id file-id :team-id team-id}) + +(t/deftest invalid-payload + (t/testing "a valid payload is read" + (t/is (some? (clipboard/html->payload (tampered-html valid-payload))))) + + (t/testing "a payload with a broken envelope is dropped" + (t/is (nil? (clipboard/html->payload (tampered-html (assoc valid-payload :file-id "file"))))) + (t/is (nil? (clipboard/html->payload (tampered-html (assoc valid-payload :team-id 42)))))) + + (t/testing "a payload whose content a text shape cannot hold is dropped" + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc-in valid-payload [:content :children 0 :children] []))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :content (text-content (assoc gradient-span :text 42))))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :content + (text-content (assoc gradient-span :fills [{:fill-color "red"}])))))))) + + (t/testing "a payload with geometry that is not plain numbers is dropped" + (let [origin {:x 12.5 :y 0.2 :box {:width 200 :height 40}}] + (t/is (some? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[origin]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc origin :x "12")]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc origin :y ##NaN)]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc-in origin [:box :width] nil)]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[origin]] :extra 1})))))))) + +(t/deftest oversized-payload + (t/testing "HTML over the paste size limit is not decoded" + (let [hidden (clipboard/payload->html (text-content gradient-span) file-id team-id)] + (t/is (nil? (clipboard/html->payload + (str hidden (apply str (repeat clipboard/max-paste-html-length "x"))))))))) + ;; --- Clean-up (def ^:private image-fill From 48a5875a7161a03fc16c4ce723bf4227f22a70f1 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 12:48:43 +0100 Subject: [PATCH 146/167] :recycle: Use DS icon buttons in the viewer board navigation (#12527) * :recycle: Use DS icon buttons in the viewer board navigation The previous, next and first board buttons of the viewer become secondary DS icon buttons with the DS arrow and reload icons. They keep their size and the viewer background at rest; on hover they take the DS secondary look. The first board button had no accessible name; it is now labelled. The position classes move to the tooltip wrappers, so the fullscreen and comments sidebar offsets still apply. New key: viewer.go-to-first-board (en, es). A visual test covers the buttons at rest and on hover. AI-assisted-by: claude-opus-5-5 * :recycle: Move viewer styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties, and the file no longer imports refactor/. The section scrollbar uses the DS custom-scrollbar mixin; the text selection and placeholder styles that %new-scrollbar gave to the comments layer stay, with DS tokens of the same value. An invalid padding-right with four values, which the browser ignored, is gone. The DS body-small typography differs from the old one only in a more compact line-height, so the board counter sits 1px higher. The z-index values (10, 2) have no DS token and keep a TODO. The .viewer-header, .not-fixed, .fixed and .frame-children rules are not used by this module and are commented out with a TODO. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the viewer The SCSS refactor of the viewer 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: .viewer-header, .not-fixed, .fixed, .frame-children g. No visual change. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 44 +++++ frontend/src/app/main/ui/viewer.cljs | 41 ++-- frontend/src/app/main/ui/viewer.scss | 177 +++++++++--------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 5 files changed, 165 insertions(+), 105 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 95b3e58edc..65ffbe4af8 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -222,3 +222,47 @@ test.describe("Comments filter", () => { }); }); }); + +test.describe("Board navigation", () => { + test("Shows the previous, next and first board buttons", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithMultipleBoards(); + await viewerPage.goToViewer({ + fileId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb0", + pageId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb3", + }); + + const next = page.getByRole("button", { name: "Next" }); + await expect(next).toBeVisible(); + await page.mouse.move(640, 300); + // Wait for the slide-in transition of the controls. + await page.waitForTimeout(800); + // First board: the next and first board buttons. + await expect(page).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await next.hover(); + await expect(next).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await next.click(); + const previous = page.getByRole("button", { name: "Previous" }); + await previous.hover(); + await expect(previous).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + const first = page.locator("[class*='viewer-bottom'] button"); + await first.hover(); + await expect(first).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index f3cb52db0b..2422d347c2 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -22,9 +22,10 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.modal :refer [modal-container*]] [app.main.ui.viewer.comments :refer [comments-layer comments-sidebar*]] [app.main.ui.viewer.header :as header] @@ -99,23 +100,33 @@ go-first-frame (mf/use-fn #(st/emit! dv/select-first-frame))] [:* (when (pos? index) - [:button {:class (stl/css-case :viewer-go-prev true - :left-bar left-bar) - :on-click go-prev-frame - :aria-label (tr "labels.previous")} - deprecated-icon/arrow]) + [:> icon-button* {:variant "secondary" + :class (stl/css :viewer-go-button) + :tooltip-class (stl/css-case :viewer-go-prev true + :left-bar left-bar) + :tooltip-placement "right" + :aria-label (tr "labels.previous") + :icon i/arrow-left + :on-click go-prev-frame}]) (when (< (+ index 1) num-frames) - [:button {:class (stl/css-case :viewer-go-next true - :comment-sidebar comment-sidebar - :right-bar right-bar) - :on-click go-next-frame - :aria-label (tr "labels.next")} - deprecated-icon/arrow]) + [:> icon-button* {:variant "secondary" + :class (stl/css :viewer-go-button) + :tooltip-class (stl/css-case :viewer-go-next true + :comment-sidebar comment-sidebar + :right-bar right-bar) + :tooltip-placement "left" + :aria-label (tr "labels.next") + :icon i/arrow-right + :on-click go-next-frame}]) [:div {:class (stl/css-case :viewer-bottom true :left-bar left-bar)} - [:button {:on-click go-first-frame - :class (stl/css :reset-button)} - deprecated-icon/reload] + [:> icon-button* {:variant "secondary" + :class (stl/css :reset-button) + :tooltip-class (stl/css :reset-button-wrapper) + :tooltip-placement "top-right" + :aria-label (tr "viewer.go-to-first-board") + :icon i/reload + :on-click go-first-frame}] [:span {:class (stl/css :counter)} (str/join " / " [(+ index 1) num-frames])] [:span]]])) diff --git a/frontend/src/app/main/ui/viewer.scss b/frontend/src/app/main/ui/viewer.scss index d727764672..bef25833d1 100644 --- a/frontend/src/app/main/ui/viewer.scss +++ b/frontend/src/app/main/ui/viewer.scss @@ -4,12 +4,16 @@ // // 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/mixins.scss" as *; +@use "ds/typography.scss" as *; .viewer-layout { - height: 100vh; + block-size: 100vh; display: grid; - grid-template-rows: deprecated.$s-48 auto; + grid-template-rows: $sz-48 auto; grid-template-columns: 1fr; user-select: none; } @@ -18,41 +22,41 @@ overflow: hidden; grid-row: 2 / span 1; display: grid; - grid-template-rows: deprecated.$s-252 auto; + grid-template-rows: $sz-252 auto; grid-template-columns: 1fr; - background-color: var(--viewer-background-color); + background-color: var(--color-background-secondary); } .empty-state { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--empty-message-foreground-color); + color: var(--color-foreground-secondary); display: grid; place-items: center; - height: 100%; - width: 100%; -} - -.viewer-header { - grid-row: 1 / span 1; + block-size: 100%; + inline-size: 100%; } .inspect-layout { display: grid; - grid-template-rows: deprecated.$s-48 auto; + grid-template-rows: $sz-48 auto; grid-template-columns: 1fr; - height: 100vh; - margin-top: 0; + block-size: 100vh; + margin-block-start: 0; user-select: none; } .thumbnails-close { - @include deprecated.button-style; - + border: none; + background: none; + cursor: pointer; grid-row: 1 / span 2; grid-column: 1 / span 1; - z-index: deprecated.$z-index-10; - background-color: var(--overlay-color); + + // TODO: no DS z-index token for 10; it keeps the backdrop above the + // viewer content (z-index 2), level with the thumbnails panel. + z-index: 10; + background-color: var(--color-overlay-default); } .thumbnails-close.invisible { @@ -60,98 +64,104 @@ } .viewer-section { - @extend %new-scrollbar; + @include custom-scrollbar($include-selection: false, $include-placeholder: false); grid-row: 1 / span 2; grid-column: 1 / span 1; display: flex; align-items: center; flex-wrap: wrap; - height: calc(100vh - deprecated.$s-48); + block-size: calc(100vh - #{$sz-48}); overflow: auto; + + // The comments layer renders inside the section: its text selection and + // placeholders keep the styles they had. + ::selection { + background: var(--color-accent-background-select); + color: var(--color-static-white); + } + + ::placeholder { + @include use-typography("body-small"); + + color: var(--color-foreground-secondary); + } } .inspect-layout .viewer-section { flex-wrap: nowrap; - margin-top: 0; - height: 100%; + margin-block-start: 0; + block-size: 100%; overflow: hidden; } +// Tooltip wrappers of the previous and next buttons. .viewer-go-prev, .viewer-go-next { - @extend %button-secondary; - @include deprecated.flex-center; - position: absolute; - right: deprecated.$s-8; - height: deprecated.$s-64; - width: deprecated.$s-32; - top: calc(50vh - deprecated.$s-32); - z-index: deprecated.$z-index-2; - background-color: var(--viewer-controls-background-color); + inset-inline-end: var(--sp-s); + inset-block-start: calc(50vh - #{$sz-32}); + + // TODO: no DS z-index token for 2; it keeps the controls above the + // viewer section. + z-index: 2; transition: transform 400ms ease 300ms; +} - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } +.viewer-go-button { + --button-height: #{$sz-64}; + --button-bg-color: var(--color-background-primary); + --button-border-color: var(--color-background-tertiary); } .viewer-go-next.comment-sidebar { - right: deprecated.$s-280; + inset-inline-end: #{px2rem(280)}; } .viewer-go-prev { - left: deprecated.$s-8; - right: unset; - - svg { - transform: rotate(180deg); - } + inset-inline-start: var(--sp-s); + inset-inline-end: unset; } .viewer-bottom { position: fixed; - bottom: 0; + inset-block-end: 0; display: flex; align-items: flex-start; justify-content: space-between; - width: 100%; - height: deprecated.$s-40; - padding-right: 0 deprecated.$s-8 deprecated.$s-40 deprecated.$s-8; + inline-size: 100%; + block-size: $sz-40; transition: transform 400ms ease 300ms; - z-index: deprecated.$z-index-2; + + // TODO: no DS z-index token for 2; it keeps the controls above the + // viewer section. + z-index: 2; pointer-events: none; } +.reset-button-wrapper { + margin-inline-start: var(--sp-s); +} + .reset-button { - @extend %button-secondary; - @include deprecated.flex-center; + --button-width: #{$sz-28}; + --button-bg-color: var(--color-background-primary); + --button-border-color: var(--color-background-tertiary); - height: deprecated.$s-32; - width: deprecated.$s-28; - margin-left: deprecated.$s-8; - background-color: var(--viewer-controls-background-color); pointer-events: all; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } } .counter { - @include deprecated.flex-center; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - border-radius: deprecated.$br-8; - width: deprecated.$s-64; - height: deprecated.$s-32; - color: var(--viewer-thumbnails-control-foreground-color); - background-color: var(--viewer-controls-background-color); + display: flex; + justify-content: center; + align-items: center; + border-radius: $br-8; + inline-size: $sz-64; + block-size: $sz-32; + color: var(--color-foreground-secondary); + background-color: var(--color-background-primary); } .viewer-wrapper { @@ -169,8 +179,8 @@ .viewer-overlay-background { position: absolute; - top: 0; - left: 0; + inset-block-start: 0; + inset-inline-start: 0; &.visible { background-color: rgb(0 0 0 / 0.2); @@ -185,24 +195,11 @@ clip-path: inset(0 0 0 0); grid-column: 1 / 1; grid-row: 1 / 1; - - .not-fixed { - position: absolute; - } - - .fixed { - position: fixed; - pointer-events: none; - - .frame-children g { - pointer-events: auto; - } - } } /** FULLSCREEN */ [data-fullscreen="true"] .viewer-bottom { - transform: translateY(deprecated.$s-40); + transform: translateY($sz-40); } [data-force-visible="true"] .viewer-bottom { @@ -210,20 +207,20 @@ } [data-fullscreen="true"] .viewer-go-next { - transform: translateX(deprecated.$s-40); + transform: translateX($sz-40); } [data-fullscreen="true"] .viewer-go-prev { - transform: translateX(-#{deprecated.$s-40}); + transform: translateX(-#{$sz-40}); } [data-fullscreen="true"] .viewer-content { - transform: translateY(-#{deprecated.$s-48}); - height: 100vh; + transform: translateY(-#{$sz-48}); + block-size: 100vh; } [data-fullscreen="true"] .viewer-section { - height: 100vh; + block-size: 100vh; } [data-force-visible="true"] .viewer-go-next { diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 63fcfeddb3..dde95de91f 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6091,6 +6091,10 @@ msgstr "No boards found on the page." msgid "viewer.frame-not-found" msgstr "Board not found." +#: src/app/main/ui/viewer.cljs:127 +msgid "viewer.go-to-first-board" +msgstr "Go to the first board" + #: src/app/main/ui/viewer/header.cljs:341 msgid "viewer.header.comments-section" msgstr "Comments (%s)" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 195a1912eb..1bd162954c 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -6172,6 +6172,10 @@ msgstr "No se ha encontrado ningún tablero." msgid "viewer.frame-not-found" msgstr "No se encuentra el tablero." +#: src/app/main/ui/viewer.cljs:127 +msgid "viewer.go-to-first-board" +msgstr "Ir al primer tablero" + #: src/app/main/ui/viewer/header.cljs:341 msgid "viewer.header.comments-section" msgstr "Comentarios (%s)" From e24e9dc747cc20b8f342731dac927fa6574595a9 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 14:39:06 +0100 Subject: [PATCH 147/167] :recycle: Use DS buttons and icons in the viewer header (#12523) * :recycle: Use DS buttons and icons in the viewer header The section buttons, full screen and edit become ghost DS icon buttons (the last two were spans), with aria-pressed where they toggle. Share and "Log in or sign up" become DS buttons. The zoom widget becomes a button that toggles its menu, like the workspace one, with DS zoom buttons inside. The breadcrumb and the current board name become buttons with aria-expanded, and the page menu moves next to its button. The arrow and tick icons use DS icons. The logo stays: it is brand art. A visual test covers the header in two sections and both menus. AI-assisted-by: claude-opus-5-5 * :recycle: Move viewer header styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties, and the button placeholders go away. The section buttons column goes from a fixed 92px to auto, since the three 32px DS buttons no longer fit; the 1fr side columns keep it centered. The zoom widget follows the workspace header one. The 11px project name becomes the 12px DS headline-small. The global .selected rule also highlighted the current page in the page menu: that row keeps its highlight through its own rule. The refactor/ import stays only for the shared menu placeholders; the options z-index (10) has no DS token and keeps a TODO. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 56 +++ frontend/src/app/main/ui/viewer/header.cljs | 130 ++++--- frontend/src/app/main/ui/viewer/header.scss | 329 +++++++----------- 3 files changed, 258 insertions(+), 257 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 65ffbe4af8..432b3df9c6 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -266,3 +266,59 @@ test.describe("Board navigation", () => { }); }); }); + +test.describe("Viewer header", () => { + test("Shows the header buttons and its menus", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithComments(); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + + const header = page.locator("header").first(); + await expect(header).toBeVisible(); + await page.mouse.move(640, 600); + // Interactions section. + await expect(header).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + // Zoom menu. + await page.getByTitle("Zoom").click(); + await expect(page.getByText("Fit - Scale down to fit")).toBeVisible(); + await page.mouse.move(640, 600); + await expect(page).toHaveScreenshot({ + clip: { x: 760, y: 0, width: 520, height: 260 }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + await page.keyboard.press("Escape"); + await page.mouse.click(640, 600); + + // Sitemap menu. + await page + .locator("#current-frame") + .locator("..") + .locator("div") + .first() + .click(); + await page.mouse.move(640, 600); + await expect(page).toHaveScreenshot({ + clip: { x: 0, y: 0, width: 520, height: 200 }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + await page.mouse.click(640, 600); + + // Comments section, with its button selected. + await viewerPage.showComments(); + await page.mouse.move(640, 600); + await expect(header).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/viewer/header.cljs b/frontend/src/app/main/ui/viewer/header.cljs index 3d1404373a..e0907d26aa 100644 --- a/frontend/src/app/main/ui/viewer/header.cljs +++ b/frontend/src/app/main/ui/viewer/header.cljs @@ -14,6 +14,9 @@ [app.main.data.viewer.shortcuts :as sc] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [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 :refer [icon*]] [app.main.ui.exports.assets :refer [progress-widget]] [app.main.ui.formats :as fmt] [app.main.ui.icons :as deprecated-icon] @@ -47,11 +50,11 @@ (let [open* (mf/use-state false) open? (deref open*) - open-dropdown + toggle-dropdown (mf/use-fn (fn [event] (dom/stop-propagation event) - (reset! open* true))) + (swap! open* not))) close-dropdown (mf/use-fn @@ -73,28 +76,31 @@ (dom/stop-propagation event) (on-decrease)))] - [:div {:class (stl/css-case :zoom-widget true - :selected open?) - :on-click open-dropdown - :title (tr "workspace.header.zoom")} - [:span {:class (stl/css :label)} (fmt/format-percent zoom)] + [:div {:class (stl/css :zoom-widget-wrapper)} + [:button {:type "button" + :class (stl/css-case :zoom-widget true + :selected open?) + :on-click toggle-dropdown + :title (tr "workspace.header.zoom") + :aria-expanded open?} + [:span {:class (stl/css :label)} (fmt/format-percent zoom)]] [:& dropdown {:show open? :on-close close-dropdown} [:ul {:class (stl/css :dropdown)} [:li {:class (stl/css :basic-zoom-bar)} [:span {:class (stl/css :zoom-btns)} - [:button {:class (stl/css :zoom-btn) - :on-click on-decrease} - [:span {:class (stl/css :zoom-icon)} - deprecated-icon/remove-icon]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "shortcuts.decrease-zoom") + :on-click on-decrease + :icon i/remove}] [:p {:class (stl/css :zoom-text)} (fmt/format-percent zoom)] - [:button {:class (stl/css :zoom-btn) - :on-click on-increase} - [:span {:class (stl/css :zoom-icon)} - deprecated-icon/add]]] - [:button {:class (stl/css :reset-btn) - :on-click on-zoom-reset} + [:> icon-button* {:variant "ghost" + :aria-label (tr "shortcuts.increase-zoom") + :on-click on-increase + :icon i/add}]] + [:> button* {:variant "ghost" + :on-click on-zoom-reset} (tr "workspace.header.reset-zoom")]] [:li {:class (stl/css :zoom-option) @@ -188,27 +194,30 @@ :on-fullscreen toggle-fullscreen}] (when (:in-team permissions) - [:span {:on-click go-to-workspace - :class (stl/css :edit-btn)} - deprecated-icon/curve]) + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.edit") + :icon i/curve + :on-click go-to-workspace}]) - [:span {:title (tr "viewer.header.fullscreen") - :class (stl/css-case :fullscreen-btn true - :selected fullscreen?) - :on-click toggle-fullscreen} - deprecated-icon/expand] + [:> icon-button* {:variant "ghost" + :aria-label (tr "viewer.header.fullscreen") + :aria-pressed (boolean fullscreen?) + :icon i/expand + :on-click toggle-fullscreen}] (when (:in-team permissions) - [:button {:on-click open-share-dialog - :class (stl/css :share-btn)} + [:> button* {:variant "primary" + :class (stl/css :share-btn) + :on-click open-share-dialog} (tr "labels.share")]) (when-not (:is-logged permissions) - [:span {:on-click open-login-dialog - :class (stl/css :go-log-btn)} (tr "labels.log-or-sign")])])) + [:> button* {:variant "ghost" + :on-click open-login-dialog} + (tr "labels.log-or-sign")])])) (mf/defc header-sitemap - [{:keys [project file page frame toggle-thumbnails] :as props}] + [{:keys [project file page frame toggle-thumbnails shown-thumbnails] :as props}] (let [project-name (:name project) file-name (:name file) page-name (:name page) @@ -236,11 +245,15 @@ :title (tr "viewer.header.sitemap")} [:span {:class (stl/css :project-name)} project-name] [:div {:class (stl/css :sitemap-text)} - [:div {:class (stl/css :breadcrumb) - :on-click open-dropdown} - [:span {:class (stl/css :breadcrumb-text)} - (dm/str file-name " / " page-name)] - [:span {:class (stl/css :icon)} deprecated-icon/arrow] + [:div {:class (stl/css :breadcrumb)} + [:button {:type "button" + :class (stl/css :breadcrumb-btn) + :aria-expanded (deref show-dropdown?) + :aria-haspopup "menu" + :on-click open-dropdown} + [:span {:class (stl/css :breadcrumb-text)} + (dm/str file-name " / " page-name)] + [:> icon* {:icon-id i/arrow-down :size "s" :class (stl/css :icon)}]] [:span "/"] [:& dropdown {:show @show-dropdown? :on-close close-dropdown} @@ -254,12 +267,14 @@ [:span {:class (stl/css :label)} (get-in file [:data :pages-index id :name])] (when (= page-id id) - [:span {:class (stl/css :icon-check)} deprecated-icon/tick])])]]] - [:div {:class (stl/css :current-frame) - :id "current-frame" - :on-click toggle-thumbnails} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon-check)}])])]]] + [:button {:type "button" + :class (stl/css :current-frame) + :id "current-frame" + :aria-expanded (boolean shown-thumbnails) + :on-click toggle-thumbnails} [:span {:class (stl/css :frame-name)} frame-name] - [:span {:class (stl/css :icon)} deprecated-icon/arrow]]]])) + [:> icon* {:icon-id i/arrow-down :size "s" :class (stl/css :icon)}]]]])) (def ^:private penpot-logo-icon (deprecated-icon/icon-xref :penpot-logo-icon (stl/css :logo-icon))) @@ -322,33 +337,34 @@ :page page :frame frame :toggle-thumbnails toggle-thumbnails + :shown-thumbnails shown-thumbnails :index index}]] [:div {:class (stl/css :mode-zone)} - [:button {:on-click navigate - :data-value "interactions" - :class (stl/css-case :mode-zone-btn true - :selected (= section :interactions)) - :title (tr "viewer.header.interactions-section" (sc/get-tooltip :open-interactions))} - deprecated-icon/play] + [:> icon-button* {:variant "ghost" + :on-click navigate + :data-value "interactions" + :aria-pressed (= section :interactions) + :aria-label (tr "viewer.header.interactions-section" (sc/get-tooltip :open-interactions)) + :icon i/play}] (when (or (:in-team permissions) (= (:who-comment permissions) "all")) - [:button {:on-click navigate - :data-value "comments" - :class (stl/css-case :mode-zone-btn true - :selected (= section :comments)) - :title (tr "viewer.header.comments-section" (sc/get-tooltip :open-comments))} - deprecated-icon/comments]) + [:> icon-button* {:variant "ghost" + :on-click navigate + :data-value "comments" + :aria-pressed (= section :comments) + :aria-label (tr "viewer.header.comments-section" (sc/get-tooltip :open-comments)) + :icon i/comments}]) (when (or (:in-team permissions) (and (= (:type permissions) :share-link) (= (:who-inspect permissions) "all"))) - [:button {:on-click go-to-inspect - :class (stl/css-case :mode-zone-btn true - :selected (= section :inspect)) - :title (tr "viewer.header.inspect-section" (sc/get-tooltip :open-inspect))} - deprecated-icon/code])] + [:> icon-button* {:variant "ghost" + :on-click go-to-inspect + :aria-pressed (= section :inspect) + :aria-label (tr "viewer.header.inspect-section" (sc/get-tooltip :open-inspect)) + :icon i/code}])] [:& header-options {:section section :permissions permissions diff --git a/frontend/src/app/main/ui/viewer/header.scss b/frontend/src/app/main/ui/viewer/header.scss index ffe476cbda..aee1220748 100644 --- a/frontend/src/app/main/ui/viewer/header.scss +++ b/frontend/src/app/main/ui/viewer/header.scss @@ -4,22 +4,29 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared menu placeholders, which move to the DS with the +// menus and dropdowns. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; .viewer-header { position: absolute; - top: 0; + inset-block-start: 0; grid-column: 1 / span 1; grid-row: 1 / span 1; display: grid; - grid-template-columns: 1fr deprecated.$s-92 1fr; + grid-template-columns: 1fr auto 1fr; justify-content: space-between; align-items: center; - height: deprecated.$s-48; - width: 100vw; - padding: deprecated.$s-8 deprecated.$s-12; + block-size: $sz-48; + inline-size: 100vw; + padding: var(--sp-s) var(--sp-m); transition: transform 400ms ease 300ms; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); } // FILE NAVIGATION @@ -28,8 +35,8 @@ display: flex; justify-content: flex-start; flex-basis: min-content; - width: 100%; - gap: deprecated.$s-12; + inline-size: 100%; + gap: var(--sp-m); } .home-link { @@ -39,143 +46,137 @@ } .logo-icon { - width: deprecated.$s-28; - height: deprecated.$s-28; - fill: var(--icon-foreground-hover); + inline-size: $sz-28; + block-size: $sz-28; + fill: var(--color-foreground-primary); } .sitemap-zone { - @include deprecated.flex-column; - + display: flex; + flex-direction: column; + gap: var(--sp-xs); position: relative; - width: 100%; + inline-size: 100%; } .project-name { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); } .sitemap-text { - @include deprecated.flex-row; + display: flex; + align-items: center; + gap: var(--sp-xs); } .breadcrumb { - @include deprecated.body-small-typography; - @include deprecated.flex-row; + @include use-typography("body-small"); - color: var(--title-foreground-color); + display: flex; + align-items: center; + gap: var(--sp-xs); + color: var(--color-foreground-secondary); +} + +// Text and arrow that open the page menu: not a DS button. +.breadcrumb-btn { + @include use-typography("body-small"); + + display: flex; + align-items: center; + gap: var(--sp-xs); + padding: 0; + border: none; + background: none; + color: inherit; cursor: pointer; } .breadcrumb-text { - @include deprecated.text-ellipsis; + @include text-ellipsis; - max-width: 12vw; // This is a fallback - max-width: 12cqw; // This is a unit refered to container + max-inline-size: 12vw; // This is a fallback + max-inline-size: 12cqw; // This is a unit refered to container } -.icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - transform: rotate(90deg); - stroke: var(--icon-foreground); - } -} - -.dropdown { - position: absolute; +.icon, +.icon-check { + flex-shrink: 0; + color: var(--color-foreground-secondary); } .dropdown-sitemap { @extend %menu-dropdown; - left: 0; - top: calc(deprecated.$s-2 + deprecated.$s-48); - width: deprecated.$s-272; - padding: deprecated.$s-6; + inset-inline-start: 0; + inset-block-start: px2rem(50); + inline-size: px2rem(272); + padding: px2rem(6); } .dropdown-element { @extend %dropdown-element-base; - .icon-check { - @include deprecated.flex-center; - - height: 100%; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } + &:hover .label { + color: var(--color-foreground-primary); } - &:hover .label { - color: var(--input-foreground-color-active); + // The current page. + &.selected { + outline: none; + border-color: var(--color-background-quaternary); + background-color: var(--color-background-quaternary); + color: var(--color-accent-primary); } } +// Board name that toggles the thumbnails: not a DS button. .current-frame { - @include deprecated.body-small-typography; - @include deprecated.flex-row; + @include use-typography("body-small"); + display: flex; + align-items: center; + gap: var(--sp-xs); flex-grow: 1; - color: var(--title-foreground-color-hover); + padding: 0; + border: none; + background: none; + color: var(--color-foreground-primary); cursor: pointer; - .icon svg { - stroke: var(--title-foreground-color-hover); + .icon { + color: var(--color-foreground-primary); } } .frame-name { - @include deprecated.text-ellipsis; + @include text-ellipsis; - max-width: 17vw; // This is a fallback - max-width: 17cqw; // This is a unit refered to container + max-inline-size: 17vw; // This is a fallback + max-inline-size: 17cqw; // This is a unit refered to container } // SECTION BUTTONS .mode-zone { - @include deprecated.flex-row; - - height: 100%; -} - -.mode-zone-btn { - @extend %button-tertiary; - @include deprecated.flex-center; - - height: deprecated.$s-32; - width: deprecated.$s-28; - padding: 0; - - svg { - @extend %button-icon; - } -} - -.selected { - @extend %button-icon-selected; + display: flex; + align-items: center; + gap: var(--sp-xs); + block-size: 100%; } // OPTION AREA .options-zone { - @include deprecated.flex-row; - + display: flex; + align-items: center; position: relative; justify-content: flex-end; - gap: deprecated.$s-8; - z-index: deprecated.$z-index-10; + gap: var(--sp-s); + + // TODO: no DS z-index token for 10; it keeps the menus above the + // viewer content. + z-index: 10; } .view-options { @@ -185,92 +186,54 @@ cursor: pointer; } -.fullscreen-btn { - @extend %button-tertiary; - @include deprecated.flex-center; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } -} - .share-btn { - @extend %button-primary; - - height: deprecated.$s-32; - min-width: deprecated.$s-72; - margin-left: deprecated.$s-4; -} - -.edit-btn { - @extend %button-tertiary; - @include deprecated.flex-center; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } -} - -.go-log-btn { - @extend %button-tertiary; - @include deprecated.body-small-typography; - - height: deprecated.$s-32; - padding: 0 deprecated.$s-8; - border-radius: deprecated.$br-8; - color: var(--button-tertiary-foreground-color-rest); + min-inline-size: px2rem(72); + margin-inline-start: var(--sp-xs); } // ZOOM WIDGET -.zoom-widget { - @include deprecated.button-style; - @include deprecated.flex-center; +.zoom-widget-wrapper { + display: flex; +} - height: deprecated.$s-28; - min-width: deprecated.$s-64; - border-radius: deprecated.$br-8; +.zoom-widget { + --zoom-widget-foreground-color: var(--color-foreground-secondary); + + display: flex; + align-items: center; + justify-content: center; + block-size: $sz-28; + min-inline-size: $sz-64; + padding: 0; + border: none; + border-radius: $br-8; + background: none; + cursor: pointer; .label { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--button-tertiary-foreground-color-rest); - } - - &:hover { - .label { - color: var(--button-tertiary-foreground-color-focus); - } + color: var(--zoom-widget-foreground-color); } + &:hover, &.selected { - .label { - color: var(--button-tertiary-foreground-color-focus); - } + --zoom-widget-foreground-color: var(--color-foreground-primary); } } .dropdown { @extend %menu-dropdown; - right: deprecated.$s-2; - top: calc(deprecated.$s-2 + deprecated.$s-48); - width: deprecated.$s-272; + inset-inline-end: px2rem(2); + inset-block-start: px2rem(50); + inline-size: px2rem(272); } .basic-zoom-bar { display: flex; justify-content: space-between; - padding: deprecated.$s-6; + padding: px2rem(6); cursor: auto; } @@ -278,49 +241,15 @@ display: flex; } -.zoom-btn { - @extend %button-tertiary; - - height: deprecated.$s-28; - width: deprecated.$s-28; - border-radius: deprecated.$br-8; - - .zoom-icon { - @include deprecated.flex-center; - - width: deprecated.$s-24; - height: deprecated.$s-32; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - } - - &:hover { - .zoom-icon svg { - stroke: var(--button-tertiary-foreground-color-hover); - } - } -} - .zoom-text { - @include deprecated.flex-center; - - height: 100%; - min-width: deprecated.$s-64; + display: flex; + justify-content: center; + align-items: center; + block-size: 100%; + min-inline-size: $sz-64; padding: 0; - margin: 0 deprecated.$s-2; - color: var(--modal-title-foreground-color); -} - -.reset-btn { - @extend %button-tertiary; - - color: var(--button-tertiary-foreground-color-hover); - height: deprecated.$s-28; - border-radius: deprecated.$br-8; + margin: 0 px2rem(2); + color: var(--color-foreground-primary); } .zoom-option { @@ -335,11 +264,11 @@ } &:hover { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); .shortcuts { .shortcut-key { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); } } } @@ -349,14 +278,14 @@ [data-fullscreen="true"] .viewer-header::after { content: " "; position: absolute; - width: 100%; - height: deprecated.$s-48; - left: 0; - top: deprecated.$s-48; + inline-size: 100%; + block-size: $sz-48; + inset-inline-start: 0; + inset-block-start: $sz-48; } [data-fullscreen="true"] .viewer-header { - transform: translateY(-#{deprecated.$s-48}); + transform: translateY(-#{$sz-48}); } [data-force-visible="true"] .viewer-header, From 576bf4ed3dd5abdbed06eb44c5938d81317ba706 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Mon, 26 Oct 2026 14:42:43 +0100 Subject: [PATCH 148/167] :sparkles: Close search and shortcuts panels with Escape (#12374) * :sparkles: Close search and shortcuts panels with Escape The workspace search and shortcuts panels could only be dismissed with the mouse, which interrupts keyboard-first workflows. A new use-escape-to-close hook gives each panel an on-key-down handler that closes it on Escape while the user is working in it: the latest pointer press or focus change must have landed inside the panel, or the panel must hold focus when it opens (a panel that autofocuses its search box, such as Shortcuts, is closed by the first Escape). Focus left behind in a panel after the user moved on to the canvas does not count, so Escape keeps its usual meaning there. The handler never stops the event. The global Escape shortcut still runs and finishes in-progress operations such as path drawing; an earlier version stopped propagation and left a half-drawn path that failed shape validation on the next commit. In the search panel an open filters menu takes the first Escape and the panel closes on the second. The history panel needs no handler: Escape already closes it through the :interrupt it emits. Closes #9540 AI-assisted-by: claude-opus-5-5 AI-assisted-by: claude-sonnet-5-5 Signed-off-by: Filip Sajdak * :paperclip: Add e2e test for closing panels with Escape Cover the Escape handling added for the layers search and shortcuts panels with four Playwright cases on the mocked workspace: - Escape closes the layers search panel. - With the search filters menu open, the first Escape closes only the menu and the second closes the panel. - Escape closes the shortcuts panel right after it opens with `?`. The panel autofocuses its search box, so the first Escape must close it instead of only blurring the box. - A path drawn while focus sits in the shortcuts panel is finished by Escape, which proves the panel does not swallow the global shortcut. The first three cases fail on develop. The shortcuts case also fails when the hook does not seed its tracker from the focused element. AI-assisted-by: claude-sonnet-5-5 Signed-off-by: Filip Sajdak * :bug: Close History with Escape when opened by shortcut or menu Only the header button opened History through initialize-history, which removes the :document-history flag on the next :interrupt. The Ctrl/Cmd+Alt+H shortcut and the "Version history" menu item toggled the flag directly, so the global Escape left the panel open. Both now emit initialize-history, like the header, and keep their own event origin. Two e2e cases cover the shortcut and the menu; both fail without this change. AI-assisted-by: claude-sonnet-5-5 Signed-off-by: Filip Sajdak * :recycle: Apply review suggestions to Escape-to-close Use dom/child? instead of .contains in the Escape hook and drop the redundant nil checks. Drive the path test through clickAt so its clicks wait for the path tool. Add a negative Search case: Escape reaches the panel after the last click landed on the viewport, and the panel stays open. That case fails when the containment check is removed. AI-assisted-by: claude-sonnet-5-5 Signed-off-by: Filip Sajdak --------- Signed-off-by: Filip Sajdak --- .../ui/specs/escape-closes-panels.spec.js | 138 ++++++++++++++++++ .../app/main/data/workspace/shortcuts.cljs | 4 +- frontend/src/app/main/ui/hooks.cljs | 42 ++++++ .../src/app/main/ui/workspace/main_menu.cljs | 5 +- .../app/main/ui/workspace/sidebar/layers.cljs | 21 ++- .../main/ui/workspace/sidebar/shortcuts.cljs | 8 +- 6 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 frontend/playwright/ui/specs/escape-closes-panels.spec.js diff --git a/frontend/playwright/ui/specs/escape-closes-panels.spec.js b/frontend/playwright/ui/specs/escape-closes-panels.spec.js new file mode 100644 index 0000000000..23f86539f8 --- /dev/null +++ b/frontend/playwright/ui/specs/escape-closes-panels.spec.js @@ -0,0 +1,138 @@ +import { test, expect } from "@playwright/test"; +import { WorkspacePage } from "../pages/WorkspacePage"; + +const VIEWPORT_POINTS = [ + [200, 200], + [400, 220], + [320, 420], +]; + +const openSearch = async (page) => { + await page.getByRole("button", { name: "Search" }).click(); + const findInput = page.getByRole("textbox", { name: /^Find/ }); + await expect(findInput).toBeVisible(); + return findInput; +}; + +const openShortcuts = async (page) => { + await page.keyboard.press("?"); + const searchInput = page.getByPlaceholder("Search shortcuts"); + await expect(searchInput).toBeVisible(); + return searchInput; +}; + +const saveVersionButton = (page) => + page.getByRole("button", { name: "Save version" }); + +const openHistoryAssertion = (page) => + expect(saveVersionButton(page)).toBeVisible(); + +test.beforeEach(async ({ page }) => { + await WorkspacePage.init(page); + const workspacePage = new WorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.mockRPC( + "get-file-snapshots?file-id=*", + "workspace/versions-snapshot-1.json", + ); + await workspacePage.goToWorkspace(); +}); + +test("Escape closes the layers search panel", async ({ page }) => { + const findInput = await openSearch(page); + + await page.keyboard.press("Escape"); + + await expect(findInput).toHaveCount(0); +}); + +test("Escape closes the search filters menu before the search panel", async ({ + page, +}) => { + const findInput = await openSearch(page); + await page.getByRole("button", { name: "Filter" }).click(); + const menuItem = page.getByText("Masks", { exact: true }); + await expect(menuItem).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(menuItem).toHaveCount(0); + await expect(findInput).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(findInput).toHaveCount(0); +}); + +test("Escape keeps the search panel open after clicking the viewport", async ({ + page, +}) => { + const workspacePage = new WorkspacePage(page); + const findInput = await openSearch(page); + + await workspacePage.clickAt(...VIEWPORT_POINTS[0]); + await page.keyboard.press("Escape"); + await expect(findInput).toBeVisible(); + + // The click moves focus out of the panel, so a real key press would + // never reach its handler. Dispatch Escape on the input instead, to + // exercise the case the hook exists for: the event reaches the panel + // although the user's last action was on the canvas. + await findInput.dispatchEvent("keydown", { + key: "Escape", + code: "Escape", + keyCode: 27, + bubbles: true, + }); + + await expect(findInput).toBeVisible(); +}); + +test("Escape closes the shortcuts panel right after it opens", async ({ + page, +}) => { + const searchInput = await openShortcuts(page); + + await page.keyboard.press("Escape"); + + await expect(searchInput).toHaveCount(0); +}); + +test("Escape closes History opened with its shortcut", async ({ page }) => { + await page.keyboard.press("ControlOrMeta+Alt+h"); + await openHistoryAssertion(page); + + await page.keyboard.press("Escape"); + + await expect(saveVersionButton(page)).toHaveCount(0); +}); + +test("Escape closes History opened from the main menu", async ({ page }) => { + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + await page.locator("#file-menu-show-version-history").click(); + await openHistoryAssertion(page); + + await page.keyboard.press("Escape"); + + await expect(saveVersionButton(page)).toHaveCount(0); +}); + +test("Escape finishes a path drawn with the shortcuts panel open", async ({ + page, +}) => { + const workspacePage = new WorkspacePage(page); + const searchInput = await openShortcuts(page); + await page.getByRole("button", { name: "BASICS" }).click(); + + await page.keyboard.press("p"); + for (const [x, y] of VIEWPORT_POINTS) { + await workspacePage.clickAt(x, y); + } + await page.keyboard.press("Escape"); + + // The shortcuts panel replaces the layers tree, so the new path is + // observed through the design tab of the selected shape. + await expect( + page.getByRole("region", { name: "Stroke section" }), + ).toBeVisible(); + await expect(searchInput).toBeVisible(); +}); diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 17246e5889..b31fdd95d3 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -19,6 +19,7 @@ [app.main.data.workspace.comments :as dwcm] [app.main.data.workspace.drawing :as dwd] [app.main.data.workspace.drawing.common :as dwdc] + [app.main.data.workspace.history :as dwh] [app.main.data.workspace.layers :as dwly] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.shape-layout :as dwsl] @@ -655,7 +656,8 @@ :subsections [:panels] :section [:workspace] :fn #(emit-when-no-readonly - (dw/toggle-layout-flag :document-history))} + (-> (dwh/initialize-history) + (vary-meta assoc ::ev/origin "workspace:shortcuts")))} :toggle-colorpalette {:tooltip (ds/alt "P") :label (fn [] (tr "shortcuts.toggle-colorpalette")) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 5bc113bab9..ca3663ada9 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -16,6 +16,7 @@ [app.main.store :as st] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] + [app.util.keyboard :as kbd] [app.util.storage :as storage] [app.util.timers :as ts] [app.util.webapi :as wapi] @@ -597,3 +598,44 @@ (rx/dispose! sub)))) [rowref limit]))) + +(defn use-escape-to-close + "Returns an `on-key-down` handler that closes a panel with Escape. + + The panel only closes while the user is working in it: the latest + pointer press or focus change must have landed inside the element + that owns the handler, or that element must hold focus when it + mounts. Focus left behind in a panel after the user moved on to the + canvas does not count, so Escape keeps its usual meaning there. + + The event is never stopped: the global Escape shortcut still runs + and finishes any in-progress operation. + + `dismiss-nested`, when given, is called first; it returns true when + it consumed the Escape (an open menu, an inline rename), and then + the panel stays open." + ([on-close] + (use-escape-to-close on-close nil)) + ([on-close dismiss-nested] + (let [last-target-ref (mf/use-ref nil)] + (mf/with-effect [] + (let [track (fn [event] + (mf/set-ref-val! last-target-ref (dom/get-target event)))] + ;; A panel that autofocuses its input on open has already + ;; received its `focusin` before this effect runs. + (mf/set-ref-val! last-target-ref (dom/get-active)) + (.addEventListener js/document "pointerdown" track true) + (.addEventListener js/document "focusin" track true) + #(do (.removeEventListener js/document "pointerdown" track true) + (.removeEventListener js/document "focusin" track true)))) + + (mf/use-fn + (mf/deps on-close dismiss-nested) + (fn [event] + (let [panel (dom/get-current-target event) + last-target (mf/ref-val last-target-ref)] + (when (and (kbd/esc? event) + (dom/child? last-target panel) + (not (and (fn? dismiss-nested) (dismiss-nested)))) + (dom/blur! (dom/get-target event)) + (on-close)))))))) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 5a64cf76c9..79d2b80a20 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -23,6 +23,7 @@ [app.main.data.shortcuts :as scd] [app.main.data.workspace :as dw] [app.main.data.workspace.comments :as dwcm] + [app.main.data.workspace.history :as dwh] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.shortcuts :as sc] @@ -617,9 +618,9 @@ on-show-version-history (mf/use-fn - (mf/deps file-id) (fn [_] - (st/emit! (dw/toggle-layout-flag :document-history)))) + (st/emit! (-> (dwh/initialize-history) + (vary-meta assoc ::ev/origin "workspace:menu"))))) on-show-version-history-key-down (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 5525364ee3..f841fbee91 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -386,6 +386,21 @@ (st/emit! dw/close-layers-search) (st/emit! (dw/open-layers-search :find {:force? true})))))) + close-search + (mf/use-fn + #(st/emit! dw/close-layers-search)) + + dismiss-filters-menu + (mf/use-fn + (mf/deps show-menu? hide-menu) + (fn [] + (when ^boolean show-menu? + (hide-menu) + true))) + + on-search-key-down + (hooks/use-escape-to-close close-search dismiss-filters-menu) + remove-filter (mf/use-fn (fn [event] @@ -587,7 +602,8 @@ #(mf/html (if show-search? [:* - [:div {:class (stl/css :tool-window-bar)} + [:div {:class (stl/css :tool-window-bar) + :on-key-down on-search-key-down} [:> search-bar* {:input-ref search-input-ref :class (stl/css :search-item) :on-change update-search-text @@ -612,7 +628,8 @@ :on-click toggle-search :icon i/close}]] - [:div {:class (stl/css :replace-wrapper)} + [:div {:class (stl/css :replace-wrapper) + :on-key-down on-search-key-down} (when ^boolean find-replace-mode? [:div {:class (stl/css :replace-row)} [:> input* {:type "text" diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs index 016b9b8ba5..4d262f12be 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs @@ -23,6 +23,7 @@ [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.product.panel-title :refer [panel-title*]] + [app.main.ui.hooks :as hooks] [app.main.ui.shortcuts :as ss] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -60,7 +61,9 @@ filter-term* (mf/use-state "") filter-term (deref filter-term*) - close-fn #(st/emit! (dw/toggle-layout-flag :shortcuts)) + close-fn (mf/use-fn #(st/emit! (dw/toggle-layout-flag :shortcuts))) + + on-key-down (hooks/use-escape-to-close close-fn) {:keys [all-shortcuts all-sc-names all-sub-names all-section-names]} (ss/build-all-shortcuts workspace-shortcuts dashboard-shortcuts viewer-shortcuts) @@ -127,7 +130,8 @@ (mf/use-fn #(st/emit! (rt/nav :settings-shortcuts {} {::rt/new-window true})))] - [:div {:class (dm/str class " " (stl/css :shortcuts))} + [:div {:class (dm/str class " " (stl/css :shortcuts)) + :on-key-down on-key-down} [:> panel-title* {:class (stl/css :shortcuts-title) :text (tr "shortcuts.title") :on-close close-fn}] From f2e5b7e20ec74c9c42a23da5c8b2742b66a31f61 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 26 Oct 2026 15:24:18 +0100 Subject: [PATCH 149/167] :bug: Fix swapped nested component loses absolute position after a lib update (#12556) --- 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 376ecda94ec6398be163b45048b3d1609b41d07a Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 15:53:05 +0100 Subject: [PATCH 150/167] :recycle: Move viewport top bar styles off the deprecated styles (#12488) * :recycle: Move viewport top bar styles off the deprecated styles The viewport top bars (view only bar of the inspect tab, path edition bar) already use DS buttons. Move top_bar.scss to DS sizes, borders, spacing tokens and logical properties, resolve the panel and shadow color aliases, and drop the refactor import. The unused done-btn class, the last user of the deprecated button placeholders here, goes away. The two z-index values have no DS token, so they stay with a TODO. Add a visual test of the view only bar; it looks the same as before, pixel for pixel. AI-assisted-by: claude-opus-5-5 * :recycle: Use the body-small typography in the viewport top bar title The title only set a 12px font size, which body-small already has. Use the DS typography instead of the inline size and its TODO. The view only bar looks the same, pixel for pixel. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 22 ++++++++ .../main/ui/workspace/viewport/top_bar.scss | 55 +++++++++---------- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 498663ae09..d5621b77ee 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -629,6 +629,28 @@ test.describe("Guides", () => { }); }); +test.describe("Viewport top bar", () => { + test("Shows the view only bar of the inspect tab", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickLeafLayer("Board"); + await workspace.rightSidebar.getByRole("tab", { name: "Inspect" }).click(); + const bar = page.getByText("Inspecting code").locator("../../.."); + await expect(bar).toBeVisible(); + await page.mouse.move(640, 600); + await expect(bar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Left header", () => { test("Shows the shared badge and the save status", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss index 255ae85080..07eb526fa0 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss @@ -4,55 +4,54 @@ // // 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 *; +@use "ds/_utils.scss" as *; .viewport-actions, .viewport-actions-path { pointer-events: none; position: absolute; - --actions-toolbar-position-y: #{deprecated.$s-28}; - --actions-toolbar-offset-y: #{deprecated.$s-6}; + --actions-toolbar-position-y: #{$sz-28}; + --actions-toolbar-offset-y: #{px2rem(6)}; - top: calc(var(--actions-toolbar-position-y) + var(--actions-toolbar-offset-y)); - left: 50%; + inset-block-start: calc(var(--actions-toolbar-position-y) + var(--actions-toolbar-offset-y)); + inset-inline-start: 50%; transform: translateX(-50%); - z-index: deprecated.$z-index-20; + + // TODO: No DS z-index token for this value. + z-index: 20; } .viewport-actions-path { - z-index: deprecated.$z-index-3; + // TODO: No DS z-index token for this value. + z-index: 3; } .viewport-actions-container { - @include deprecated.flex-row; - - background: var(--panel-background-color); - border-radius: deprecated.$br-12; - box-shadow: 0 0 deprecated.$s-12 0 var(--menu-shadow-color); - gap: deprecated.$s-8; - height: deprecated.$s-48; - padding: deprecated.$s-8; + display: flex; + align-items: center; + gap: var(--sp-s); + background: var(--color-background-primary); + border-radius: $br-12; + box-shadow: 0 0 px2rem(12) 0 var(--color-shadow-dark); + block-size: $sz-48; + padding: var(--sp-s); cursor: initial; pointer-events: initial; - min-width: deprecated.$s-400; - border: deprecated.$s-2 solid var(--panel-border-color); + min-inline-size: $sz-400; + border: $b-2 solid var(--color-background-quaternary); } .viewport-actions-title { + @include use-typography("body-small"); + flex: 1; - font-size: deprecated.$fs-12; color: var(--color-foreground-secondary); - padding-left: deprecated.$s-8; - width: max-content; -} - -.done-btn { - @extend %button-primary; - - text-transform: uppercase; - padding: deprecated.$s-8 deprecated.$s-20; - font-size: deprecated.$fs-11; + padding-inline-start: var(--sp-s); + inline-size: max-content; } .viewport-actions-no-rulers { From 96b62f6475697b9fe7919c46ed17b353ed52598b Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 17:17:46 +0100 Subject: [PATCH 151/167] :recycle: Use DS buttons in the delete account modal (#12540) * :recycle: Use DS buttons in the delete account modal The close button of the delete account modal becomes a ghost DS icon button named "Close"; the class that places it goes on the tooltip wrapper. The cancel and delete buttons become DS secondary and destructive buttons, so their labels no longer wrap and the delete button takes the DS destructive red. The delete button keeps its test id. A visual test covers the modal at rest and with the close and delete buttons hovered. AI-assisted-by: claude-opus-5-5 * :recycle: Move delete account modal styles closer to the DS The delete account stylesheet resolves its legacy sizes, spacing, mixins and color aliases to DS tokens and uses logical properties and the DS typography module without a namespace. The legacy uppercase title becomes the DS headline-small typography, which differs only in its size (12px instead of 11px), so the title and the content below sit 1px lower. The organization counts used a fixed dark-theme gray; they now use the themed DS secondary foreground. Two unused selectors are commented out with a TODO. refactor/ stays only for the modal placeholders (%modal-*), which move with the modals refactor. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the delete account modal The SCSS refactor of the delete account 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: .fields-row, .select-title. No visual change. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 20 +++++++ .../app/main/ui/settings/delete_account.cljs | 23 ++++--- .../app/main/ui/settings/delete_account.scss | 60 +++++++------------ 3 files changed, 56 insertions(+), 47 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 3b783793f6..1b2ce0d483 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -317,3 +317,23 @@ test("User goes to the team settings section", async ({ page }) => { await expect(page.getByText("TEAM INFO")).toBeVisible(); await expect(dashboardPage.page).toHaveScreenshot(); }); + +test.describe("Delete account modal", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Delete account modal buttons", async ({ page }) => { + await page.goto("/?screen=settings-profile"); + await page.getByText("Want to remove your account?").click(); + + const modal = page.locator("[class*='modal-container']"); + await expect(page.getByTestId("delete-account-btn")).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); + + await page.getByTestId("delete-account-btn").hover(); + await expect(modal).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/settings/delete_account.cljs b/frontend/src/app/main/ui/settings/delete_account.cljs index 678ff29f20..b916734729 100644 --- a/frontend/src/app/main/ui/settings/delete_account.cljs +++ b/frontend/src/app/main/ui/settings/delete_account.cljs @@ -14,8 +14,9 @@ [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.organization-avatar :refer [organization-avatar*]] + [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 :refer [icon*]] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] @@ -62,8 +63,11 @@ [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} (tr "modals.delete-account.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)} [:div {:class (stl/css :warning-notice)} @@ -100,11 +104,12 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:button {:class (stl/css :cancel-button) - :on-click modal/hide!} + [:> button* {:variant "secondary" + :type "button" + :on-click modal/hide!} (tr "modals.delete-account.cancel")] - [:button {:class (stl/css-case :accept-button true - :danger true) - :on-click on-accept - :data-testid "delete-account-btn"} + [:> button* {:variant "destructive" + :type "button" + :on-click on-accept + :data-testid "delete-account-btn"} (tr "modals.delete-account.confirm")]]]]])) diff --git a/frontend/src/app/main/ui/settings/delete_account.scss b/frontend/src/app/main/ui/settings/delete_account.scss index 4d0c1efafc..f27a48c7a4 100644 --- a/frontend/src/app/main/ui/settings/delete_account.scss +++ b/frontend/src/app/main/ui/settings/delete_account.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/spacing.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; @@ -19,54 +21,48 @@ display: flex; flex-direction: column; - 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"); + display: flex; + flex-direction: column; flex: 1 1 auto; - min-height: 0; - gap: deprecated.$s-24; - margin-bottom: deprecated.$s-24; + min-block-size: 0; + gap: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .warning-notice { flex-shrink: 0; } -.fields-row { - @include deprecated.flex-column; -} - -.select-title { - @include deprecated.body-small-typography; - - color: var(--modal-title-foreground-color); -} - .organizations-section { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: flex; flex-direction: column; flex: 1 1 auto; - min-height: 0; + min-block-size: 0; gap: var(--sp-l); } @@ -98,7 +94,7 @@ display: flex; flex-direction: column; flex: 1 1 auto; - min-height: 0; + min-block-size: 0; gap: var(--sp-l); margin: 0; padding: 0; @@ -119,28 +115,16 @@ } .organization-name { - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } .organization-counts { display: flex; flex-direction: row; gap: var(--sp-s); - color: var(--df-secondary); + color: var(--color-foreground-secondary); } .action-buttons { @extend %modal-action-btns; } - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-button { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} From f07452e36bc8cf6e4a4cb2dff4f82ecb4851ff9a Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 17:55:54 +0100 Subject: [PATCH 152/167] :recycle: Use DS buttons and icons in the inspect code tab (#12521) * :recycle: Use DS buttons and icons in the inspect code tab "Copy all code" becomes a DS secondary button, and the expand panel buttons ghost DS icon buttons labelled "Expand panel" (new key, en and es). The collapse toggles of the CSS and markup blocks stay raw buttons, since they sit in a 12px column; their arrow uses DS icons and they get a name and aria-expanded. The copy buttons belong to the shared copy button and move with it. A visual test covers the code tab buttons, expanded and collapsed. AI-assisted-by: claude-opus-5-5 * :recycle: Move inspect code tab styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties. The 11px uppercase titles become the 12px DS headline-small, the only difference. The code typography keeps its values with a TODO: the DS code-font names "Roboto Mono", while the app loads "robotomono". The full-width "Copy all code" DS button centers its label explicitly. The refactor/ import stays only for the copy buttons, styled here until the shared copy button moves to the DS. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 40 ++++++ frontend/src/app/main/ui/inspect/code.cljs | 42 +++--- frontend/src/app/main/ui/inspect/code.scss | 132 ++++++++---------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 5 files changed, 133 insertions(+), 89 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 432b3df9c6..2085eca3e3 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -322,3 +322,43 @@ test.describe("Viewer header", () => { }); }); }); + +test.describe("Inspect code tab", () => { + test("Shows the code tab buttons, expanded and collapsed", async ({ + page, + }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithComments(); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + await viewerPage.showCode(); + await page.getByRole("combobox").filter({ hasText: "Styles" }).click(); + await page.getByRole("option", { name: "Code" }).click(); + + const copyAll = page.getByRole("button", { name: "Copy all code" }); + await expect(copyAll).toBeVisible(); + // The CSS block header, with its toggle, expand and copy buttons. + const cssToggle = page.locator('button[data-type="css"]'); + const cssHeader = cssToggle.locator(".."); + await page.mouse.move(0, 0); + await expect(copyAll).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + await expect(cssHeader).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await cssToggle.click(); + await page.mouse.move(0, 0); + // Collapsed: the arrow turns. + await expect(cssHeader).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index 78b748a064..b96759932a 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -20,14 +20,17 @@ [app.main.ui.components.code-block :refer [code-block*]] [app.main.ui.components.copy-button :refer [copy-button*]] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [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 :refer [icon*]] [app.main.ui.hooks.resize :refer [use-resize-hook]] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.shapes.text.fontfaces :refer [shapes->fonts]] [app.util.clipboard :as clipboard] [app.util.code-beautify :as cb] [app.util.code-gen :as cg] [app.util.dom :as dom] [app.util.http :as http] + [app.util.i18n :refer [tr]] [beicon.v2.core :as rx] [cuerdas.core :as str] [okulary.core :as l] @@ -278,8 +281,9 @@ [:div {:class (stl/css-case :element-options true :viewer-code-block (= :viewer from))} [:div {:class (stl/css :attributes-block)} - [:button {:class (stl/css :download-button) - :on-click handle-copy-all-code} + [:> button* {:variant "secondary" + :class (stl/css :download-button) + :on-click handle-copy-all-code} "Copy all code"]] #_[:div.attributes-block @@ -291,11 +295,12 @@ [:div {:class (stl/css :code-row-lang)} [:button {:class (stl/css :toggle-btn) :data-type "css" + :aria-label "CSS" + :aria-expanded (not collapsed-css?) :on-click handle-collapse} - [:span {:class (stl/css-case - :collapsabled-icon true - :rotated collapsed-css?)} - deprecated-icon/arrow]] + [:> icon* {:icon-id (if collapsed-css? i/arrow-right i/arrow-down) + :size "s" + :class (stl/css :collapsabled-icon)}]] [:div {:class (stl/css :code-lang-option)} "CSS"] @@ -306,9 +311,10 @@ ;; :options [{:label "CSS" :value "css"}]}] [:div {:class (stl/css :action-btns)} - [:button {:class (stl/css :expand-button) - :on-click on-expand} - deprecated-icon/code] + [:> icon-button* {:variant "ghost" + :aria-label (tr "inspect.code.expand-panel") + :icon i/code + :on-click on-expand}] [:> copy-button* {:data copy-css-fn :class (stl/css :css-copy-btn) @@ -330,11 +336,12 @@ [:div {:class (stl/css :code-row-lang)} [:button {:class (stl/css :toggle-btn) :data-type "markup" + :aria-label (str/upper markup-type) + :aria-expanded (not collapsed-markup?) :on-click handle-collapse} - [:span {:class (stl/css-case - :collapsabled-icon true - :rotated collapsed-markup?)} - deprecated-icon/arrow]] + [:> icon* {:icon-id (if collapsed-markup? i/arrow-right i/arrow-down) + :size "s" + :class (stl/css :collapsabled-icon)}]] [:& radio-buttons {:selected markup-type :on-change set-markup @@ -347,9 +354,10 @@ :id :svg}]] [:div {:class (stl/css :action-btns)} - [:button {:class (stl/css :expand-button) - :on-click on-expand} - deprecated-icon/code] + [:> icon-button* {:variant "ghost" + :aria-label (tr "inspect.code.expand-panel") + :icon i/code + :on-click on-expand}] [:> copy-button* {:data copy-html-fn :class (stl/css :html-copy-btn) diff --git a/frontend/src/app/main/ui/inspect/code.scss b/frontend/src/app/main/ui/inspect/code.scss index 58acf7b110..01367318d6 100644 --- a/frontend/src/app/main/ui/inspect/code.scss +++ b/frontend/src/app/main/ui/inspect/code.scss @@ -4,45 +4,55 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the copy buttons, styled here until the shared copy button +// moves to the DS. @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 *; .element-options { display: flex; flex-direction: column; - height: calc(100vh - #{deprecated.$s-160}); // TODO: Fix this hardcoded value + block-size: calc(100vh - #{$sz-160}); // TODO: Fix this hardcoded value overflow: hidden; - padding-bottom: deprecated.$s-16; + padding-block-end: var(--sp-l); overflow-y: auto; padding-inline: var(--sp-m); } .viewer-code-block { - height: calc(100vh - #{deprecated.$s-108}); // TODO: Fix this hardcoded value + block-size: calc(100vh - #{px2rem(108)}); // TODO: Fix this hardcoded value } +// A full-width DS button does not center its label on its own. .download-button { - @extend %button-secondary; - @include deprecated.uppercase-title-typography; - - height: deprecated.$s-32; - width: 100%; - margin: deprecated.$s-8 0; + justify-content: center; + inline-size: 100%; + margin: var(--sp-s) 0; } .code-block { - @include deprecated.code-typography; - + // TODO: No exact DS typography match found: the DS code-font names + // "Roboto Mono", the app loads it as "robotomono". + // Closest match: code-font. + font-family: "robotomono", monospace; + font-size: px2rem(12); + font-weight: 400; + line-height: 1.2; display: flex; flex-direction: column; - height: 100%; - min-height: 0; - padding: 0 deprecated.$s-4 deprecated.$s-8 0; + block-size: 100%; + min-block-size: 0; + padding: 0 var(--sp-xs) var(--sp-s) 0; + // The highlighted code markup has no class of its own. pre { - border-radius: deprecated.$br-8; - padding: deprecated.$s-16; + border-radius: $br-8; + padding: var(--sp-l); overflow: auto; - height: 100%; + block-size: 100%; } // Overrides background setted in the theme @@ -51,19 +61,19 @@ } &.collapsed { - height: initial; + block-size: initial; } } .code-row-lang { display: grid; - grid-template-columns: deprecated.$s-12 1fr deprecated.$s-60; - gap: deprecated.$s-4; - width: 100%; + grid-template-columns: $sz-12 1fr auto; + gap: var(--sp-xs); + inline-size: 100%; } .code-lang { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); display: flex; align-items: center; @@ -71,92 +81,70 @@ .action-btns { display: grid; - grid-template-columns: 1fr 1fr; - gap: deprecated.$s-4; + grid-template-columns: auto auto; + gap: var(--sp-xs); } -.expand-button, .css-copy-btn, .html-copy-btn { @extend %button-tertiary; - height: deprecated.$s-32; - width: deprecated.$s-28; + block-size: $sz-32; + inline-size: $sz-28; svg { @extend %button-icon; - stroke: var(--icon-foreground); + stroke: var(--color-foreground-secondary); } } .code-lang-options { - max-width: deprecated.$s-108; + max-inline-size: px2rem(108); } .code-lang-select { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - width: deprecated.$s-72; - border: deprecated.$s-1 solid transparent; + inline-size: px2rem(72); + border: $b-1 solid transparent; background-color: transparent; - color: var(--menu-foreground-color-disabled); + color: var(--color-foreground-secondary); } .code-lang-option { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - width: deprecated.$s-72; - height: deprecated.$s-32; - padding: deprecated.$s-8; - color: var(--menu-foreground-color-disabled); + inline-size: px2rem(72); + block-size: $sz-32; + padding: var(--sp-s); + color: var(--color-foreground-secondary); } .code-row-display { flex: 1; - min-height: 0; + min-block-size: 0; overflow: hidden; - padding-bottom: deprecated.$s-8; + padding-block-end: var(--sp-s); } +// A disclosure toggle in a 12px column, not a DS button. .toggle-btn { - @include deprecated.button-style; + --toggle-btn-foreground-color: var(--color-foreground-secondary); display: flex; align-items: center; padding: 0; - color: var(--title-foreground-color); - stroke: var(--title-foreground-color); - - .collapsabled-icon { - @include deprecated.flex-center; - - height: deprecated.$s-24; - border-radius: deprecated.$br-8; - - svg { - @extend %button-icon-small; - - transform: rotate(90deg); - stroke: var(--icon-foreground); - } - - &.rotated svg { - transform: rotate(0deg); - } - } + border: none; + background: none; + cursor: pointer; + color: var(--toggle-btn-foreground-color); &:hover { - color: var(--title-foreground-color-hover); - stroke: var(--title-foreground-color-hover); - - .title { - color: var(--title-foreground-color-hover); - stroke: var(--title-foreground-color-hover); - } - - .collapsabled-icon svg { - stroke: var(--title-foreground-color-hover); - } + --toggle-btn-foreground-color: var(--color-foreground-primary); } } + +.collapsabled-icon { + flex-shrink: 0; +} diff --git a/frontend/translations/en.po b/frontend/translations/en.po index dde95de91f..c747726caa 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2281,6 +2281,10 @@ msgstr "Variant properties" msgid "inspect.attributes.variants" msgstr "Variants properties" +#: src/app/main/ui/inspect/code.cljs:315, src/app/main/ui/inspect/code.cljs:358 +msgid "inspect.code.expand-panel" +msgstr "Expand panel" + #: src/app/main/ui/inspect/right_sidebar.cljs:169 msgid "inspect.color-space-label" msgstr "Select color space" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 1bd162954c..c95dafcebd 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2317,6 +2317,10 @@ msgstr "Propiedades de la variante" msgid "inspect.attributes.variants" msgstr "Propiedades de las variantes" +#: src/app/main/ui/inspect/code.cljs:315, src/app/main/ui/inspect/code.cljs:358 +msgid "inspect.code.expand-panel" +msgstr "Expandir panel" + #: src/app/main/ui/inspect/right_sidebar.cljs:169 msgid "inspect.color-space-label" msgstr "Seleccionar espacio de color" From cf19d7f8708f637860d27b131f5275c8948942ed 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 153/167] :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 4b13562d022e8d489242790f77797c7c94b7e48d 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 154/167] :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 e4c1b154e33293707de94d0f9f2ad81c742dd177 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 27 Oct 2026 09:30:28 +0100 Subject: [PATCH 155/167] :sparkles: Add profile name and email to nitrate audit log (#12636) * :sparkles: Add profile name and email to nitrate audit log * :sparkles: Mr changes --- .serena/memories/audit-log.md | 2 +- .../src/app/loggers/audit/consumer_task.clj | 61 ++++++++-- backend/src/app/nitrate.clj | 4 +- .../loggers_audit_consumer_task_test.clj | 104 +++++++++++++++++- .../nitrate_ingest_audit_log_test.clj | 6 + 5 files changed, 157 insertions(+), 20 deletions(-) diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index 68d4bcc021..a9766ec80b 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -277,7 +277,7 @@ Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and insert ### How it works today -Every 5 min each archive job drains its own queue (`:audit-log-consumer-nexus` on flag `:nexus`, cron `0 */5 * * * ?`; `:audit-log-consumer-admin-console` on flag `:admin-console`, cron `30 */5 * * * ?`, 30s offset so the two drains never start on the same second), in 128-row chunks taken oldest-first (`ORDER BY created_at ASC, id ASC`) with `FOR UPDATE OF audit_log` (`SET LOCAL lock_timeout = '30s'`) inside the same transaction that sends and completes them, with 100 ms pauses between chunks. Batches arrive at Nexus in chronological order (it numbers by arrival for order-sensitive consumers). Nexus receives transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri; flag on but no URI raises `:task-not-configured`. The admin console receives only its allowlisted event names (enriched with team names and emails); anything filtered out counts as done for the admin-console queue. A chunk whose destination refuses it (Nexus non-204 raises `:consumer-chunk-refused`) or throws (transport failure, admin-console error) rolls back and stays for the next tick, and the job aborts as failed: it archives what it can, and the cron retries the rest. Committed chunks stay sent. The send fns return counts or throw carrying the attempted count as `:events`; `run-single-consumer` records the `failed` metric from it, outside every chunk transaction (a local DB failure carries no `:events` and stays uncounted — it is not a destination failure). No `warn` before the throw: the runner reports the aborted job. Each job touches only its own queue, so a fallen Nexus never starves the admin console (nor vice versa). +Every 5 min each archive job drains its own queue (`:audit-log-consumer-nexus` on flag `:nexus`, cron `0 */5 * * * ?`; `:audit-log-consumer-admin-console` on flag `:admin-console`, cron `30 */5 * * * ?`, 30s offset so the two drains never start on the same second), in 128-row chunks taken oldest-first (`ORDER BY created_at ASC, id ASC`) with `FOR UPDATE OF audit_log` (`SET LOCAL lock_timeout = '30s'`) inside the same transaction that sends and completes them, with 100 ms pauses between chunks. Batches arrive at Nexus in chronological order (it numbers by arrival for order-sensitive consumers). Nexus receives transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri; flag on but no URI raises `:task-not-configured`. The admin console receives only its allowlisted event names (enriched with team names, props emails, and optional top-level `:profile-email` / `:profile-name` from the actor `:profile-id`); anything filtered out counts as done for the admin-console queue. A chunk whose destination refuses it (Nexus non-204 raises `:consumer-chunk-refused`) or throws (transport failure, admin-console error) rolls back and stays for the next tick, and the job aborts as failed: it archives what it can, and the cron retries the rest. Committed chunks stay sent. The send fns return counts or throw carrying the attempted count as `:events`; `run-single-consumer` records the `failed` metric from it, outside every chunk transaction (a local DB failure carries no `:events` and stays uncounted — it is not a destination failure). No `warn` before the throw: the runner reports the aborted job. Each job touches only its own queue, so a fallen Nexus never starves the admin console (nor vice versa). A chunk is **one POST** and the task advances only on 204, so a single oversized event wedges the whole queue: a crash report whose message embeds a whole payload (a serialized file, for example) can push the request past the proxy body limit and the rejection blocks every later event until it is pruned by hand. Frontend crash reports are therefore size-bounded at the source (`mem:frontend/routing-app-shell-subtleties`): `app.common.exceptions/get-hint` and the CLJS `format-throwable` clamp exception messages (`max-message-length`). diff --git a/backend/src/app/loggers/audit/consumer_task.clj b/backend/src/app/loggers/audit/consumer_task.clj index 9fa05c5e70..7270cfae94 100644 --- a/backend/src/app/loggers/audit/consumer_task.clj +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -289,6 +289,19 @@ id->email-prop))) events)) +(defn- collect-actor-ids-needing-profile + "Actor ids (event :profile-id) that still need top-level :profile-email + and/or :profile-name. Skips uuid/zero and non-uuids." + [events] + (into #{} + (keep (fn [event] + (when (or (not (contains? event :profile-email)) + (not (contains? event :profile-name))) + (when-let [id (uuid/coerce (:profile-id event))] + (when-not (uuid/zero? id) + id))))) + events)) + (defn- load-team-names [conn ids] (if (seq ids) @@ -297,12 +310,13 @@ (into {} (map (juxt :id :name) rows))) {})) -(defn- load-profile-emails +(defn- load-profiles + "Batch-load profile email and fullname (including soft-deleted)." [conn ids] (if (seq ids) (let [arr (db/create-array conn "uuid" ids) - rows (db/exec! conn ["SELECT id, email FROM profile WHERE id = ANY(?)" arr])] - (into {} (map (juxt :id :email) rows))) + rows (db/exec! conn ["SELECT id, email, fullname FROM profile WHERE id = ANY(?)" arr])] + (into {} (map (juxt :id identity) rows))) {})) (defn- maybe-assoc-team-name @@ -315,33 +329,58 @@ event))) (defn- maybe-assoc-emails - [event emails] + [event profiles] (update event :props (fn [props] (reduce-kv (fn [props id-key email-key] (if-let [email (and (not (contains? props email-key)) (when-let [id (uuid/coerce (get props id-key))] - (get emails id)))] + (:email (get profiles id))))] (assoc props email-key email) props)) props id->email-prop)))) +(defn- maybe-assoc-actor-profile + "Fill-only top-level :profile-email / :profile-name from the event + actor (:profile-id). Omits keys when the id is missing, zero, the + profile row is absent, or the corresponding column is empty + (fullname defaults to \"\" in the schema)." + [event profiles] + (let [id (uuid/coerce (:profile-id event))] + (if (or (nil? id) (uuid/zero? id)) + event + (if-let [profile (get profiles id)] + (cond-> event + (and (not (contains? event :profile-email)) + (not-empty (:email profile))) + (assoc :profile-email (:email profile)) + + (and (not (contains? event :profile-name)) + (not-empty (:fullname profile))) + (assoc :profile-name (:fullname profile))) + event)))) + (defn- enrich-events-for-admin-console "Enrich allowlisted events before the admin-console ingest: 1. Fill missing :team-name from team id (including soft-deleted). - 2. Fill missing emails from matching id keys (including soft-deleted - profiles): :profile-email, :user-email, :member-email, - :user-who-send-invitation-email." + 2. Fill missing emails from matching id keys in props (including + soft-deleted profiles): :profile-email, :user-email, :member-email, + :user-who-send-invitation-email. + 3. Fill missing top-level :profile-email / :profile-name from the + event actor (:profile-id), including soft-deleted profiles; omit + when there is no usable actor id or the value is empty." [{:keys [::db/conn]} events] (let [team-ids (collect-team-ids-needing-name events) - profile-ids (collect-profile-ids-needing-email events) + profile-ids (into (collect-profile-ids-needing-email events) + (collect-actor-ids-needing-profile events)) team-names (load-team-names conn team-ids) - emails (load-profile-emails conn profile-ids)] + profiles (load-profiles conn profile-ids)] (mapv (fn [event] (-> event (maybe-assoc-team-name team-names) - (maybe-assoc-emails emails))) + (maybe-assoc-emails profiles) + (maybe-assoc-actor-profile profiles))) events))) (defn- send-to-admin-console diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index f911307ea2..e475c531c1 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -483,8 +483,8 @@ (assoc params :throw-on-error? true))) (def ^:private audit-event-keys - [:id :name :type :profile-id :props :context - :created-at :tracked-at :source :ip-addr]) + [:id :name :type :profile-id :profile-email :profile-name + :props :context :created-at :tracked-at :source :ip-addr]) (defn- ->audit-event [event] diff --git a/backend/test/backend_tests/loggers_audit_consumer_task_test.clj b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj index 1fb3efd9e5..17a5a2bbc3 100644 --- a/backend/test/backend_tests/loggers_audit_consumer_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj @@ -64,10 +64,14 @@ (insert-pendings! (:id row)) row))) -(defn- admin-console-event-props +(defn- admin-console-event [admin-console-mock] (let [[_ _ params] (:call-args @admin-console-mock)] - (:props (first (:events params))))) + (first (:events params)))) + +(defn- admin-console-event-props + [admin-console-mock] + (:props (admin-console-event admin-console-mock))) (defn- run-both! "Run both consumer jobs, Nexus first (the historical single-run @@ -319,12 +323,16 @@ :user-who-send-invitation (str (:id actor))}) (th/run-task! :audit-log-consumer-admin-console {}) (t/is (:called? @admin-console-mock)) - (let [props (admin-console-event-props admin-console-mock)] + (let [event (admin-console-event admin-console-mock) + props (:props event)] (t/is (= (:name team) (:team-name props))) (t/is (= (:email actor) (:profile-email props))) (t/is (= (:email member) (:user-email props))) (t/is (= (:email member) (:member-email props))) - (t/is (= (:email actor) (:user-who-send-invitation-email props)))) + (t/is (= (:email actor) (:user-who-send-invitation-email props))) + ;; Top-level actor fields (event :profile-id), distinct from props. + (t/is (= (:email actor) (:profile-email event))) + (t/is (= (:fullname actor) (:profile-name event)))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) @@ -340,12 +348,48 @@ {:team-id (:id team) :profile-id (:id other)}) (th/run-task! :audit-log-consumer-admin-console {}) - (let [props (admin-console-event-props admin-console-mock)] + (let [event (admin-console-event admin-console-mock) + props (:props event)] (t/is (= (:name team) (:team-name props))) - (t/is (= (:email other) (:profile-email props)))) + (t/is (= (:email other) (:profile-email props))) + (t/is (= (:email actor) (:profile-email event))) + (t/is (= (:fullname actor) (:profile-name event)))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) +(t/deftest consumer-events-enriches-soft-deleted-actor-profile + (let [actor (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id actor)})] + (th/db-update! :profile {:deleted-at (ct/now)} {:id (:id actor)}) + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! (:id actor) "create-project" + {:team-id (:id team)}) + (th/run-task! :audit-log-consumer-admin-console {}) + (let [event (admin-console-event admin-console-mock)] + (t/is (= (:email actor) (:profile-email event))) + (t/is (= (:fullname actor) (:profile-name event)))))))) + +(t/deftest consumer-events-omits-actor-profile-when-no-usable-id + (let [actor (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id actor)}) + missing (uuid/next)] + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! uuid/zero "create-project" {:team-id (:id team)}) + (insert-audit-row! missing "create-team" {:team-id (:id team)}) + (th/run-task! :audit-log-consumer-admin-console {}) + (t/is (:called? @admin-console-mock)) + (let [[_ _ params] (:call-args @admin-console-mock) + events (:events params) + by-name (into {} (map (juxt :name identity) events)) + zero-evt (get by-name "create-project") + missing-evt (get by-name "create-team")] + (t/is (not (contains? zero-evt :profile-email))) + (t/is (not (contains? zero-evt :profile-name))) + (t/is (not (contains? missing-evt :profile-email))) + (t/is (not (contains? missing-evt :profile-name)))))))) + (t/deftest consumer-events-does-not-overwrite-existing-enrich-props (let [actor (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] @@ -371,6 +415,54 @@ (t/is (= "already-inviter@example.com" (:user-who-send-invitation-email props)))))))) +(t/deftest consumer-events-does-not-overwrite-existing-actor-profile-fields + ;; Top-level :profile-email / :profile-name are not stored on the row; + ;; exercise fill-only via the enrich helper directly. + (let [actor (th/create-profile* 1 {:is-active true}) + event {:profile-id (:id actor) + :profile-email "preset@example.com" + :profile-name "Preset Name" + :props {}}] + (db/tx-run! th/*system* + (fn [cfg] + (let [enriched (first (#'consumer/enrich-events-for-admin-console + cfg [event]))] + (t/is (= "preset@example.com" (:profile-email enriched))) + (t/is (= "Preset Name" (:profile-name enriched)))))))) + +(t/deftest consumer-events-fills-missing-actor-profile-field-independently + ;; Each top-level key fills on its own; guards an or→and regression in + ;; collect-actor-ids-needing-profile. + (let [actor (th/create-profile* 1 {:is-active true})] + (db/tx-run! th/*system* + (fn [cfg] + (let [email-only (first (#'consumer/enrich-events-for-admin-console + cfg [{:profile-id (:id actor) + :profile-email "preset@example.com" + :props {}}])) + name-only (first (#'consumer/enrich-events-for-admin-console + cfg [{:profile-id (:id actor) + :profile-name "Preset Name" + :props {}}]))] + (t/is (= "preset@example.com" (:profile-email email-only))) + (t/is (= (:fullname actor) (:profile-name email-only))) + (t/is (= (:email actor) (:profile-email name-only))) + (t/is (= "Preset Name" (:profile-name name-only)))))))) + +(t/deftest consumer-events-omits-empty-actor-profile-name + ;; profile.fullname is NOT NULL DEFAULT ''; empty must not become + ;; :profile-name "". + (let [actor (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id actor)})] + (th/db-update! :profile {:fullname ""} {:id (:id actor)}) + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! (:id actor) "create-project" {:team-id (:id team)}) + (th/run-task! :audit-log-consumer-admin-console {}) + (let [event (admin-console-event admin-console-mock)] + (t/is (= (:email actor) (:profile-email event))) + (t/is (not (contains? event :profile-name)))))))) + (t/deftest consumer-events-enrich-skips-missing-and-non-uuid-ids (let [actor (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] diff --git a/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj b/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj index b8a679d842..82971f4d93 100644 --- a/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj +++ b/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj @@ -43,6 +43,8 @@ :name "create-project" :type "action" :profile-id p1 + :profile-email "ada@example.com" + :profile-name "Ada" :props {:team-id team-id} :context {} :created-at now @@ -73,6 +75,10 @@ (t/is (= (str e2) (str (:id e2')))) (t/is (= (str p1) (str (:profile-id e1')))) (t/is (= (str p2) (str (:profile-id e2')))) + (t/is (= "ada@example.com" (:profile-email e1'))) + (t/is (= "Ada" (:profile-name e1'))) + (t/is (nil? (:profile-email e2'))) + (t/is (nil? (:profile-name e2'))) (t/is (= "127.0.0.1" (:ip-addr e1'))) (t/is (= "10.0.0.1" (:ip-addr e2'))) (t/is (string? (:created-at e1'))) From 411f661bdbcf7d5a1e6ba8b63efca3ccc8d1c4f5 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 10:20:11 +0100 Subject: [PATCH 156/167] :recycle: Use DS buttons in the delete shared libraries modal (#12554) * :recycle: Use DS buttons in the delete shared libraries modal The delete shared libraries modal (unpublish a library, delete a shared file or project) gets a ghost DS icon button named "Close", placed on the tooltip wrapper, and DS buttons for cancel (secondary) and accept (destructive for the danger style, primary otherwise) instead of raw inputs. The buttons are narrower and the accept button takes the DS destructive red. A new empty library references fixture and a visual test cover the unpublish library modal from the workspace main menu. AI-assisted-by: claude-opus-5-5 * :recycle: Move delete shared libraries modal styles closer to the DS The delete shared libraries stylesheet resolves its legacy modal color aliases to DS tokens, uses a logical property and the DS typography module without a namespace. refactor/ stays only for the modal placeholders (%modal-*), which move with the modals refactor. Two unused selectors are commented out with a TODO. No visual change. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the delete shared libraries modal The SCSS refactor of the delete shared libraries 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: &.transparent, .list-wrapper. No visual change. AI-assisted-by: claude-opus-5-5 --- .../get-library-file-references-empty.json | 1 + .../ui/visual-specs/workspace.spec.js | 29 ++++++++++++ frontend/src/app/main/ui/delete_shared.cljs | 29 ++++++------ frontend/src/app/main/ui/delete_shared.scss | 45 ++++++------------- 4 files changed, 60 insertions(+), 44 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-library-file-references-empty.json diff --git a/frontend/playwright/data/workspace/get-library-file-references-empty.json b/frontend/playwright/data/workspace/get-library-file-references-empty.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/frontend/playwright/data/workspace/get-library-file-references-empty.json @@ -0,0 +1 @@ +[] diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index d5621b77ee..91dc893850 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -1077,3 +1077,32 @@ test.describe("Export modal", () => { }); }); }); + +test.describe("Unpublish library modal", () => { + test("Shows the close, cancel and unpublish buttons", async ({ page }) => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-library.json"); + await workspace.mockRPC( + "get-library-file-references?file-id=*", + "workspace/get-library-file-references-empty.json", + ); + await workspace.goToWorkspace({ + fileId: "c1249a66-fce0-8175-8004-7433fe4be8bc", + pageId: "c1249a66-fce0-8175-8004-7433fe4be8bd", + }); + + await page.getByRole("button", { name: "Main menu" }).click(); + await page.locator("#file-menu-file").click(); + await page.locator("#file-menu-remove-shared").click(); + + const modal = page.locator("[class*='delete_shared__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); + }); +}); diff --git a/frontend/src/app/main/ui/delete_shared.cljs b/frontend/src/app/main/ui/delete_shared.cljs index 6e88fd41ab..4acd9b066d 100644 --- a/frontend/src/app/main/ui/delete_shared.cljs +++ b/frontend/src/app/main/ui/delete_shared.cljs @@ -11,8 +11,10 @@ [app.main.data.modal :as modal] [app.main.repo :as rp] [app.main.store :as st] + [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.notifications.context-notification :refer [context-notification*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as k] @@ -97,8 +99,11 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} title] - [:button {:class (stl/css :modal-close-btn) - :on-click cancel-fn} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click cancel-fn}]] [:div {:class (stl/css :modal-content)} (when (and (string? subtitle) (not= subtitle "")) @@ -124,14 +129,12 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} (when-not (= cancel-label :omit) - [:input {:class (stl/css :cancel-button) - :type "button" - :value cancel-label - :on-click cancel-fn}]) + [:> button* {:variant "secondary" + :type "button" + :on-click cancel-fn} + cancel-label]) - [:input {:class (stl/css-case :accept-btn true - :danger (= accept-style :danger) - :primary (= accept-style :primary)) - :type "button" - :value accept-label - :on-click accept-fn}]]]]])) + [:> button* {:variant (if (= accept-style :danger) "destructive" "primary") + :type "button" + :on-click accept-fn} + accept-label]]]]])) diff --git a/frontend/src/app/main/ui/delete_shared.scss b/frontend/src/app/main/ui/delete_shared.scss index ab77bb5fd1..5851074638 100644 --- a/frontend/src/app/main/ui/delete_shared.scss +++ b/frontend/src/app/main/ui/delete_shared.scss @@ -4,15 +4,13 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor. @use "refactor/basic-rules.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; .modal-overlay { @extend %modal-overlay-base; - - &.transparent { - background-color: transparent; - } } .modal-container { @@ -23,33 +21,30 @@ grid-template-rows: auto minmax(0, 1fr) auto; } -.list-wrapper { - display: grid; - grid-template-rows: auto 1fr auto; - max-height: 100%; -} - .modal-title { - @include t.use-typography("headline-medium"); + @include use-typography("headline-medium"); - 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 t.use-typography("body-small"); + @include use-typography("body-small"); display: grid; gap: var(--sp-s); } .element-list { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); overflow-y: auto; margin-block: 0; } @@ -58,18 +53,6 @@ @extend %modal-action-btns; } -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} - .modal-scd-msg { margin-block: 0; } @@ -77,8 +60,8 @@ .modal-scd-msg, .modal-subtitle, .modal-msg { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); line-height: 1.5; } From 9854f8e54f125fb0c60a6782ae0ab69100ff7406 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 10:23:55 +0100 Subject: [PATCH 157/167] :recycle: Move dashboard subscription styles off the deprecated styles (#12538) * :recycle: Move dashboard subscription styles off the deprecated styles The subscription stylesheet inlines the deprecated button-style and flex-center mixins, resolves its legacy color aliases and sizes to DS tokens, uses logical properties and the DS typography module without a namespace. Three rules that match nothing (the arrow svg, .cta-link and .open-arrow) are commented out with a TODO. The subscription controls need no DS change: the plan card, the current plan card and the manage subscription link have no DS equivalent, and they use no legacy icons. refactor/ stays only for the power up menu item placeholder and the shared dashboard header styles. A visual test covers the sidebar plan card (closed and open), the team settings plan and the members seats warnings. No visual change. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the dashboard subscription The SCSS refactor of the dashboard 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, .cta-link, .open-arrow. No visual change. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 101 +++++++++++++++++ .../app/main/ui/dashboard/subscription.scss | 104 ++++++++---------- 2 files changed, 149 insertions(+), 56 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 1b2ce0d483..ee79bdfc75 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -337,3 +337,104 @@ test.describe("Delete account modal", () => { await expect(modal).toHaveScreenshot(shot); }); }); + +test.describe("Subscription controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + // An unlimited trial that the logged-in user owns, as in + // specs/subscriptions-dashboard.spec.js. + const setupSubscription = async ( + page, + members, + secondTeam = true, + usage = "subscription/get-subscription-usage.json", + ) => { + await DashboardPage.mockConfigFlags(page, [ + "enable-subscriptions", + "disable-onboarding", + ]); + await DashboardPage.mockRPC( + page, + "get-profile", + "subscription/get-profile-unlimited-subscription.json", + ); + await DashboardPage.mockRPC(page, "get-subscription-usage", usage); + await DashboardPage.mockRPC( + page, + "get-team-info", + "subscription/get-team-info-subscriptions.json", + ); + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await DashboardPage.mockRPC( + page, + "get-teams", + "subscription/get-teams-unlimited-subscription-owner.json", + ); + if (secondTeam) { + await DashboardPage.mockRPC( + page, + "get-projects?team-id=*", + "dashboard/get-projects-second-team.json", + ); + } + await DashboardPage.mockRPC(page, "get-team-members?team-id=*", members); + await DashboardPage.mockRPC( + page, + "get-team-stats?team-id=*", + "dashboard/get-team-stats.json", + ); + await dashboardPage.mockRPC( + "push-audit-events", + "workspace/audit-event-empty.json", + ); + return dashboardPage; + }; + + test("Sidebar plan card", async ({ page }) => { + const dashboardPage = await setupSubscription( + page, + "subscription/get-team-members-subscription-owner.json", + false, + "subscription/get-subscription-usage-one-editor.json", + ); + await dashboardPage.goToDashboard(); + + const name = page.getByTestId("subscription-name"); + await expect(name).toHaveText("Unlimited plan (trial)"); + const card = page.locator("[class*='cta-power-up']").first(); + await page.mouse.move(640, 300); + await expect(card).toHaveScreenshot(shot); + + await name.click(); + await page.mouse.move(640, 300); + await expect(card).toHaveScreenshot(shot); + }); + + test("Team settings plan", async ({ page }) => { + const dashboardPage = await setupSubscription( + page, + "subscription/get-team-members-subscription-owner.json", + ); + await dashboardPage.goToSecondTeamSettingsSection(); + + await expect( + page.getByRole("button", { name: "Manage your subscription" }), + ).toBeVisible(); + await expect( + page.locator("[class$='subscription__team']"), + ).toHaveScreenshot(shot); + }); + + test("Members seats warning", async ({ page }) => { + const dashboardPage = await setupSubscription( + page, + "subscription/get-team-members-subscription-eight-member.json", + ); + await dashboardPage.goToSecondTeamMembersSection(); + + await expect(page.getByTestId("cta")).toHaveCount(2); + await page.mouse.move(640, 700); + await expect(page).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index 43a6d9419b..6cfe4c2e9a 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -4,10 +4,16 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the menu item placeholder (%menu-item-base) of the +// power up submenu. @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/_borders.scss" as *; +@use "ds/_utils.scss" as *; @use "ds/spacing.scss" as *; .cta-power-up { @@ -26,8 +32,9 @@ } .cta-top-section { - @include deprecated.button-style; - + border: none; + background: none; + cursor: pointer; display: grid; color: var(--color-foreground-secondary); grid-template-columns: 1fr auto; @@ -44,17 +51,11 @@ } .icon-dropdown { - @include deprecated.flex-center; - - height: 100%; - width: var(--sp-l); -} - -.icon-dropdown svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - transform: rotate(90deg); + display: flex; + justify-content: center; + align-items: center; + block-size: 100%; + inline-size: var(--sp-l); } .cta-bottom-section { @@ -69,12 +70,14 @@ } .cta-bottom-section .content { - @include t.use-typography("body-medium"); - @include deprecated.button-style; + @include use-typography("body-medium"); + border: none; + background: none; + cursor: pointer; color: var(--color-foreground-secondary); display: inline-block; - text-align: left; + text-align: start; } .cta-bottom-button { @@ -87,72 +90,67 @@ .cta-text, .cta-title { - text-align: left; + text-align: start; } .cta-title { - @include t.use-typography("body-small"); + @include use-typography("body-small"); margin-block-end: var(--sp-xs); } .highlighted .cta-title { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); margin-block-end: 0; } .cta-text { - @include t.use-typography("title-small"); + @include use-typography("title-small"); } .highlighted .cta-text { - @include t.use-typography("body-large"); + @include use-typography("body-large"); color: var(--color-foreground-primary); } .cta-bottom-section .content a { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-accent-tertiary); margin-inline-start: var(--sp-xs); } -.cta-link { - @include deprecated.button-style; - - align-self: end; - margin-inline-start: var(--sp-xs); -} - .team { display: grid; grid-auto-rows: min-content; gap: var(--sp-s); - max-width: deprecated.$s-1000; - width: 100%; + max-inline-size: px2rem(1000); + inline-size: 100%; } .team-label { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); } .team-text { - @include t.use-typography("title-medium"); + @include use-typography("title-medium"); color: var(--color-foreground-primary); } .manage-subscription-link { - @include deprecated.button-style; - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); + border: none; + background: none; + cursor: pointer; color: var(--color-accent-tertiary); display: flex; - margin-block-start: -8px; + margin-block-start: calc(-1 * var(--sp-s)); padding: 0; } @@ -178,21 +176,15 @@ cursor: pointer; &:hover { - color: var(--menu-foreground-color-hover); - - .open-arrow { - svg { - stroke: var(--menu-foreground-color-hover); - } - } + color: var(--color-foreground-primary); } } .members-cta { - height: fit-content; + block-size: fit-content; margin-block-start: var(--sp-s); - margin-inline-start: deprecated.$s-52; - max-width: deprecated.$s-220; + margin-inline-start: px2rem(52); + max-inline-size: px2rem(220); .cta-title { line-height: 1.2; @@ -200,7 +192,7 @@ } .dashboard-cta { - height: fit-content; + block-size: fit-content; margin: var(--sp-l); .cta-title { @@ -209,7 +201,7 @@ } .cta-message { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); line-height: 1; @@ -231,13 +223,13 @@ } .nitrate-title { - @include t.use-typography("body-large"); + @include use-typography("body-large"); color: var(--color-foreground-primary); } .nitrate-info { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); margin-block: var(--sp-s) var(--sp-xxl); @@ -249,8 +241,8 @@ } .nitrate-bottom-button { - width: fit-content; - height: fit-content; + inline-size: fit-content; + block-size: fit-content; padding: var(--sp-s) var(--sp-m); } @@ -271,14 +263,14 @@ } .nitrate-current-plan-label { - @include t.use-typography("body-small"); + @include use-typography("body-small"); padding-block-end: var(--sp-xs); color: var(--color-foreground-secondary); } .nitrate-current-plan-text { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-primary); } From 19fc9ebce870508b3d9c1c109544984073302cc6 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 10:47:07 +0100 Subject: [PATCH 158/167] :recycle: Use DS icons in the viewer flows and interactions menus (#12520) * :recycle: Use DS icons in the viewer flows and interactions menus The flows and interactions triggers in the viewer header were divs with a click handler, and their menus lived inside them. They are now buttons with aria-expanded and aria-haspopup, and each menu sits next to its button in a wrapper, since a button cannot hold the menu entries. The play, arrow and tick icons use DS icons. The menu entries stay as they are. A visual test covers the interactions menu closed and open; the test files have no flows, and the flows trigger shares its styles. AI-assisted-by: claude-opus-5-5 * :recycle: Move viewer interactions styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties. The DS icons take their color from the text and no longer shrink, so the old icon placeholders go away. The DS body-small line-height is more compact, so the trigger labels sit 1px higher. The refactor/ import stays only for the shared menu placeholders. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 33 ++++++++ .../src/app/main/ui/viewer/interactions.cljs | 40 ++++++---- .../src/app/main/ui/viewer/interactions.scss | 79 +++++++------------ 3 files changed, 89 insertions(+), 63 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 2085eca3e3..80ed3f7d52 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -362,3 +362,36 @@ test.describe("Inspect code tab", () => { }); }); }); + +test.describe("Interactions menu", () => { + test("Shows the interactions menu closed and open", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithMultipleBoards(); + await viewerPage.goToViewer({ + fileId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb0", + pageId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb3", + }); + + const trigger = page + .getByText("Interactions", { exact: true }) + .locator(".."); + await expect(trigger).toBeVisible(); + await page.mouse.move(640, 600); + // Closed: the "Interactions" trigger with its arrow. + await expect(trigger).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await trigger.click(); + const menu = page.getByText("Show interactions", { exact: true }); + await expect(menu).toBeVisible(); + await page.mouse.move(640, 600); + // Open: the menu with the selected mode ticked. + await expect(menu.locator("../..")).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/viewer/interactions.cljs b/frontend/src/app/main/ui/viewer/interactions.cljs index bd96266d37..0aff863b5d 100644 --- a/frontend/src/app/main/ui/viewer/interactions.cljs +++ b/frontend/src/app/main/ui/viewer/interactions.cljs @@ -18,8 +18,8 @@ [app.main.features :as features] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks :as h] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.viewer.shapes :as shapes] [app.main.ui.viewer.viewport-common :as vpc] [app.main.ui.viewer.viewport-wasm :as viewport.wasm] @@ -218,11 +218,17 @@ (st/emit! (dv/go-to-frame (:starting-frame flow))))))] (when flows - [:div {:on-click toggle-dropdown - :class (stl/css :view-options)} - [:span {:class (stl/css :icon)} deprecated-icon/play] - [:span {:class (stl/css :dropdown-title)} (:name current-flow)] - [:span {:class (stl/css :icon-dropdown)} deprecated-icon/arrow] + [:div {:class (stl/css :view-options-wrapper)} + [:button {:type "button" + :class (stl/css :view-options) + :aria-expanded show-dropdown? + :aria-haspopup "menu" + :on-click toggle-dropdown} + [:> icon* {:icon-id i/play :size "s" :class (stl/css :icon)}] + [:span {:class (stl/css :dropdown-title)} (:name current-flow)] + [:> icon* {:icon-id i/arrow-down + :size "s" + :class (stl/css :icon-dropdown)}]] [:& dropdown {:show show-dropdown? :on-close hide-dropdown} [:ul {:class (stl/css :dropdown)} @@ -236,7 +242,7 @@ :on-click select-flow} [:span {:class (stl/css :label)} (:name flow)] (when (= flow-id (:id current-flow)) - [:span {:class (stl/css :icon)} deprecated-icon/tick])])]]]))) + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])])]]]))) (mf/defc interactions-menu* [{:keys [interactions-mode]}] @@ -253,10 +259,16 @@ (dom/stop-propagation event) (st/emit! (dv/set-interactions-mode mode)))))] - [:div {:on-click toggle-dropdown - :class (stl/css :view-options)} - [:span {:class (stl/css :dropdown-title)} (tr "viewer.header.interactions")] - [:span {:class (stl/css :icon-dropdown)} deprecated-icon/arrow] + [:div {:class (stl/css :view-options-wrapper)} + [:button {:type "button" + :class (stl/css :view-options) + :aria-expanded (deref show-dropdown?) + :aria-haspopup "menu" + :on-click toggle-dropdown} + [:span {:class (stl/css :dropdown-title)} (tr "viewer.header.interactions")] + [:> icon* {:icon-id i/arrow-down + :size "s" + :class (stl/css :icon-dropdown)}]] [:& dropdown {:show @show-dropdown? :on-close hide-dropdown} [:ul {:class (stl/css :dropdown)} @@ -267,7 +279,7 @@ [:span {:class (stl/css :label)} (tr "viewer.header.dont-show-interactions")] (when (= interactions-mode :hide) - [:span {:class (stl/css :icon)} deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] [:li {:class (stl/css-case :dropdown-element true :selected (= interactions-mode :show)) @@ -275,7 +287,7 @@ :data-mode "show"} [:span {:class (stl/css :label)} (tr "viewer.header.show-interactions")] (when (= interactions-mode :show) - [:span {:class (stl/css :icon)} deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] @@ -286,7 +298,7 @@ [:span {:class (stl/css :label)} (tr "viewer.header.show-interactions-on-click")] (when (= interactions-mode :show-on-click) - [:span {:class (stl/css :icon)} deprecated-icon/tick])]]]])) + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])]]]])) (defn animate-go-to-frame [animation current-viewport orig-viewport current-size orig-size wrapper-size] diff --git a/frontend/src/app/main/ui/viewer/interactions.scss b/frontend/src/app/main/ui/viewer/interactions.scss index b17ac08b84..669e02940f 100644 --- a/frontend/src/app/main/ui/viewer/interactions.scss +++ b/frontend/src/app/main/ui/viewer/interactions.scss @@ -4,94 +4,75 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared menu placeholders, which move to the DS with the +// menus and dropdowns. @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 *; + +.view-options-wrapper { + position: relative; +} .view-options { - @include deprecated.body-small-typography; + @include use-typography("body-small"); display: flex; align-items: center; - position: relative; - gap: deprecated.$s-4; - height: deprecated.$s-32; - border-radius: deprecated.$br-8; - background-color: var(--input-background-color); - padding: deprecated.$s-8; + border: none; + gap: var(--sp-xs); + block-size: $sz-32; + border-radius: $br-8; + background-color: var(--color-background-tertiary); + padding: var(--sp-s); cursor: pointer; } .dropdown-title { - @include deprecated.body-small-typography; + @include use-typography("body-small"); flex-grow: 1; - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } .label { flex-grow: 1; - color: var(--input-foreground-color); + color: var(--color-foreground-secondary); } .dropdown { @extend %menu-dropdown; - right: deprecated.$s-2; - top: calc(deprecated.$s-2 + deprecated.$s-48); - width: deprecated.$s-272; - padding: deprecated.$s-6; - max-height: calc(100vh - 3 * (deprecated.$s-2 + deprecated.$s-48)); + inset-inline-end: px2rem(2); + inset-block-start: px2rem(50); + inline-size: px2rem(272); + padding: px2rem(6); + max-block-size: calc(100vh - #{px2rem(150)}); overflow: auto; } .dropdown-element { @extend %dropdown-element-base; - min-height: deprecated.$s-32; - - .icon { - @include deprecated.flex-center; - - height: 100%; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } - } + min-block-size: $sz-32; &:hover .label { - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } } .dropdown-element.selected { .label { - color: var(--input-foreground-color-active); - } - - .icon svg { - stroke: var(--input-foreground-color); + color: var(--color-foreground-primary); } } .icon, .icon-dropdown { - @include deprecated.flex-center; - - height: 100%; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } -} - -.icon-dropdown svg { - transform: rotate(90deg); + flex-shrink: 0; + color: var(--color-foreground-secondary); } // breakpoint 1013px From afd838ea5b4114398c55b24bcd27f5c4939bf881 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 10:58:13 +0100 Subject: [PATCH 159/167] :recycle: Use DS buttons in the dashboard team modals (#12537) * :recycle: Use DS buttons in the dashboard team modals The close buttons of the create/rename team modal, the team permission notice and the two "before you leave" modals become ghost DS icon buttons named "Close"; the class that places them goes on the tooltip wrapper. The cancel and "promote and leave" inputs of the leave modals become DS buttons: secondary, and destructive once a member is picked (primary and disabled before). Two fixtures make the leave and reassign modal testable (the logged-in user owns the second team, which has another member). A visual test covers the create team modal and the leave and reassign modal. AI-assisted-by: claude-opus-5-5 * :recycle: Move dashboard team modal styles closer to the DS The change owner and team form stylesheets resolve their legacy color aliases, spacing and typography mixins to DS tokens and use the DS typography module without a namespace. The legacy uppercase title becomes the DS headline-small typography, which differs only in its size (12px instead of 11px): the "before you leave" title grows 1px and the modal 2px. Unused selectors are commented out with a TODO. refactor/ stays only for the modal placeholders (%modal-*), which move with the modals refactor, and the input placeholders. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the dashboard team modals The SCSS refactor of the dashboard team modals 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: .input-wrapper, .cancel-button, &.danger. No visual change. AI-assisted-by: claude-opus-5-5 --- .../dashboard/get-team-members-owner.json | 30 ++++++++++ .../dashboard/get-teams-second-owner.json | 48 +++++++++++++++ .../ui/visual-specs/visual-dashboard.spec.js | 48 +++++++++++++++ .../app/main/ui/dashboard/change_owner.cljs | 60 ++++++++++--------- .../app/main/ui/dashboard/change_owner.scss | 56 +++++++---------- .../src/app/main/ui/dashboard/team_form.cljs | 17 ++++-- .../src/app/main/ui/dashboard/team_form.scss | 27 ++++----- 7 files changed, 203 insertions(+), 83 deletions(-) create mode 100644 frontend/playwright/data/dashboard/get-team-members-owner.json create mode 100644 frontend/playwright/data/dashboard/get-teams-second-owner.json diff --git a/frontend/playwright/data/dashboard/get-team-members-owner.json b/frontend/playwright/data/dashboard/get-team-members-owner.json new file mode 100644 index 0000000000..10771dcd4d --- /dev/null +++ b/frontend/playwright/data/dashboard/get-team-members-owner.json @@ -0,0 +1,30 @@ +[ + { + "~:is-admin": false, + "~:email": "bar@example.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Han Solo", + "~:fullname": "Han Solo", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~u1e162163-87b7-805b-8005-5fd05514b6d3", + "~:profile-id": "~u1e162163-87b7-805b-8005-5fd05514b6d3", + "~:created-at": "~m1733324626956" + }, + { + "~:is-admin": true, + "~:email": "foo@example.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Princesa Leia", + "~:fullname": "Princesa Leia", + "~:is-owner": true, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b", + "~:profile-id": "~uf56647eb-19a7-8115-8003-b6bc939ecd1b", + "~:created-at": "~m1713533116365" + } +] diff --git a/frontend/playwright/data/dashboard/get-teams-second-owner.json b/frontend/playwright/data/dashboard/get-teams-second-owner.json new file mode 100644 index 0000000000..cb8eb7f114 --- /dev/null +++ b/frontend/playwright/data/dashboard/get-teams-second-owner.json @@ -0,0 +1,48 @@ +[ + { + "~: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-38e630f40f6d", + "~:created-at": "~m1713533116375", + "~:is-default": true + }, + { + "~: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": "Second team", + "~:modified-at": "~m1701164272671", + "~:id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:created-at": "~m1701164272671", + "~:is-default": false + } +] diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index ee79bdfc75..5b271306cb 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -438,3 +438,51 @@ test.describe("Subscription controls", () => { await expect(page).toHaveScreenshot(shot); }); }); + +test.describe("Team modals controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Create team modal", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + await dashboardPage.teamDropdown.click(); + await page.getByText("Create new team").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("Leave and reassign modal", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + // The logged-in user owns the second team, which has another member. + await dashboardPage.mockRPC( + "get-teams", + "dashboard/get-teams-second-owner.json", + ); + await dashboardPage.mockRPC( + "get-team-members?team-id=*", + "dashboard/get-team-members-owner.json", + ); + await dashboardPage.goToSecondTeamMembersSection(); + await expect(page.getByText("role")).toBeVisible(); + + const rows = page.locator("[class*='table-rows']"); + await rows + .locator("[class*='menu-btn']") + .or(rows.getByRole("button", { name: "Options" })) + .first() + .click(); + await rows.getByText("Leave team").click(); + + const modal = page.locator("[class*='modal-container']"); + await expect(modal).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/dashboard/change_owner.cljs b/frontend/src/app/main/ui/dashboard/change_owner.cljs index b6eb800030..e4a1cc3498 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.cljs +++ b/frontend/src/app/main/ui/dashboard/change_owner.cljs @@ -12,7 +12,9 @@ [app.common.uuid :as uuid] [app.main.data.modal :as modal] [app.main.ui.components.forms :as fm] - [app.main.ui.icons :as deprecated-icon] + [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.util.i18n :as i18n :refer [tr]] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -45,8 +47,11 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} (tr "modals.leave-and-reassign.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)} [:p {:class (stl/css :modal-msg)} @@ -62,19 +67,16 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click modal/hide!}] + [:> button* {:variant "secondary" + :type "button" + :on-click modal/hide!} + (tr "labels.cancel")] - [:input.accept-button - {:type "button" - :class (stl/css-case :accept-btn true - :danger (:valid @form) - :global/disabled (not (:valid @form))) - :disabled (not (:valid @form)) - :value (tr "modals.leave-and-reassign.promote-and-leave") - :on-click on-accept}]]]]])) + [:> button* {:variant (if (:valid @form) "destructive" "primary") + :type "button" + :disabled (not (:valid @form)) + :on-click on-accept} + (tr "modals.leave-and-reassign.promote-and-leave")]]]]])) @@ -149,8 +151,11 @@ [:div {:class (stl/css :modal-organization-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-organization-title)} (tr "modals.before-leave-organization.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)} (if (zero? num-teams-to-delete) @@ -169,16 +174,13 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click modal/hide!}] + [:> button* {:variant "secondary" + :type "button" + :on-click modal/hide!} + (tr "labels.cancel")] - [:input.accept-button - {:type "button" - :class (stl/css-case :accept-btn true - :danger all-valid? - :global/disabled (not all-valid?)) - :disabled (not all-valid?) - :value (tr "modals.leave-and-reassign.promote-and-leave") - :on-click on-accept}]]]]])) + [:> button* {:variant (if all-valid? "destructive" "primary") + :type "button" + :disabled (not all-valid?) + :on-click on-accept} + (tr "modals.leave-and-reassign.promote-and-leave")]]]]])) diff --git a/frontend/src/app/main/ui/dashboard/change_owner.scss b/frontend/src/app/main/ui/dashboard/change_owner.scss index df51c6e86a..d066c372d4 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.scss +++ b/frontend/src/app/main/ui/dashboard/change_owner.scss @@ -4,8 +4,10 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*, to move with the +// modals refactor) and the input placeholder (%input-with-label, with the inputs). @use "refactor/common-refactor.scss" as deprecated; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/z-index.scss" as *; @@ -20,48 +22,34 @@ } .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.body-small-typography; + @include use-typography("body-small"); - margin-bottom: deprecated.$s-24; -} - -.input-wrapper { - @extend %input-with-label; - @include deprecated.body-small-typography; + margin-block-end: var(--sp-xxl); } .action-buttons { @extend %modal-action-btns; } -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} - .modal-msg { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } .teams-container { @@ -75,34 +63,34 @@ display: grid; grid-template-columns: 1fr 2fr; align-items: center; - width: 100%; + inline-size: 100%; } .modal-organization-container { @extend %modal-container-base; overflow-y: auto; - max-height: $sz-512; + max-block-size: $sz-512; } .modal-organization-title { - @include t.use-typography("headline-large"); + @include use-typography("headline-large"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } .modal-organization-msg { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } .team-name { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } .team-member { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); } diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index 81803fa4a1..08f6dcaad6 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -18,7 +18,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.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -118,8 +119,11 @@ [:h2 {:class (stl/css :modal-title)} (tr "labels.create-team")]) - [: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)} [:& fm/input {:type "text" @@ -162,7 +166,10 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} 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)} [:div message]]]])) diff --git a/frontend/src/app/main/ui/dashboard/team_form.scss b/frontend/src/app/main/ui/dashboard/team_form.scss index 946057fa95..1047c8f3d8 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.scss +++ b/frontend/src/app/main/ui/dashboard/team_form.scss @@ -4,9 +4,11 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*, to move with the +// modals refactor) and the input placeholder (%input-element-label, with the inputs). @use "refactor/common-refactor.scss" as deprecated; @use "ds/_utils.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -21,20 +23,23 @@ } .modal-title { - @include t.use-typography("headline-large"); + @include use-typography("headline-large"); - 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 { margin-block-end: var(--sp-xxl); color: var(--color-foreground-secondary); - @include t.use-typography("body-large"); + @include use-typography("body-large"); } .team-form { @@ -52,24 +57,16 @@ flex-direction: column; gap: var(--sp-xs); align-items: flex-start; - width: 100%; + inline-size: 100%; border: none; background-color: transparent; - height: 100%; + block-size: 100%; } .action-buttons { @extend %modal-action-btns; } -.cancel-button { - @extend %modal-cancel-btn; -} - .accept-btn { @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } } From 33d304c0afb9507c541f8fa86963c051cdf73273 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 11:41:19 +0100 Subject: [PATCH 160/167] :recycle: Use DS icons in the dashboard sidebar (#12534) * :recycle: Use DS icons in the dashboard sidebar The search, clear search, pinned projects placeholder and logout icons of the dashboard sidebar use the DS icons, and they get their color from the color property. The two search buttons had untranslated test names as their labels ("dashboard-search"); they are now named "Search" and "Clear search". The pin keeps its place: the DS small icon draws 12px inside a 16px box, so its margins shrink by 2px. New key: dashboard.clear-search (en, es). The dashboard page object looks the search button up by its new name. A visual test covers the search box with and without a term, the pinned projects placeholder and the profile menu. AI-assisted-by: claude-opus-5-5 * :recycle: Move dashboard sidebar styles off the deprecated styles Legacy color aliases resolve to their DS tokens, deprecated mixins, sizes and font variables are inlined with DS values, physical properties become logical ones and the DS typography module is used without a namespace. An invalid max-width with two values, which the browser ignored, is gone. Two rules that match nothing are commented out with a TODO: the profile menu arrow svg rule (the arrow is the DS icon svg itself) and the upgrade plan section, which sidebar.cljs does not use. refactor/ stays only for the profile menu placeholders (%menu-dropdown, %menu-item-base) and the shared dashboard header styles (common-dashboard). No visual change. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the dashboard sidebar The SCSS refactor of the dashboard sidebar 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: .profile-dropdown-item .open-arrow svg, .upgrade-plan-section, .penpot-free, .no-limits, .power-up. No visual change. AI-assisted-by: claude-opus-5-5 --- frontend/playwright/ui/pages/DashboardPage.js | 5 +- .../ui/visual-specs/visual-dashboard.spec.js | 28 +++ .../src/app/main/ui/dashboard/sidebar.cljs | 37 ++-- .../src/app/main/ui/dashboard/sidebar.scss | 207 ++++++++---------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 6 files changed, 145 insertions(+), 140 deletions(-) diff --git a/frontend/playwright/ui/pages/DashboardPage.js b/frontend/playwright/ui/pages/DashboardPage.js index 8532455c88..8d0f7df082 100644 --- a/frontend/playwright/ui/pages/DashboardPage.js +++ b/frontend/playwright/ui/pages/DashboardPage.js @@ -82,7 +82,10 @@ export class DashboardPage extends BaseWebSocketPage { this.fontsLink = this.sidebar.getByText("Fonts"); this.librariesLink = this.sidebar.getByText("Libraries"); - this.searchButton = page.getByRole("button", { name: "dashboard-search" }); + this.searchButton = page.getByRole("button", { + name: "Search", + exact: true, + }); this.searchInput = page.getByPlaceholder("Search…"); this.teamDropdown = this.sidebar.getByRole("button", { diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 5b271306cb..320d855e64 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -486,3 +486,31 @@ test.describe("Team modals controls", () => { await expect(modal).toHaveScreenshot(shot); }); }); + +test.describe("Dashboard sidebar controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Search box, pinned placeholder and profile menu", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + const search = page.locator("[class*='sidebar-search']"); + await expect(search).toBeVisible(); + await expect(search).toHaveScreenshot(shot); + + await expect(page.getByTestId("pinned-projects")).toHaveScreenshot(shot); + + // A search term swaps the search icon for the clear button. + await page.locator("#search-input").fill("test"); + await expect(page).toHaveURL(/search/); + const clear = search.locator("button"); + await clear.hover(); + await expect(search).toHaveScreenshot(shot); + + await page.getByTestId("profile-btn").click(); + await expect(page.getByTestId("logout-profile-opt")).toBeVisible(); + await expect( + page.getByTestId("logout-profile-opt").locator("xpath=.."), + ).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index b528a5c788..d9514fb927 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -37,7 +37,6 @@ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.layout.menu :refer [context-menu*]] [app.main.ui.hooks :refer [use-focus-timer-ref]] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.nitrate.nitrate-form] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] @@ -48,18 +47,6 @@ [goog.functions :as f] [rumext.v2 :as mf])) -(def ^:private clear-search-icon - (deprecated-icon/icon-xref :delete-text (stl/css :clear-search-icon))) - -(def ^:private search-icon - (deprecated-icon/icon-xref :search (stl/css :search-icon))) - -(def ^:private pin-icon - (deprecated-icon/icon-xref :pin (stl/css :pin-icon))) - -(def ^:private exit-icon - (deprecated-icon/icon-xref :exit (stl/css :exit-icon))) - (defn schedule-focus-by-id! [ref element-id] (when-let [h (mf/ref-val ref)] @@ -101,7 +88,6 @@ (schedule-focus-by-id! focus-timer-ref (str project-id)) (st/emit! (dcm/go-to-dashboard-files :project-id project-id))))) - on-edit-open (mf/use-fn #(swap! local* assoc :edition? true)) @@ -239,15 +225,19 @@ (if (or @focused? (seq search-term)) [:button {:class (stl/css :search-btn :clear-search-btn) :tab-index "0" - :aria-label "dashboard-clear-search" + :aria-label (tr "dashboard.clear-search") :on-click on-clear-click :on-key-down handle-clear-search} - clear-search-icon] + [:> icon* {:icon-id i/delete-text + :class (stl/css :search-icon) + :aria-hidden true}]] [:button {:class (stl/css :search-btn) - :aria-label "dashboard-search" + :aria-label (tr "labels.search") :on-click on-clear-click} - search-icon])])) + [:> icon* {:icon-id i/search + :class (stl/css :search-icon) + :aria-hidden true}]])])) (mf/defc sidebar-content* {::mf/private true} @@ -364,7 +354,6 @@ :keyboard-action go-drafts-with-key} [:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]] - [:div {:class (stl/css :sidebar-content-section)} [:div {:class (stl/css :sidebar-section-title)} (tr "labels.sources")] @@ -384,7 +373,6 @@ :keyboard-action go-libs-with-key} [:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]] - [:div {:class (stl/css :sidebar-content-section) :data-testid "pinned-projects"} [:div {:class (stl/css :sidebar-section-title)} @@ -399,7 +387,9 @@ :team-id (:id team) :is-selected (= (:id item) (:id project))}])] [:div {:class (stl/css :sidebar-empty-placeholder)} - pin-icon + [:> icon* {:icon-id i/pin + :size "s" + :class (stl/css :pin-icon)}] [:span {:class (stl/css :empty-text)} (tr "dashboard.no-projects-placeholder")]])]] [:div {:class (stl/css-case :separator true :overflow-separator overflow?)}]]])) @@ -673,7 +663,6 @@ [:> dashboard-cta* {:profile profile}] [:> subscription-sidebar* {:profile profile}]))) - (when (and team profile) [:& comments-section {:profile profile @@ -704,7 +693,6 @@ [:li {:class (stl/css :profile-separator)}] - [:> dropdown-menu-item* {:class (stl/css-case :profile-dropdown-item true) :on-click on-menu-click :on-key-down (fn [event] @@ -749,7 +737,8 @@ [:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item :item-with-icon) :on-click handle-logout-click :data-testid "logout-profile-opt"} - exit-icon + [:> icon* {:icon-id i/exit + :class (stl/css :exit-icon)}] (tr "labels.logout")]] (when (and team profile) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 4bbd6d243b..424f56427b 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -4,14 +4,21 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/colors.scss" as *; @use "ds/spacing.scss" as *; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; @use "ds/z-index.scss" as *; +@use "ds/mixins.scss" as *; + +// The shared dashboard header styles move in their own change: they are +// used by ten dashboard modules. @use "common/refactor/common-dashboard"; + +// refactor/ stays for the menu placeholders (%menu-dropdown, +// %menu-item-base) of the profile menu and its submenus. @use "common/refactor/common-refactor.scss" as deprecated; // SIDEBAR COMPONENT @@ -20,19 +27,19 @@ grid-row: 1 / span 2; grid-column: 1 / span 2; grid-template-rows: auto 1fr auto; - height: 100%; - width: 100%; + block-size: 100%; + inline-size: 100%; margin: 0 var(--sp-l) 0 0; - border-right: $b-1 solid var(--panel-border-color); - background-color: var(--panel-background-color); + border-inline-end: $b-1 solid var(--color-background-quaternary); + background-color: var(--color-background-primary); } // SIDEBAR CONTENT COMPONENT .sidebar-content-wrapper { display: flex; flex-direction: column; - min-height: 0; - height: 100%; + min-block-size: 0; + block-size: 100%; overflow: hidden; } @@ -41,7 +48,7 @@ grid-template-rows: auto auto auto auto 1fr; gap: var(--sp-xxl); flex: 1; - min-height: 0; + min-block-size: 0; padding: 0; overflow: hidden auto; } @@ -52,17 +59,17 @@ } .separator { - height: var(--sp-xxs); - width: 94%; - margin-left: 3%; + block-size: var(--sp-xxs); + inline-size: 94%; + margin-inline-start: 3%; } .overflow-separator { - border-bottom: $b-1 solid var(--color-background-quaternary); + border-block-end: $b-1 solid var(--color-background-quaternary); } .sidebar-section-title { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); padding: 0 var(--sp-s) var(--sp-s) var(--sp-xxl); color: var(--color-foreground-secondary); @@ -82,31 +89,33 @@ cursor: pointer; &:hover { - background-color: var(--sidebar-element-background-color-hover); + background-color: var(--color-background-secondary); + // The item labels have no class. span { - color: var(--sidebar-element-foreground-color-hover); + color: var(--color-accent-primary); } } &.current { - background-color: var(--sidebar-element-background-color-selected); + background-color: var(--color-background-quaternary); .element-title { - color: var(--sidebar-element-foreground-color-selected); + color: var(--color-accent-primary); } } } +// The icon has no class. .recent-projects svg { - stroke: var(--main-icon-foreground); + stroke: var(--color-foreground-primary); } .sidebar-link { display: block; padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-xxl); - font-weight: deprecated.$fw400; - width: 100%; + font-weight: 400; + inline-size: 100%; &:hover { text-decoration: none; @@ -118,31 +127,33 @@ } .element-title { - @include deprecated.text-ellipsis; + @include text-ellipsis; - width: deprecated.$s-256; + inline-size: px2rem(256); color: var(--color-foreground-primary); - font-size: deprecated.$fs-14; + font-size: px2rem(14); } // Pinned projects .sidebar-empty-placeholder { padding: var(--sp-m); - color: var(--empty-message-foreground-color); + color: var(--color-foreground-secondary); display: flex; align-items: center; } +// The DS small icon draws 12px inside a 16px box: 2px less margin on each +// side keeps the drawing and the text where they were. .pin-icon { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - margin: 0 var(--sp-m); + flex-shrink: 0; + stroke-width: 1.33px; + color: var(--color-foreground-secondary); + margin: 0 px2rem(10); } .empty-text { - font-size: deprecated.$fs-12; + font-size: px2rem(12); } // Search @@ -155,55 +166,58 @@ border: $b-1 solid transparent; margin: 0 var(--sp-l); border-radius: $br-8; - background-color: var(--search-bar-input-background-color); + background-color: var(--color-background-tertiary); } .input-text { - @include t.use-typography("title-small"); + @include use-typography("title-small"); - height: $sz-40; - width: 100%; + block-size: $sz-40; + inline-size: 100%; padding: $sz-6 var(--sp-m); margin: 0; border: transparent; border-radius: $br-8; background: transparent; - color: var(--search-bar-foreground-color); + color: var(--color-foreground-primary); &:focus, &:focus-within, &:focus-visible { outline: none; - border: $b-1 solid var(--search-bar-input-border-color-focus); + border: $b-1 solid var(--color-accent-primary); } ::placeholder { - color: var(--search-bar-placeholder-foreground-color); + color: var(--color-foreground-secondary); } } .search-btn { - @include deprecated.button-style; - @include deprecated.flex-center; + border: none; + background: none; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + --sidebar-search-foreground-color: var(--color-foreground-secondary); + + color: var(--sidebar-search-foreground-color); position: absolute; - right: 0; - height: var(--sp-xxl); - width: var(--sp-xxxl); + inset-inline-end: 0; + block-size: var(--sp-xxl); + inline-size: var(--sp-xxxl); padding: 0 var(--sp-s); } -.search-icon, -.clear-search-btn { - @extend %button-icon; - - --sidebar-search-foreground-color: var(--search-bar-icon-foreground-color); - - stroke: var(--sidebar-search-foreground-color); +.search-icon { + flex-shrink: 0; + stroke-width: 1px; } .clear-search-btn:hover { - --sidebar-search-foreground-color: var(--search-bar-icon-foreground-color-hover); + --sidebar-search-foreground-color: var(--color-accent-primary); } // Profile @@ -212,33 +226,32 @@ display: grid; grid-template-columns: 1fr auto; padding: var(--sp-m) var(--sp-l); - border-top: $b-1 solid var(--panel-border-color); - background-color: var(--profile-section-background-color); + border-block-start: $b-1 solid var(--color-background-quaternary); + background-color: var(--color-background-tertiary); cursor: pointer; } .profile { - @include deprecated.button-style; - + border: none; + background: none; display: grid; grid-template-columns: auto 1fr; gap: var(--sp-s); cursor: pointer; - text-align: left; + text-align: start; } .profile-fullname { - @include t.use-typography("title-small"); - @include deprecated.text-ellipsis; + @include use-typography("title-small"); + @include text-ellipsis; align-self: center; - max-width: var(--sp-l) 0; - color: var(--profile-foreground-color); + color: var(--color-foreground-primary); } .profile-img { - height: $sz-40; - width: $sz-40; + block-size: $sz-40; + inline-size: $sz-40; border-radius: $br-circle; } @@ -247,14 +260,14 @@ inset-inline-start: var(--sp-s); inset-block-end: px2rem(72); // 72 is the height of the profile button - min-width: calc(100% - var(--sp-s)); + min-inline-size: calc(100% - var(--sp-s)); // TODO ADD animation fadeInUp } .profile-dropdown-item { @extend %menu-item-base; - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); block-size: $sz-40; margin-block-end: var(--sp-xs); @@ -262,29 +275,25 @@ padding-inline-start: var(--sp-l); &:hover { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); .open-arrow { - --icon-stroke-color: var(--menu-foreground-color-hover); + --icon-stroke-color: var(--color-foreground-primary); } } } .profile-dropdown-item .open-arrow { - @include deprecated.flex-center; -} - -.profile-dropdown-item .open-arrow svg { - @extend %button-icon; - - stroke: var(--icon-foreground); + display: flex; + justify-content: center; + align-items: center; } .sub-menu { @extend %menu-dropdown; - inset-inline-start: calc(deprecated.$s-292 + var(--sp-s)); - min-width: deprecated.$s-192; + inset-inline-start: calc(#{px2rem(292)} + var(--sp-s)); + min-inline-size: $sz-192; } // Each submenu is positioned via its bottom edge; the visual top lands @@ -293,11 +302,11 @@ // its top edge sits one row above Community — aligning with the // "Help & Learning" trigger row in the parent menu. .sub-menu.help-learning { - inset-block-end: deprecated.$s-120; + inset-block-end: $sz-120; } .sub-menu.community { - inset-block-end: deprecated.$s-120; + inset-block-end: $sz-120; } .sub-menu.about { @@ -306,7 +315,7 @@ .submenu-item { @extend %menu-item-base; - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); block-size: $sz-40; margin-block-end: var(--sp-xs); @@ -314,7 +323,7 @@ padding-inline: var(--sp-l); &:hover { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); } &.checking { @@ -334,7 +343,7 @@ } .submenu-separator { - border-top: $b-1 solid var(--color-background-quaternary); + border-block-start: $b-1 solid var(--color-background-quaternary); margin: 0; } @@ -345,8 +354,8 @@ } .menu-version { - @include t.use-typography("code-font"); - @include deprecated.text-ellipsis; + @include use-typography("code-font"); + @include text-ellipsis; color: var(--color-foreground-secondary); margin-inline-start: var(--sp-s); @@ -354,8 +363,8 @@ } .profile-separator { - height: $sz-6; - border-top: $b-1 solid var(--color-background-quaternary); + block-size: $sz-6; + border-block-start: $b-1 solid var(--color-background-quaternary); } .item-with-icon { @@ -365,38 +374,6 @@ } .exit-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); -} - -.upgrade-plan-section { - @include deprecated.button-style; - - display: flex; - justify-content: space-between; - border: $b-1 solid var(--color-background-quaternary); - border-radius: var(--sp-s); - padding: var(--sp-m); - margin: var(--sp-m); + stroke-width: 1px; color: var(--color-foreground-secondary); - cursor: pointer; -} - -.penpot-free { - @include t.use-typography("body-medium"); - - display: flex; - flex-direction: column; - text-align: left; -} - -.no-limits { - @include t.use-typography("body-small"); -} - -.power-up { - @include t.use-typography("body-small"); - - color: var(--color-accent-tertiary); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index c747726caa..57b5569932 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -356,6 +356,10 @@ msgstr "View full changelog" msgid "dashboard.check-updates.view-release-notes" msgstr "View release notes" +#: src/app/main/ui/dashboard/sidebar.cljs:228 +msgid "dashboard.clear-search" +msgstr "Clear search" + #: src/app/main/ui/dashboard/deleted.cljs:322 msgid "dashboard.clear-trash-button" msgstr "Clear trash" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index c95dafcebd..6d20e94fe9 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -362,6 +362,10 @@ msgstr "Ver changelog completo" msgid "dashboard.check-updates.view-release-notes" msgstr "Ver notas de la versión" +#: src/app/main/ui/dashboard/sidebar.cljs:228 +msgid "dashboard.clear-search" +msgstr "Borrar búsqueda" + #: src/app/main/ui/dashboard/deleted.cljs:322 msgid "dashboard.clear-trash-button" msgstr "Vaciar papelera" From 8800ff4bc9b1179650399cfa3b6c90157e4265cd Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 11:41:23 +0100 Subject: [PATCH 161/167] :recycle: Use DS buttons and icons in the assets tab (#12559) * :recycle: Use DS buttons and icons in the assets tab - "Add library" and "Manage library" become DS primary and secondary buttons, full width with the label centered; they keep their test id. - The filter button stays a raw segment of the search bar; its legacy icon becomes the DS filter icon. Its opened state comes before the focus and hover rules, as the legacy placeholder did, so the focus border still shows. - The create/rename group modal gets a ghost DS icon button named "Close", placed on the tooltip wrapper, and DS cancel (secondary) and create/rename (primary) buttons instead of raw inputs. A visual test covers the add and manage library buttons, the filter at rest, on hover and opened, and the create group modal. AI-assisted-by: claude-opus-5-5 * :recycle: Move assets tab styles off the deprecated styles assets.scss no longer imports refactor/: its mixins, sizes, spacing and input / button icon color aliases resolve to DS values and tokens, with logical properties. The unused .sections-container, .section-item and .section-btn rules are deleted (the filter menu is the shared context menu). assets/groups.scss does the same and keeps refactor/ only for the modal placeholders (%modal-*) and %input-with-label. Its legacy uppercase title becomes the DS headline-small typography, which differs only in its size (12px instead of 11px): the group modal grows 2px. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 55 +++++++ .../app/main/ui/workspace/sidebar/assets.cljs | 22 ++- .../app/main/ui/workspace/sidebar/assets.scss | 149 +++++++----------- .../ui/workspace/sidebar/assets/groups.cljs | 30 ++-- .../ui/workspace/sidebar/assets/groups.scss | 49 +++--- 5 files changed, 159 insertions(+), 146 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 91dc893850..a9a4ef2bf3 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -1106,3 +1106,58 @@ test.describe("Unpublish library modal", () => { await expect(modal).toHaveScreenshot(shot); }); }); + +test.describe("Assets tab controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Add library button", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.clickAssets(); + + const add = workspace.sidebar.getByTestId("libraries"); + await expect(add).toBeVisible(); + await page.mouse.move(640, 600); + await expect(add).toHaveScreenshot(shot); + await add.hover(); + await expect(add).toHaveScreenshot(shot); + }); + + test("Libraries, filter and group modal", async ({ page }) => { + const workspace = new WorkspacePage(page); + const { fileId, pageId } = await setupFileWithAssets(workspace); + await workspace.goToWorkspace({ fileId, pageId }); + await workspace.clickAssets(); + + const header = workspace.sidebar + .getByTestId("libraries") + .locator("xpath=.."); + await expect(workspace.sidebar.getByTestId("libraries")).toBeVisible(); + await page.mouse.move(640, 600); + await expect(header).toHaveScreenshot(shot); + + await workspace.sidebar.getByTestId("libraries").hover(); + await expect(header).toHaveScreenshot(shot); + + const filter = workspace.sidebar.getByTitle("Filter"); + await filter.hover(); + await expect(header).toHaveScreenshot(shot); + + await filter.click(); + await page.mouse.move(640, 600); + await expect(header).toHaveScreenshot(shot); + await page.keyboard.press("Escape"); + + await workspace.sidebar.getByRole("button", { name: "Colors" }).click(); + await workspace.sidebar + .locator("[class*='colors'] [class*='asset-list-item']") + .first() + .click({ button: "right" }); + await page.getByText("Group", { exact: true }).click(); + const modal = page.locator("[class*='groups__modal-container']"); + await expect(modal).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index 9650b235f3..ff5b91e7ef 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -18,8 +18,9 @@ [app.main.ui.components.context-menu-a11y :refer [context-menu*]] [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.context :as ctx] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.workspace.sidebar.assets.common :as cmm] [app.main.ui.workspace.sidebar.assets.file-library :refer [file-library*]] [app.main.ui.workspace.sidebar.scroll :as sc] @@ -201,14 +202,18 @@ [:div {:class (stl/css :assets-header)} (when-not ^boolean read-only? (if (and (= num-libs 1) (empty? components) (not shared?)) - [:button {:class (stl/css :add-library-button) - :on-click show-libraries-dialog - :data-testid "libraries"} + [:> button* {:variant "primary" + :type "button" + :class (stl/css :libraries-button) + :on-click show-libraries-dialog + :data-testid "libraries"} (tr "workspace.assets.add-library")] - [:button {:class (stl/css :libraries-button) - :on-click show-libraries-dialog - :data-testid "libraries"} + [:> button* {:variant "secondary" + :type "button" + :class (stl/css :libraries-button) + :on-click show-libraries-dialog + :data-testid "libraries"} (tr "workspace.assets.manage-library")])) @@ -221,7 +226,8 @@ :title (tr "workspace.assets.filter") :class (stl/css-case :section-button true :opened menu-open?)} - deprecated-icon/filter-icon]] + [:> icon* {:icon-id i/filter + :class (stl/css :section-icon)}]]] [:> context-menu* {:on-close on-menu-close diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.scss b/frontend/src/app/main/ui/workspace/sidebar/assets.scss index 716d0682b8..afccd3404d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.scss @@ -4,141 +4,98 @@ // // 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 *; .assets-bar { display: grid; grid-auto-rows: max-content; // TODO: ugly hack :( Fix this! we shouldn't be hardcoding this height - height: calc(100vh - deprecated.$s-92); + block-size: calc(100vh - #{px2rem(92)}); scrollbar-gutter: stable; overflow-y: auto; - padding-top: deprecated.$s-8; + padding-block-start: var(--sp-s); } +// A DS button keeps its label at the start when it grows. .libraries-button { - @extend %button-secondary; - @include deprecated.uppercase-title-typography; - - gap: deprecated.$s-2; - height: deprecated.$s-32; - width: 100%; - margin-bottom: deprecated.$s-4; - border-radius: deprecated.$s-8; - - &:hover { - background-color: var(--button-secondary-background-color-hover); - color: var(--button-secondary-foreground-color-hover); - border: deprecated.$s-1 solid var(--button-secondary-border-color-hover); - } - - &:focus { - background-color: var(--button-secondary-background-color-focus); - color: var(--button-secondary-foreground-color-focus); - border: deprecated.$s-1 solid var(--button-secondary-border-color-focus); - } -} - -.add-library-button { - @extend %button-primary; - @include deprecated.uppercase-title-typography; - - gap: deprecated.$s-2; - height: deprecated.$s-32; - width: 100%; - margin-bottom: deprecated.$s-4; - border-radius: deprecated.$s-8; + inline-size: 100%; + margin-block-end: var(--sp-xs); + justify-content: center; } .section-button { - @include deprecated.flex-center; - @include deprecated.button-style; - - height: deprecated.$s-32; - width: deprecated.$s-32; + display: flex; + justify-content: center; + align-items: center; + background: none; + cursor: pointer; + block-size: $sz-32; + inline-size: $sz-32; margin: 0; - border: deprecated.$s-1 solid var(--input-border-color-rest); - border-radius: deprecated.$br-8 deprecated.$br-2 deprecated.$br-2 deprecated.$br-8; - background-color: var(--input-background-color-rest); + border: $b-1 solid var(--color-background-tertiary); + border-radius: $br-8 px2rem(2) px2rem(2) $br-8; + background-color: var(--color-background-tertiary); - svg { - height: deprecated.$s-16; - width: deprecated.$s-16; - stroke: var(--icon-foreground); + .section-icon { + flex-shrink: 0; + stroke-width: 1px; + color: var(--color-foreground-secondary); + } + + // Before :focus and :hover, as the legacy placeholder was: the focus + // border wins over the opened state. + &.opened { + outline: none; + border-color: var(--color-background-quaternary); + background-color: var(--color-background-quaternary); + color: var(--color-accent-primary); + + .section-icon { + color: var(--color-accent-primary); + } } &:focus { - border: deprecated.$s-1 solid var(--input-border-color-focus); + border: $b-1 solid var(--color-accent-primary); outline: 0; - background-color: var(--input-background-color-focus); - color: var(--input-foreground-color-focus); + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); - svg { - background-color: var(--input-background-color-focus); + .section-icon { + background-color: var(--color-background-tertiary); } } &:hover { - border: deprecated.$s-1 solid var(--input-border-color-hover); - background-color: var(--input-background-color-hover); + border: $b-1 solid var(--color-background-quaternary); + background-color: var(--color-background-quaternary); - svg { - background-color: var(--input-background-color-hover); - stroke: var(--button-foreground-hover); + .section-icon { + background-color: var(--color-background-quaternary); + color: var(--color-accent-primary); } &:focus { - border: deprecated.$s-1 solid var(--input-border-color-focus); + border: $b-1 solid var(--color-accent-primary); outline: 0; - background-color: var(--input-background-color-focus); - color: var(--input-foreground-color-focus); + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); - svg { - background-color: var(--input-background-color-focus); + .section-icon { + background-color: var(--color-background-tertiary); } } } - - &.opened { - @extend %button-icon-selected; - } -} - -.sections-container { - @include deprecated.menu-shadow; - @include deprecated.flex-column; - - position: absolute; - top: deprecated.$s-84; - left: deprecated.$s-12; - width: deprecated.$s-192; - padding: deprecated.$s-4; - border-radius: deprecated.$br-8; - background-color: var(--menu-background-color); - z-index: deprecated.$z-index-2; -} - -.section-item { - @include deprecated.body-small-typography; - - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: deprecated.$s-6; - border-radius: deprecated.$br-8; -} - -.section-btn { - @include deprecated.button-style; } .assets-header { - padding: 0 0 deprecated.$s-24 deprecated.$s-12; + padding: 0 0 var(--sp-xxl) var(--sp-m); } .search-wrapper { display: flex; - gap: deprecated.$s-4; + gap: var(--sp-xs); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs index b072139f1e..745491c625 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs @@ -14,9 +14,9 @@ [app.main.store :as st] [app.main.ui.components.forms :as fm] [app.main.ui.components.title-bar :refer [title-bar*]] + [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.icons :as deprecated-icon] [app.main.ui.workspace.sidebar.assets.common :as cmm] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -159,8 +159,11 @@ (if create? (tr "workspace.assets.create-group") (tr "workspace.assets.rename-group"))] - [: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)} [:& fm/form {:form form :on-submit on-accept} @@ -172,16 +175,13 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input - {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click modal/hide!}] + [:> button* {:variant "secondary" + :type "button" + :on-click modal/hide!} + (tr "labels.cancel")] - [:input - {:type "button" - :class (stl/css-case :accept-btn true - :global/disabled (not (:valid @form))) - :disabled (not (:valid @form)) - :value (if create? (tr "labels.create") (tr "labels.rename")) - :on-click on-accept}]]]]])) + [:> button* {:variant "primary" + :type "button" + :disabled (not (:valid @form)) + :on-click on-accept} + (if create? (tr "labels.create") (tr "labels.rename"))]]]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss index 003db6e756..2e752f499d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss @@ -4,10 +4,14 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor, and the input placeholder (%input-with-label). @use "refactor/common-refactor.scss" as deprecated; +@use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; .group-title-wrapper { - width: 100%; + inline-size: 100%; display: flex; justify-content: space-between; @@ -30,21 +34,21 @@ } .group-title { - padding-left: deprecated.$s-4; + padding-inline-start: var(--sp-xs); } .pre-path { - margin-left: deprecated.$s-2; + margin-inline-start: var(--sp-xxs); text-transform: initial; - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); } .path { - @include deprecated.text-ellipsis; + @include text-ellipsis; - margin-left: deprecated.$s-2; + margin-inline-start: var(--sp-xxs); text-transform: initial; - color: var(--title-foreground-color-hover); + color: var(--color-foreground-primary); } .modal-overlay { @@ -56,44 +60,35 @@ } .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.body-small-typography; + @include use-typography("body-small"); - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .input-wrapper { @extend %input-with-label; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - margin-bottom: deprecated.$s-8; + margin-block-end: var(--sp-s); } .action-buttons { @extend %modal-action-btns; } - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} From 112e6dedd7b10e5818d31f7ce6302f85d9168e26 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 11:41:36 +0100 Subject: [PATCH 162/167] :recycle: Use DS icons in the copy button (#12558) * :recycle: Use DS icons in the copy button The shared copy button (Inspect attribute rows and code blocks, share link, integrations) draws its clipboard and tick icons with the DS icons at the small size, colored through the color property. The button itself stays: it wraps whole rows. The icons are the same drawings, so nothing changes on screen. A visual test covers an Inspect computed attribute row and the code block copy button, at rest, on hover and after copying. AI-assisted-by: claude-opus-5-5 * :recycle: Move copy button styles off the deprecated styles The copy button stylesheet no longer imports refactor/: the button-style, flex-center and copy-wrapper-base mixins are inlined with DS values, the tertiary button and icon color aliases resolve to their DS tokens, and physical properties become logical ones. No visual change (whole viewer visual spec compared; the code tab syntax highlighting loads late on a cold first run, with either stylesheet). AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 47 ++++++++ .../app/main/ui/components/copy_button.cljs | 8 +- .../app/main/ui/components/copy_button.scss | 102 +++++++++--------- 3 files changed, 105 insertions(+), 52 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 80ed3f7d52..5dac8b7377 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -395,3 +395,50 @@ test.describe("Interactions menu", () => { }); }); }); + +test.describe("Copy buttons", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + const openInspectTab = async (page, name) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithComments(); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + await viewerPage.showCode(); + await page.getByRole("combobox").filter({ hasText: "Styles" }).click(); + await page.getByRole("option", { name }).click(); + }; + + test("Computed attribute rows", async ({ page }) => { + await openInspectTab(page, "Computed"); + const row = page.locator("[class*='copy-wrapper']").first(); + await expect(row).toBeVisible(); + await page.mouse.move(10, 700); + await expect(row).toHaveScreenshot(shot); + + await row.hover(); + await expect(row).toHaveScreenshot(shot); + + await row.click(); + await expect(row).toHaveAttribute("data-active", "true"); + await expect(row).toHaveScreenshot(shot); + }); + + test("Code block copy button", async ({ page }) => { + await openInspectTab(page, "Code"); + const copy = page.locator("[class*='css-copy-btn']"); + await expect(copy).toBeVisible(); + await page.mouse.move(10, 700); + await expect(copy).toHaveScreenshot(shot); + + await copy.hover(); + await expect(copy).toHaveScreenshot(shot); + + await copy.click(); + await expect(copy).toHaveAttribute("data-active", "true"); + await expect(copy).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/components/copy_button.cljs b/frontend/src/app/main/ui/components/copy_button.cljs index b3d5c0f1f6..a4db5ddc65 100644 --- a/frontend/src/app/main/ui/components/copy_button.cljs +++ b/frontend/src/app/main/ui/components/copy_button.cljs @@ -9,7 +9,7 @@ (:require [app.common.data.macros :as dm] [app.main.data.event :as-alias ev] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -43,6 +43,6 @@ :on-click on-click} children [:span {:class (stl/css :icon-btn)} - (if active? - deprecated-icon/tick - deprecated-icon/clipboard)]])) + [:> icon* {:icon-id (if active? i/tick i/clipboard) + :size "s" + :class (stl/css :icon)}]]])) diff --git a/frontend/src/app/main/ui/components/copy_button.scss b/frontend/src/app/main/ui/components/copy_button.scss index 3173698c92..8ae7cbf664 100644 --- a/frontend/src/app/main/ui/components/copy_button.scss +++ b/frontend/src/app/main/ui/components/copy_button.scss @@ -4,89 +4,95 @@ // // 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 *; .copy-button { - @include deprecated.button-style; - - width: 100%; - height: deprecated.$s-32; - border: deprecated.$s-1 solid transparent; - border-radius: deprecated.$br-8; + border: $b-1 solid transparent; + background: none; + cursor: pointer; + inline-size: 100%; + block-size: $sz-32; + border-radius: $br-8; background-color: transparent; box-sizing: border-box; .icon-btn { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-32; + min-inline-size: $sz-28; + inline-size: $sz-28; - height: deprecated.$s-32; - min-width: deprecated.$s-28; - width: deprecated.$s-28; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); + .icon { + flex-shrink: 0; + stroke-width: 1.33px; + color: var(--color-foreground-secondary); } } &:hover { background-color: var(--color-background-tertiary); color: var(--color-foreground-primary); - border: deprecated.$s-1 solid var(--color-background-tertiary); + border: $b-1 solid var(--color-background-tertiary); - .icon-btn { - svg { - stroke: var(--button-tertiary-foreground-color-active); - } + .icon-btn .icon { + color: var(--color-accent-primary); } } &:focus, &:focus-visible { outline: none; - border: deprecated.$s-1 solid var(--button-tertiary-border-color-focus); + border: $b-1 solid var(--color-accent-primary); background-color: transparent; - color: var(--button-tertiary-foreground-color-focus); + color: var(--color-foreground-primary); - .icon-btn svg { - stroke: var(--button-tertiary-foreground-color-active); + .icon-btn .icon { + color: var(--color-accent-primary); } } } .copy-wrapper { - @include deprecated.button-style; - @include deprecated.copy-wrapper-base; - - width: 100%; - height: fit-content; - text-align: left; - border: deprecated.$s-1 solid transparent; + position: relative; + min-block-size: $sz-32; + inline-size: 100%; + block-size: fit-content; + padding: px2rem(7) 0 px2rem(7) px2rem(7); + border: $b-1 solid transparent; + border-radius: $br-8; + background: none; + box-sizing: border-box; + cursor: pointer; + text-align: start; .icon-btn { - @include deprecated.flex-center; - + display: flex; + justify-content: center; + align-items: center; position: absolute; - top: 0; - right: 0; - height: deprecated.$s-32; - width: deprecated.$s-28; + inset-block-start: 0; + inset-inline-end: 0; + block-size: $sz-32; + inline-size: $sz-28; - svg { - @extend %button-icon-small; - - stroke: var(--button-tertiary-foreground-color-focus); + .icon { + flex-shrink: 0; + stroke-width: 1.33px; + color: var(--color-foreground-primary); display: none; } } &:hover { - background-color: var(--button-tertiary-background-color-focus); - color: var(--button-tertiary-foreground-color-focus); - border: deprecated.$s-1 solid var(--button-tertiary-background-color-focus); + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); + border: $b-1 solid var(--color-background-tertiary); - .icon-btn svg { + .icon-btn .icon { display: flex; } } @@ -94,8 +100,8 @@ &:focus, &:focus-visible { outline: none; - border: deprecated.$s-1 solid var(--button-tertiary-border-color-focus); + border: $b-1 solid var(--color-accent-primary); background-color: transparent; - color: var(--button-tertiary-foreground-color-focus); + color: var(--color-foreground-primary); } } From deb577124806303e6bf6fa05c5b2b920b628a159 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 27 Oct 2026 12:05:30 +0100 Subject: [PATCH 163/167] :sparkles: Use WASM for SVG markup in code inspect and copy (#12567) (#12608) When render-wasm is active, generate SVG for the Code inspector and Copy as SVG from the WASM export path instead of classic SVG codegen. Add multi-root render_shapes_svg so multi-select stays one document. Closes #12567 --- .../app/main/data/workspace/clipboard.cljs | 6 +- frontend/src/app/main/ui/inspect/code.cljs | 11 ++- frontend/src/app/render_wasm/api.cljs | 25 ++++++ .../app/util/code_gen/markup_svg_wasm.cljs | 56 ++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + .../util/code_gen_markup_svg_wasm_test.cljs | 67 ++++++++++++++ render-wasm/src/main.rs | 22 +++++ render-wasm/src/render/svg/fixtures.rs | 14 ++- render-wasm/src/render/svg/mod.rs | 89 ++++++++++++++++++- render-wasm/src/render/svg/tests.rs | 69 ++++++++++++++ render-wasm/src/state.rs | 4 + 11 files changed, 357 insertions(+), 8 deletions(-) create mode 100644 frontend/src/app/util/code_gen/markup_svg_wasm.cljs create mode 100644 frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 37f7ef80b4..877e535f75 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -56,6 +56,7 @@ [app.render-wasm.text-paste :as text-paste] [app.util.clipboard :as clipboard] [app.util.code-gen.markup-svg :as svg] + [app.util.code-gen.markup-svg-wasm :as svg.wasm] [app.util.code-gen.style-css :as css] [app.util.globals :as ug] [app.util.http :as http] @@ -392,7 +393,10 @@ (gsh/translate-to-frame % (get objects parent-frame-id))) shapes (mapv maybe-translate selected) - svg-formatted (svg/generate-formatted-markup objects shapes)] + ;; With render-wasm active, never call classic SVG codegen. + svg-formatted (if (features/active-feature? state "render-wasm/v1") + (svg.wasm/get-formatted-svg-markup (mapv :id shapes)) + (svg/generate-formatted-markup objects shapes))] (-> (clipboard/to-clipboard-multi {"image/svg+xml" svg-formatted "text/plain" svg-formatted}) diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index b96759932a..d7a170a67e 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -14,6 +14,7 @@ [app.common.types.shape-tree :as ctst] [app.config :as cfg] [app.main.data.event :as ev] + [app.main.features :as features] [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] @@ -28,6 +29,7 @@ [app.util.clipboard :as clipboard] [app.util.code-beautify :as cb] [app.util.code-gen :as cg] + [app.util.code-gen.markup-svg-wasm :as svg.wasm] [app.util.dom :as dom] [app.util.http :as http] [app.util.i18n :refer [tr]] @@ -116,6 +118,9 @@ collapsed-css? (contains? @collapsed* :css) collapsed-markup? (contains? @collapsed* :markup) + ;; With render-wasm active, never use classic SVG codegen for SVG markup. + wasm-svg-markup? (features/use-feature "render-wasm/v1") + objects (use-objects from) shapes @@ -151,9 +156,11 @@ markup-code (mf/use-memo - (mf/deps markup-type shapes images-data) + (mf/deps markup-type shapes images-data wasm-svg-markup?) (fn [] - (cg/generate-formatted-markup-code objects markup-type shapes))) + (if (and (= markup-type "svg") wasm-svg-markup?) + (svg.wasm/get-formatted-svg-markup (mapv :id shapes)) + (cg/generate-formatted-markup-code objects markup-type shapes)))) on-markup-copied (mf/use-fn diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f5af3275dd..8a4ad7d427 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -3073,6 +3073,31 @@ (mem/free) result))) +(defn render-shapes-svg + "Renders the given shape ids into one SVG document (joined viewBox). + IDs are written to the transfer buffer (same layout as `render-shapes-pdf`)." + [shape-ids scale] + (when (and (initialized?) (seq shape-ids)) + (let [ids (vec shape-ids) + size (mem/get-alloc-size ids UUID-U8-SIZE) + heap (mem/get-heap-u32) + offset (mem/alloc->offset-32 size) + + _ (reduce (fn [offset id] + (mem.h32/write-uuid offset heap id)) + offset + ids) + + result-offset + (h/call wasm/internal-module "_render_shapes_svg" scale) + + heapu8 (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 result-offset)) + result (dr/read-image-bytes heapu8 (+ result-offset 4) length)] + (mem/free) + result))) + (defn init-wasm-module [module] (let [default-fn (unchecked-get module "default") diff --git a/frontend/src/app/util/code_gen/markup_svg_wasm.cljs b/frontend/src/app/util/code_gen/markup_svg_wasm.cljs new file mode 100644 index 0000000000..4342ced0b6 --- /dev/null +++ b/frontend/src/app/util/code_gen/markup_svg_wasm.cljs @@ -0,0 +1,56 @@ +;; 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.util.code-gen.markup-svg-wasm + "SVG markup via the render-wasm export path (inspect / copy-as-svg). + Callers must gate on `render-wasm/v1` and must not fall back to classic + SVG codegen while that feature is on." + (:require + [app.render-wasm.api :as wasm.api] + [app.util.code-beautify :as cb] + [cuerdas.core :as str])) + +(defn- decode-utf8 + [bytes] + (when bytes + (.decode (js/TextDecoder.) bytes))) + +(defn get-svg-markup + "UTF-8 SVG string for the given shape ids via the main-thread WASM module. + Empty string when WASM is not ready, ids are empty, or export throws." + [shape-ids] + (let [ids (vec shape-ids)] + (cond + (empty? ids) + "" + + (not (wasm.api/initialized?)) + "" + + :else + (try + (or (decode-utf8 + (if (= 1 (count ids)) + (wasm.api/render-shape-svg (first ids) 1) + (wasm.api/render-shapes-svg ids 1))) + "") + ;; Missing shape / export failure is a WASM CriticalError (:panic). + ;; Letting it reach the global handler would show Internal Error and + ;; block the workspace; the user can still work without this markup, so + ;; log and return empty instead (same pattern as other non-fatal WASM UI paths). + (catch :default cause + (js/console.error "WASM SVG markup failed:" cause) + ""))))) + +(defn get-formatted-svg-markup + "Beautified SVG markup for inspect / copy-as-svg. + Skips beautify when markup is empty so inspect/copy stay safe without + js-beautify (and avoid a no-op format call)." + [shape-ids] + (let [markup (get-svg-markup shape-ids)] + (if (str/empty? markup) + "" + (cb/format-code markup "svg")))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index f2585704e8..5e18876e88 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -151,6 +151,7 @@ [frontend-tests.util-text-editor-test] [frontend-tests.util-webapi-test] [frontend-tests.util-zip-test] + [frontend-tests.util.code-gen-markup-svg-wasm-test] [frontend-tests.util.dom.dnd-test] [frontend-tests.worker-snap-test] [frontend-tests.worker.exports-test] @@ -315,6 +316,7 @@ 'frontend-tests.util-text-clipboard-test 'frontend-tests.util-text-editor-test 'frontend-tests.util-webapi-test + 'frontend-tests.util.code-gen-markup-svg-wasm-test 'frontend-tests.util.dom.dnd-test 'frontend-tests.util-zip-test 'frontend-tests.worker-snap-test]) diff --git a/frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs b/frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs new file mode 100644 index 0000000000..62df62eb59 --- /dev/null +++ b/frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs @@ -0,0 +1,67 @@ +;; 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.code-gen-markup-svg-wasm-test + "Unit tests for WASM SVG markup helpers used by Code inspect and Copy as SVG." + (:require + [app.common.uuid :as uuid] + [app.render-wasm.api :as wasm.api] + [app.util.code-gen.markup-svg-wasm :as svg.wasm] + [cljs.test :as t :include-macros true])) + +(defn- utf8-bytes + [s] + (.encode (js/TextEncoder.) s)) + +(t/deftest get-svg-markup-empty-ids + (t/is (= "" (svg.wasm/get-svg-markup [])))) + +(t/deftest get-svg-markup-not-initialized + (with-redefs [wasm.api/initialized? (constantly false)] + (t/is (= "" (svg.wasm/get-svg-markup [(uuid/next)]))))) + +(t/deftest get-svg-markup-single-shape + (let [id (uuid/next) + called (atom nil)] + (with-redefs [wasm.api/initialized? (constantly true) + wasm.api/render-shape-svg + (fn [shape-id scale] + (reset! called [shape-id scale]) + (utf8-bytes ""))] + (t/is (= "" (svg.wasm/get-svg-markup [id]))) + (t/is (= [id 1] @called))))) + +(t/deftest get-svg-markup-multi-shapes-one-document + (let [ids [(uuid/next) (uuid/next)] + called (atom nil)] + (with-redefs [wasm.api/initialized? (constantly true) + wasm.api/render-shapes-svg + (fn [shape-ids scale] + (reset! called [shape-ids scale]) + (utf8-bytes ""))] + (t/is (= "" (svg.wasm/get-svg-markup ids))) + (t/is (= [ids 1] @called)) + (t/is (= 1 (count (re-seq #" Result<*mut u8> { + let bytes = mem::bytes(); + let ids: Vec = bytes + .chunks(size_of::<::BytesType>()) + .map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string()))) + .collect::>>()?; + + with_state!(state, { + let data = state.render_shapes_svg(&ids, scale)?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + /// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]` /// (LE), same layout as `render_shape_pixels`. `format` selects the encoder: /// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`). diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index a33d5d258e..a112752b6b 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -15,7 +15,7 @@ use crate::state::ShapesPool; use crate::utils::uuid_from_u32_quartet; use crate::uuid::Uuid; -use super::render_tree_to_svg; +use super::{render_tree_to_svg, render_trees_to_svg}; /// Font URL referenced in exported SVG `@font-face` rules. pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf"; @@ -607,6 +607,18 @@ pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String { render_with(pool, root, |_resources| {}) } +/// Multi-root SVG export for tests (one document, joined viewBox). +pub(super) fn render_multi(pool: &ShapesPool, roots: &[Uuid]) -> String { + let _serial = svg_export_test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + register_test_font_urls(&mut resources.fonts); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let bytes = render_trees_to_svg(&mut resources, roots, pool, 1.0).expect("svg multi export"); + String::from_utf8(bytes).expect("utf8 svg") +} + /// SVG export installs a process-wide resources pointer; serialize tests that /// call this so parallel rustc threads do not race / SIGSEGV. fn svg_export_test_lock() -> &'static Mutex<()> { diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index ae4fdd3c08..997a68e71c 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -104,6 +104,88 @@ pub(crate) fn render_tree_to_svg( collect_font_aliases(tree, id, &mut aliases); let font_css = shared.fonts.font_face_css_for_aliases(&aliases); + Ok(emit_svg_document(page_w, page_h, &font_css, &defs, &body)) +} + +/// Multi-root SVG export: one document whose viewBox covers every visible root, +/// with relative placement preserved (inspect / copy-as-svg multi-select). +pub fn render_to_svg_multi( + shared: &mut RenderResources, + ids: &[Uuid], + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + render_trees_to_svg(shared, ids, tree, scale) +} + +/// Core multi-root SVG export for headless tests and the WASM bridge. +pub(crate) fn render_trees_to_svg( + shared: &mut RenderResources, + ids: &[Uuid], + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + if ids.is_empty() { + return Ok(emit_svg_document(0.0, 0.0, "", "", "")); + } + + let mut joined: Option = None; + let mut roots: Vec = Vec::with_capacity(ids.len()); + for id in ids { + let Some(shape) = tree.get(id) else { + continue; + }; + if shape.hidden { + continue; + } + let bounds = svg_page_bounds(shape, tree, scale); + match &mut joined { + None => joined = Some(bounds), + Some(acc) => { + acc.join(bounds); + } + } + roots.push(*id); + } + + // Missing or hidden-only roots: empty document (same as empty `ids`), not + // CriticalError — inspect/copy must not crash the host when nothing is drawable. + let Some(bounds) = joined else { + return Ok(emit_svg_document(0.0, 0.0, "", "", "")); + }; + + let page_w = bounds.width() * scale; + let page_h = bounds.height() * scale; + let rect = skia::Rect::from_xywh(0., 0., page_w, page_h); + let tx = -bounds.left(); + let ty = -bounds.top(); + + let mut builder = SvgLayerCanvas::new(scale, rect, tx, ty); + for id in &roots { + if let Some(shape) = tree.get(id) { + builder.nested_fills.push(shape.inherited_fills(tree)); + } + render_tree(&mut builder, shared, id, tree, scale)?; + builder.nested_fills.pop(); + } + builder.flush(); + + let mut aliases = HashSet::new(); + for id in &roots { + collect_font_aliases(tree, id, &mut aliases); + } + let font_css = shared.fonts.font_face_css_for_aliases(&aliases); + + Ok(emit_svg_document( + page_w, + page_h, + &font_css, + &builder.defs, + &builder.out, + )) +} + +fn emit_svg_document(page_w: f32, page_h: f32, font_css: &str, defs: &str, body: &str) -> Vec { let mut out = String::with_capacity(body.len() + defs.len() + font_css.len() + 256); out.push_str("\n"); out.push_str(&format!( @@ -118,14 +200,13 @@ pub(crate) fn render_tree_to_svg( "" )); } - out.push_str(&defs); + out.push_str(defs); out.push_str(""); } - out.push_str(&body); + out.push_str(body); out.push_str(""); - - Ok(out.into_bytes()) + out.into_bytes() } mod background_blur; diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 87eeb29fb4..490d5ef27a 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -58,6 +58,75 @@ fn exports_a_solid_rect() { insta::assert_snapshot!(render(&pool, id)); } +#[test] +fn exports_multiple_roots_in_one_svg_with_relative_placement() { + let mut pool = ShapesPool::new(); + let left = uid(1); + let right = uid(2); + add_solid_rect( + &mut pool, + left, + Uuid::nil(), + (0.0, 0.0, 50.0, 50.0), + skia::Color::from_rgb(255, 0, 0), + ); + add_solid_rect( + &mut pool, + right, + Uuid::nil(), + (100.0, 0.0, 150.0, 50.0), + skia::Color::from_rgb(0, 0, 255), + ); + + let svg = render_multi(&pool, &[left, right]); + assert_eq!( + svg.matches(": {svg}" + ); + assert_eq!( + svg.matches("").count(), + 1, + "multi-root export must emit one : {svg}" + ); + // Joined page is 150×50; both fills present and the right rect keeps x=100. + assert!( + svg.contains("viewBox=\"0 0 150 50\""), + "joined viewBox should span both rects: {svg}" + ); + assert!(svg.contains("fill=\"red\""), "missing left red fill: {svg}"); + assert!( + svg.contains("fill=\"blue\"") && svg.contains("x=\"100\""), + "missing right blue fill at x=100: {svg}" + ); +} + +#[test] +fn exports_multi_root_all_hidden_or_missing_as_empty_svg() { + let mut pool = ShapesPool::new(); + let hidden = uid(1); + add_solid_rect( + &mut pool, + hidden, + Uuid::nil(), + (0.0, 0.0, 50.0, 50.0), + skia::Color::from_rgb(255, 0, 0), + ); + pool.get_mut(&hidden).unwrap().set_hidden(true); + + let missing = uid(99); + let svg = render_multi(&pool, &[hidden, missing]); + assert_eq!(svg.matches(" Result> { + crate::render::svg::render_to_svg_multi(get_resources(), ids, &self.shapes, scale) + } + /// GPU-free counterpart of [`State::render_shape_pixels`]: encodes to /// `format` on a CPU raster surface, no GPU/WebGL. pub fn render_shape_raster( From 29e79edc667417bd92489302aeeb29dbe3b58707 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 12:19:47 +0100 Subject: [PATCH 164/167] :recycle: Use DS buttons and icons in the dashboard team pages (#12531) * :recycle: Use DS buttons and icons in the dashboard team pages The team pages (members, invitations, webhooks and settings) and their modals move off the deprecated icons and raw buttons: - The member and webhook row menus become ghost DS icon buttons named "Options", with aria-expanded. - The member and invitation role selectors become buttons with aria-haspopup and aria-expanded, and use the DS arrow icon. They were divs; one carried a role and an id repeated on every row. - The four modal close buttons become ghost DS icon buttons named "Close"; the class that places them goes on the tooltip wrapper. - "Create webhook" becomes a DS primary button. - The webhook status, team image and settings icons use the DS icons. The invitation checkbox tick stays until the checkbox migration. A visual test covers the role and row menus, the resend and webhook modals and the team image overlay. AI-assisted-by: claude-opus-5-5 * :recycle: Move dashboard team styles to logical properties and DS tokens The remaining legacy color aliases resolve to their DS tokens, physical offsets and margins become logical properties, and the DS typography module is used without a namespace. Four selectors that team.cljs does not use are commented out with a TODO. refactor/ stays only for component-family placeholders: %input-checkbox and %checkbox-icon (checkboxes) and %modal-* (modals). The shared dashboard header styles (common-dashboard) are used by ten dashboard modules and move in their own change. No visual change. AI-assisted-by: claude-opus-5-5 * :bug: Open the team row menus below their button The member and webhook row menus were placed from the bottom of their cell with a fixed offset, so their position depended on the menu height and the button height. The two-item webhook menu covered its button, the member menu covered the lower part of it, and the taller DS icon button moved both 3px down. They now open 4px below the button. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the dashboard team pages The SCSS refactor left four rules commented out with a TODO because team.cljs does not use them. CSS modules scope the classes to this module and no other source uses them, so they go: .dashboard-top-cta, .error-notification, .empty-invitations-buttons and .invitations-dropdown. No visual change. AI-assisted-by: claude-opus-5-5 * :bug: Find the member role selector as a button in the dashboard test The "Bug 9443" test looked for the member role selector as a combobox. The selector is now a button with aria-haspopup, so the test failed in CI. It now looks for a button named "Admin", and checks that the owner has no such button. AI-assisted-by: claude-opus-5-5 --- .../playwright/ui/specs/dashboard.spec.js | 4 +- .../ui/visual-specs/visual-dashboard.spec.js | 87 ++++++++ frontend/src/app/main/ui/dashboard/team.cljs | 126 +++++------ frontend/src/app/main/ui/dashboard/team.scss | 203 +++++++----------- 4 files changed, 232 insertions(+), 188 deletions(-) diff --git a/frontend/playwright/ui/specs/dashboard.spec.js b/frontend/playwright/ui/specs/dashboard.spec.js index 044ed5226a..d20b90cfd4 100644 --- a/frontend/playwright/ui/specs/dashboard.spec.js +++ b/frontend/playwright/ui/specs/dashboard.spec.js @@ -131,9 +131,9 @@ test("Bug 9443, Admin can not demote owner", async ({ page }) => { await dashboardPage.goToSecondTeamMembersSection(); await expect(page.getByRole("heading", { name: "Members" })).toBeVisible(); - await expect(page.getByRole("combobox", { name: "Admin" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Admin" })).toBeVisible(); await expect(page.getByText("Owner")).toBeVisible(); - await expect(page.getByRole("combobox", { name: "Owner" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Owner" })).toHaveCount(0); }); test("Bug 9927, Don't show the banner to invite team members if the user has dismissed it", async ({ diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 320d855e64..17d3ba0faf 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -514,3 +514,90 @@ test.describe("Dashboard sidebar controls", () => { ).toHaveScreenshot(shot); }); }); + +test.describe("Team pages controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + // The row menu button: a raw button with the menu-btn class before the DS + // migration, a DS icon button named "Options" after it. + const openRowMenu = (page) => { + const rows = page.locator("[class*='table-rows']"); + return rows + .locator("[class*='menu-btn']") + .or(rows.getByRole("button", { name: "Options" })) + .first() + .click(); + }; + + test("Members role and actions menus", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + // Han Solo owns the team; the logged-in user is an admin. + await dashboardPage.mockRPC( + "get-team-members?team-id=*", + "dashboard/get-team-members-admin.json", + ); + await dashboardPage.goToSecondTeamMembersSection(); + await expect(page.getByText("role")).toBeVisible(); + + await page.locator("[class*='has-priv']").first().click(); + await expect(page.locator("[class*='roles-dropdown']")).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + + await page.keyboard.press("Escape"); + await page.mouse.click(10, 700); + await openRowMenu(page); + await expect(page.locator("[class*='actions-dropdown']")).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + }); + + test("Invitations role menu and resend modal", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.setupTeamInvitations(); + await dashboardPage.goToSecondTeamInvitationsSection(); + await expect(page.getByText("test1@mail.com")).toBeVisible(); + + await page.locator("[class*='has-priv']").first().click(); + await expect(page.locator("[class*='roles-dropdown']")).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + + await page.mouse.click(10, 700); + await page.getByText("test1@mail.com").click(); + await page.getByRole("button", { name: "Resend invitation" }).click(); + await expect(page.getByText("Resend invitations")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(page).toHaveScreenshot(shot); + }); + + test("Webhooks actions menu and modal", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.setupTeamWebhooks(); + await dashboardPage.goToSecondTeamWebhooksSection(); + await expect(page.getByText("https://www.google.com")).toBeVisible(); + + await openRowMenu(page); + await expect( + page.locator("[class*='webhook-actions-dropdown']"), + ).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + + await page.mouse.click(10, 700); + await page.getByText("Create webhook").click(); + await expect(page.getByText("Payload URL")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(page).toHaveScreenshot(shot); + }); + + test("Team settings image overlay", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.setupTeamSettings(); + await dashboardPage.goToSecondTeamSettingsSection(); + await expect(page.getByText("TEAM INFO")).toBeVisible(); + + await page.locator("[class*='team-icon']").hover(); + await expect(page.locator("[class*='team-icon']")).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index b84ed95f0b..8888c6d4e3 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -52,33 +52,6 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(def ^:private arrow-icon - (deprecated-icon/icon-xref :arrow (stl/css :arrow-icon))) - -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - -(def ^:private organization-menu-icon - (deprecated-icon/icon-xref :menu (stl/css :organization-menu-icon))) - -(def ^:private warning-icon - (deprecated-icon/icon-xref :msg-warning (stl/css :warning-icon))) - -(def ^:private success-icon - (deprecated-icon/icon-xref :msg-success (stl/css :success-icon))) - -(def ^:private image-icon - (deprecated-icon/icon-xref :img (stl/css :image-icon))) - -(def ^:private user-icon - (deprecated-icon/icon-xref :user (stl/css :user-icon))) - -(def ^:private document-icon - (deprecated-icon/icon-xref :document (stl/css :document-icon))) - -(def ^:private group-icon - (deprecated-icon/icon-xref :group (stl/css :group-icon))) - (mf/defc header* {::mf/wrap [mf/memo]} [{:keys [section team profile]}] @@ -323,8 +296,11 @@ [:div {:class (stl/css :modal-restricted-header)} [:h2 {:class (stl/css :modal-restricted-title)} (tr "modals.invite-restricted-members.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-restricted-content)} [:p (tr "modals.invite-restricted-members.description")] @@ -409,13 +385,14 @@ on-hide (mf/use-fn #(reset! show? false))] [:* (if (and can-change-rol not-superior (not (and is-you is-owner))) - [:div {:class (stl/css :rol-selector :has-priv) - :role "combobox" - :aria-labelledby "role-label-id" - :on-click on-show} - [:span {:class (stl/css :rol-label) - :id "role-label-id"} role] - arrow-icon] + [:button {:class (stl/css :rol-selector :has-priv) + :type "button" + :aria-haspopup "listbox" + :aria-expanded @show? + :on-click on-show} + [:span {:class (stl/css :rol-label)} role] + [:> icon* {:icon-id i/arrow-down + :class (stl/css :arrow-icon)}]] [:div {:class (stl/css :rol-selector)} [:span {:class (stl/css :rol-label)} role]]) @@ -451,9 +428,12 @@ (when (or is-you? (and can-delete? (not (and is-owner? (not owner?))))) [:* - [:button {:class (stl/css :menu-btn) - :on-click on-show} - menu-icon] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.options") + :aria-haspopup "menu" + :aria-expanded @show? + :icon i/menu + :on-click on-show}] [:& dropdown {:show @show? :on-close on-hide :dropdown-id (str "member-actions-" (:id member))} [:ul {:class (stl/css :actions-dropdown)} @@ -691,10 +671,14 @@ [:* (if (and can-invite (= status :pending)) - [:div {:class (stl/css :rol-selector :has-priv) - :on-click on-show} + [:button {:class (stl/css :rol-selector :has-priv) + :type "button" + :aria-haspopup "listbox" + :aria-expanded @show? + :on-click on-show} [:span {:class (stl/css :rol-label)} label] - arrow-icon] + [:> icon* {:icon-id i/arrow-down + :class (stl/css :arrow-icon)}]] [:div {:class (stl/css :rol-selector)} [:span {:class (stl/css :rol-label)} label]]) @@ -872,8 +856,11 @@ (tr "dashboard.invitation-modal.title.delete-invitations") (tr "dashboard.invitation-modal.title.resend-invitations"))] - [: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-invitation-content)} [:p @@ -1000,8 +987,11 @@ [:h2 {:class (stl/css :modal-select-organization-title)} 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!}]] (when text [:div {:class (stl/css :modal-content :modal-select-organization-text)} text]) @@ -1380,8 +1370,11 @@ (tr "modals.edit-webhook.title") (tr "modals.create-webhook.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)} [:div {:class (stl/css :fields-row)} @@ -1422,8 +1415,10 @@ (tr "labels.webhooks")] [:> i18n/tr-html* {:class (stl/css :hero-desc) :content (tr "dashboard.webhooks.description")}] - [:button {:class (stl/css :hero-btn) - :on-click #(st/emit! (modal/show :webhook {}))} + [:> button* {:variant "primary" + :type "button" + :class (stl/css :hero-btn) + :on-click #(st/emit! (modal/show :webhook {}))} (tr "dashboard.webhooks.create")]]) (mf/defc webhook-actions* @@ -1434,9 +1429,12 @@ on-hide (mf/use-fn #(reset! show? false))] (if can-edit [:* - [:button {:class (stl/css :menu-btn) - :on-click on-show} - menu-icon] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.options") + :aria-haspopup "menu" + :aria-expanded @show? + :icon i/menu + :on-click on-show}] [:& dropdown {:show @show? :on-close on-hide :dropdown-id "webhook-actions"} [:ul {:class (stl/css :webhook-actions-dropdown)} [:li {:on-click on-edit @@ -1502,8 +1500,10 @@ [:div {:class (stl/css :table-field :last-delivery) :title last-delivery-text} (if (nil? error-code) - success-icon - warning-icon)] + [:> icon* {:icon-id i/msg-success + :class (stl/css :success-icon)}] + [:> icon* {:icon-id i/msg-warning + :class (stl/css :warning-icon)}])] [:div {:class (stl/css :table-field :uri)} [:div (dm/str (:uri webhook))]] [:div {:class (stl/css :table-field :active)} @@ -1657,7 +1657,9 @@ (when can-edit [:button {:class (stl/css :update-overlay) :on-click on-image-click} - image-icon]) + [:> icon* {:icon-id i/img + :size "l" + :class (stl/css :image-icon)}]]) [:img {:class (stl/css :team-image) :src (cfg/resolve-team-photo-url team)}] (when can-edit @@ -1688,7 +1690,8 @@ :type "button" :class (stl/css-case :organization-options-btn (not show-organization-options-menu?) :organization-options-btn-open show-organization-options-menu?) :on-click on-show-options-click} - organization-menu-icon + [:> icon* {:icon-id i/menu + :class (stl/css :organization-menu-icon)}] [:& dropdown {:show show-organization-options-menu? :on-close close-organization-options-menu :dropdown-id "organization-options"} [:ul {:class (stl/css :organization-dropdown) @@ -1720,7 +1723,8 @@ (str (:name owner) " (" (tr "labels.owner") ")")]] [:div {:class (stl/css :block-content)} - user-icon + [:> icon* {:icon-id i/user + :class (stl/css :user-icon)}] [:span {:class (stl/css :block-text)} (tr "dashboard.num-of-members" (count members))]]] @@ -1729,12 +1733,14 @@ (tr "dashboard.team-projects")] [:div {:class (stl/css :block-content)} - group-icon + [:> icon* {:icon-id i/group + :class (stl/css :group-icon)}] [:span {:class (stl/css :block-text)} (tr "labels.num-of-projects" (i18n/c (dec (:projects stats))))]] [:div {:class (stl/css :block-content)} - document-icon + [:> icon* {:icon-id i/document + :class (stl/css :document-icon)}] [:span {:class (stl/css :block-text)} (tr "labels.num-of-files" (i18n/c (:files stats)))]]] diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 2a8b754496..b51bd9d071 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -4,10 +4,13 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the component-family placeholders: %input-checkbox +// and %checkbox-icon (checkboxes, #12380) and %modal-* (modals). The shared +// dashboard header (common-dashboard) moves in its own change. @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; @use "ds/_utils.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @use "ds/mixins.scss" as *; @@ -44,7 +47,7 @@ } .block-label { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); color: var(--color-foreground-secondary); } @@ -75,11 +78,9 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; margin: 0 auto; - stroke: var(--color-foreground-secondary); + color: var(--color-foreground-secondary); } .team-icon { @@ -98,8 +99,8 @@ .team-image { position: absolute; - top: 0; - left: 0; + inset-block-start: 0; + inset-inline-start: 0; border-radius: 50%; inline-size: $sz-120; block-size: $sz-120; @@ -114,8 +115,8 @@ justify-content: center; align-items: center; position: absolute; - top: 0; - left: 0; + inset-block-start: 0; + inset-inline-start: 0; block-size: 100%; inline-size: 100%; z-index: var(--z-index-set); @@ -124,17 +125,8 @@ } .image-icon { - display: flex; - justify-content: center; - align-items: center; - block-size: $sz-16; - inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - min-inline-size: $sz-24; - min-block-size: $sz-24; - stroke: var(--color-foreground-primary); + color: var(--color-foreground-primary); } // TEAM MEMBERS PAGE @@ -150,11 +142,6 @@ scrollbar-gutter: stable; } -.dashboard-team-members.dashboard-top-cta { - flex-direction: column; - justify-content: flex-start; -} - .team-members { display: grid; grid-template-rows: auto 1fr; @@ -164,7 +151,7 @@ } .table-header { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); display: grid; align-items: center; @@ -184,7 +171,7 @@ inline-size: 100%; block-size: 100%; max-inline-size: $sz-1000; - margin-top: var(--sp-l); + margin-block-start: var(--sp-l); color: var(--color-foreground-secondary); } @@ -250,23 +237,23 @@ .member-name, .member-email { @include text-ellipsis; - @include t.use-typography("body-large"); + @include use-typography("body-large"); } .member-email { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); } .you { color: var(--color-foreground-secondary); - margin-left: px2rem(6); + margin-inline-start: px2rem(6); } // ROL INFO .rol-selector { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); position: relative; display: grid; @@ -282,6 +269,9 @@ } .has-priv { + border: none; + color: inherit; + text-align: start; cursor: pointer; } @@ -302,13 +292,13 @@ background-color: var(--color-background-tertiary); border: $b-2 solid var(--color-background-quaternary); margin: 0; - bottom: calc(-1 * px2rem(76)); + inset-block-end: calc(-1 * px2rem(76)); inline-size: fit-content; min-inline-size: $sz-160; } .rol-dropdown-item { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; align-items: center; @@ -325,24 +315,6 @@ } // MEMBER ACTIONS -.menu-icon { - display: flex; - justify-content: center; - align-items: center; - block-size: $sz-16; - inline-size: $sz-16; - color: transparent; - fill: none; - stroke-width: $b-1; - stroke: var(--color-foreground-primary); -} - -.menu-btn { - border: none; - background: none; - cursor: pointer; -} - .input-checkbox { // TODO: remove this extended class. @extend %input-checkbox; @@ -363,15 +335,17 @@ background-color: var(--color-background-tertiary); border: $b-2 solid var(--color-background-quaternary); margin: 0; - bottom: calc(-1 * var(--sp-xxxl)); - right: 0; - left: unset; + + // Opens below the button, whatever the menu and button heights. + inset-block-start: calc(100% + var(--sp-xs)); + inset-inline-end: 0; + inset-inline-start: unset; inline-size: fit-content; min-inline-size: $sz-160; } .action-dropdown-item { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; align-items: center; @@ -409,21 +383,17 @@ } .invitations-actions { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: flex; justify-content: end; align-items: center; gap: var(--sp-l); - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); block-size: $sz-40; margin-block-end: px2rem(36); } -.error-notification { - margin-block-end: var(--sp-xxl); -} - .table-row-invitations { grid-template-columns: 43% 1fr $sz-88 var(--sp-xxl); align-items: center; @@ -431,7 +401,7 @@ .empty-invitations { inline-size: 100%; - margin-top: var(--sp-l); + margin-block-start: var(--sp-l); border: $b-1 solid var(--color-background-quaternary); border-radius: $br-8; color: var(--color-foreground-secondary); @@ -439,11 +409,6 @@ text-align: center; } -.empty-invitations-buttons { - inline-size: fit-content; - margin: auto; -} - .no-permission-text { max-inline-size: $sz-512; margin: auto; @@ -468,18 +433,13 @@ .field-email { @include text-ellipsis; - @include t.use-typography("body-large"); + @include use-typography("body-large"); display: flex; gap: var(--sp-l); align-items: center; } -.invitations-dropdown { - bottom: calc(-1 * px2rem(112)); - right: calc(-1 * var(--sp-xl)); -} - // WEBHOOKS SECTION .dashboard-team-webhooks { display: grid; @@ -515,7 +475,7 @@ } .webhooks-hero { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: grid; grid-template-rows: auto 1fr auto; @@ -527,24 +487,20 @@ } .hero-title { - @include t.use-typography("title-large"); + @include use-typography("title-large"); color: var(--color-foreground-primary); } .hero-desc { - @include t.use-typography("body-large"); + @include use-typography("body-large"); color: var(--color-foreground-secondary); - margin-bottom: 0; + margin-block-end: 0; max-inline-size: $sz-512; } .hero-btn { - // TODO: Remove this extended class using a DS component - @extend %button-primary; - - block-size: $sz-32; max-inline-size: $sz-512; } @@ -584,14 +540,16 @@ background-color: var(--color-background-tertiary); border: $b-2 solid var(--color-background-quaternary); margin: 0; - right: calc(-1 * var(--sp-l)); - bottom: calc(-1 * $sz-40); + inset-inline-end: calc(-1 * var(--sp-l)); + + // Opens below the button, whatever the menu and button heights. + inset-block-start: calc(100% + var(--sp-xs)); inline-size: fit-content; min-inline-size: $sz-160; } .webhook-dropdown-item { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; align-items: center; @@ -613,10 +571,8 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--color-accent-success); + color: var(--color-accent-success); } .warning-icon { @@ -625,10 +581,8 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--color-accent-warning); + color: var(--color-accent-warning); } // INVITE MEMBERS MODAL @@ -641,22 +595,22 @@ max-block-size: $sz-512; box-shadow: var(--el-shadow-dark); position: fixed; - top: px2rem(72); - right: var(--sp-m); - left: unset; + inset-block-start: px2rem(72); + inset-inline-end: var(--sp-m); + inset-inline-start: unset; inline-size: $sz-400; padding: var(--sp-xxxl); background-color: var(--color-background-primary); z-index: var(--z-index-set); &.hero { - top: px2rem(216); - right: var(--sp-xxxl); + inset-block-start: px2rem(216); + inset-inline-end: var(--sp-xxxl); } } .modal-team-container-workspace { - top: $sz-40; + inset-block-start: $sz-40; z-index: var(--z-index-set); } @@ -687,11 +641,8 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--color-foreground-secondary); - transform: rotate(90deg); + color: var(--color-foreground-secondary); } .action-buttons { @@ -711,12 +662,12 @@ justify-content: center; align-items: center; position: fixed; - left: 0; - top: 0; + inset-inline-start: 0; + inset-block-start: 0; block-size: 100%; inline-size: 100%; z-index: var(--z-index-set); - background-color: var(--overlay-color); + background-color: var(--color-overlay-default); } .modal-container { @@ -732,27 +683,29 @@ } .modal-header { - margin-bottom: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .modal-title { - @include t.use-typography("title-small"); + @include use-typography("title-small"); color: var(--color-foreground-primary); } +// Tooltip wrapper of the modal close buttons: it places them in the corner. .modal-close-btn { - // TODO remove extended class creating a modal component - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; flex-direction: column; gap: var(--sp-xxl); - margin-bottom: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .fields-row { @@ -762,7 +715,7 @@ } .select-title { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-primary); } @@ -790,7 +743,7 @@ display: flex; align-items: center; - @include t.use-typography("body-large"); + @include use-typography("body-large"); label { display: flex; @@ -815,7 +768,7 @@ span { @extend %checkbox-icon; - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); } @@ -823,7 +776,7 @@ input { margin: 0; - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); } @@ -842,13 +795,13 @@ } .modal-invitation-title { - @include t.use-typography("headline-large"); + @include use-typography("headline-large"); color: var(--color-foreground-primary); } .modal-invitation-content { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); overflow: auto; color: var(--color-foreground-secondary); @@ -877,13 +830,13 @@ } .modal-restricted-title { - @include t.use-typography("headline-large"); + @include use-typography("headline-large"); color: var(--color-foreground-primary); } .modal-restricted-content { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); margin-block-end: 0; } @@ -955,13 +908,13 @@ } .modal-select-organization-content { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); } .modal-select-organization-info { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: flex; flex-direction: column; @@ -971,13 +924,13 @@ } .modal-select-organization-title { - @include t.use-typography("headline-large"); + @include use-typography("headline-large"); color: var(--color-foreground-primary); } .modal-select-organization-text { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); } @@ -1023,10 +976,8 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--stroke-color); + color: var(--stroke-color); } .organization-dropdown { @@ -1042,13 +993,13 @@ background-color: var(--color-background-tertiary); border: $b-2 solid var(--color-background-quaternary); margin: 0; - top: var(--sp-xxxl); + inset-block-start: var(--sp-xxxl); inline-size: fit-content; min-inline-size: $sz-160; } .organization-dropdown-item { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; align-items: center; @@ -1071,5 +1022,5 @@ } a { - color: var(--modal-link-foreground-color); + color: var(--color-accent-primary); } From c26a67a8dde205c2eaf5948d7e5f4fd1468de603 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 14:07:42 +0100 Subject: [PATCH 165/167] :recycle: Use a DS button for previous in the onboarding questions (#12550) * :recycle: Use a DS button for previous in the onboarding questions The "Previous" button of the onboarding questions becomes a DS secondary button; it was a raw button with the deprecated modal cancel styles. It is a bit narrower and its label uses the DS typography. "Next" stays: it is the shared forms submit button. The "Other" option icon stays too: it belongs to the forms image radio buttons, which move with the radio buttons group. A visual test covers the step 2 buttons at rest and with "Previous" hovered. AI-assisted-by: claude-opus-5-5 * :recycle: Move onboarding questions styles closer to the DS The onboarding questions stylesheet resolves its legacy modal color aliases to DS tokens, uses logical properties and the DS typography module without a namespace. refactor/ stays only for the modal placeholders (%modal-*), which move with the modals refactor. No visual change (checked on steps 1 and 2; only the shuffled options differ between runs). AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 28 +++++++++++++++ .../src/app/main/ui/onboarding/questions.cljs | 6 ++-- .../src/app/main/ui/onboarding/questions.scss | 36 +++++++++---------- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 17d3ba0faf..06e3cd2639 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -601,3 +601,31 @@ test.describe("Team pages controls", () => { await expect(page.locator("[class*='team-icon']")).toHaveScreenshot(shot); }); }); + +test.describe("Onboarding questions controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Previous and next buttons", async ({ page }) => { + await DashboardPage.mockConfigFlags(page, ["enable-onboarding"]); + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + await expect( + page.getByRole("heading", { name: "Help us get to know you" }), + ).toBeVisible(); + + // Step 1, as in OnboardingPage.fillOnboardingInputsStep1. + await page.getByText("Personal", { exact: true }).click(); + await page.getByText("Select option").click(); + await page.getByText("Product Management").click(); + await page.getByRole("button", { name: "Next" }).click(); + + const previous = page.getByRole("button", { name: "Previous" }); + await expect(previous).toBeVisible(); + const actions = previous.locator("xpath=.."); + await page.mouse.move(10, 700); + await expect(actions).toHaveScreenshot(shot); + + await previous.hover(); + await expect(actions).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/onboarding/questions.cljs b/frontend/src/app/main/ui/onboarding/questions.cljs index a3c19b32fc..2b65c4a2ee 100644 --- a/frontend/src/app/main/ui/onboarding/questions.cljs +++ b/frontend/src/app/main/ui/onboarding/questions.cljs @@ -15,6 +15,7 @@ [app.main.data.profile :as du] [app.main.store :as st] [app.main.ui.components.forms :as fm] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] [cuerdas.core :as str] @@ -43,8 +44,9 @@ [:div {:class (stl/css :action-buttons)} (when (some? on-prev) - [:button {:class (stl/css :prev-button) - :on-click on-prev} + [:> button* {:variant "secondary" + :type "button" + :on-click on-prev} (tr "labels.previous")]) [:> fm/submit-button* diff --git a/frontend/src/app/main/ui/onboarding/questions.scss b/frontend/src/app/main/ui/onboarding/questions.scss index 9a1adaccc6..77a8742e87 100644 --- a/frontend/src/app/main/ui/onboarding/questions.scss +++ b/frontend/src/app/main/ui/onboarding/questions.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/_sizes.scss" as *; @use "ds/_utils.scss" as *; @@ -22,19 +24,15 @@ @extend %modal-accept-btn; } -.prev-button { - @extend %modal-cancel-btn; -} - .modal-container { - max-width: $sz-964; - max-height: fit-content; + max-inline-size: $sz-964; + max-block-size: fit-content; inline-size: fit-content; padding-inline: $sz-96; padding-block: $sz-40 $sz-64; border-radius: $br-8; - border: $b-2 solid var(--modal-border-color); - background-color: var(--modal-background-color); + border: $b-2 solid var(--color-background-quaternary); + background-color: var(--color-background-primary); } .form-wrapper { @@ -44,15 +42,15 @@ } .paginator { - @include t.use-typography("title-small"); + @include use-typography("title-small"); - text-align: right; - color: var(--modal-text-foreground-color); + text-align: end; + color: var(--color-foreground-secondary); } .radio label, .select span { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); } .header-image { @@ -62,25 +60,25 @@ } .modal-title { - @include t.use-typography("title-large"); + @include use-typography("title-large"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); min-block-size: $sz-32; margin-block: auto; } .modal-subtitle { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); margin: 0; padding: 0; } .modal-text { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); margin: 0; } From 9d5dad3de7389f5f8a348635ca97e45d9e66233e Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Tue, 27 Oct 2026 14:59:22 +0100 Subject: [PATCH 166/167] :arrow_up: Upgrade to Serena v2.1.0 (#12482) * :arrow_up: Upgrade to Serena v2.1.0 Switch to REPL interface (configured in project) Additional changes: * Remove additional Serena project in subfolder mcp/ * :bug: Fix outdated memories --------- Co-authored-by: alonso.torres --- .serena/memories/devenv/core.md | 5 +- .../frontend/composable-component-tests.md | 2 +- .serena/project.yml | 73 +++++--- AGENTS.md | 11 +- docker/devenv/Dockerfile | 4 +- docker/devenv/docker-compose.main.yml | 2 +- mcp/.serena/.gitignore | 1 - mcp/.serena/memories/project_overview.md | 87 --------- mcp/.serena/project.yml | 176 ------------------ 9 files changed, 63 insertions(+), 298 deletions(-) delete mode 100644 mcp/.serena/.gitignore delete mode 100644 mcp/.serena/memories/project_overview.md delete mode 100644 mcp/.serena/project.yml diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index d048aff4d1..415a69342e 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -107,10 +107,7 @@ Two Dockerfile trees, both multi-arch (`linux/amd64,linux/arm64`): per-platform one. - Every hand-pinned checksum is per architecture; one wrong value breaks half of the multi-arch build. -- `SERENA_UPDATE_VERSION` in `docker-compose.main.yml` must stay empty. Any - value makes `start-tmux.sh` reinstall that Serena release on every start, - overriding the `SERENA_VERSION` baked into the image (it silently replaced - 1.7.0 with 1.6.1). Set it only to test one specific release on purpose. +- `SERENA_UPDATE_VERSION` in `docker-compose.main.yml` must never be lower than the Dockerfile's `SERENA_VERSION`. Any value makes `start-tmux.sh` install that Serena release on start, overriding the version baked into the image; a stale value silently downgrades Serena (it once replaced 1.7.0 with 1.6.1). If the image already has that version the install is an offline no-op; otherwise it downloads on every start (`run-devenv` recreates the container) and, offline, fails after ~25s and keeps the image version. Bump both together; empty means use the image version. - Infra images live in `docker-compose.infra.yml`: `rustfs/rustfs` (owns the S3 data volume), `axllent/mailpit` (SQLite in a volume), `valkey/valkey`, `postgres:18.6`, `rroemhild/test-openldap`. Keep the volume names stable diff --git a/.serena/memories/frontend/composable-component-tests.md b/.serena/memories/frontend/composable-component-tests.md index 6618203d88..f0f586aac2 100644 --- a/.serena/memories/frontend/composable-component-tests.md +++ b/.serena/memories/frontend/composable-component-tests.md @@ -150,5 +150,5 @@ The frontend bundle is built once per SHA and restored by this job (`mem:fronten Details: README, "Running in CI". ## Substrate -`mem:common/test-setup`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`, +`mem:common/testing`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`, `mem:frontend/testing`. diff --git a/.serena/project.yml b/.serena/project.yml index 4b63be5624..0eabf5ed6a 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,42 +1,54 @@ # the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: "penpot" -# list of languages for which language servers are started (LSP backend only); choose from: -# ada al angular ansible bash -# bsl clojure cpp cpp_ccls crystal -# csharp csharp_omnisharp cue dart elixir -# elm erlang fortran fsharp gdscript -# go groovy haskell haxe hlsl -# html java json julia kotlin -# latex lean4 lua luau markdown -# matlab msl nix ocaml pascal -# perl php php_phpactor php_phpantom powershell -# python python_jedi python_pyrefly python_ty r -# rego ruby ruby_solargraph rust scala -# scss solidity svelte swift systemverilog -# terraform toml typescript typescript_vts vue -# yaml zig +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible astro +# bash bsl clojure cpp cpp_ccls +# crystal csharp csharp_omnisharp cue dart +# deno elixir elm erlang fortran +# fsharp gdscript gleam go groovy +# haskell haxe hlsl html java +# json julia julia_fatou kotlin latex +# lean4 lua lua_emmylua luau markdown +# matlab msl nextflow nix ocaml +# pascal perl php php_devsense php_phpactor +# php_phpantom powershell python python_basedpyright python_jedi +# python_pyrefly python_ty qml r rego +# ruby ruby_solargraph rust scala scss +# solidity svelte swift systemverilog terraform +# toml typescript typescript_vts vue wolfram +# yaml zig # (This list may be outdated; generated with scripts/print_language_list.py; -# For the current list, see values of Language enum here: +# For the current list, see values of the LanguageServerId enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript # - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Astro projects, use astro (subsumes typescript/javascript for .astro projects; requires npm) # - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) # - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal +# - External Python adapters may add further registered IDs; install the adapter package first +# and then use its ID here, for example: example # Special requirements: -# Some languages require additional setup/installations. +# Some language servers require additional setup/installations. # See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: + - clojure + - typescript + - rust + +# same list for Serena 1.x, which only reads this key; keep both lists equal languages: -- clojure -- typescript -- rust + - clojure + - typescript + - rust # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings @@ -177,3 +189,18 @@ activation_command: # maximum time in seconds to wait for activation_command to complete before killing it (default 180s). # must be a positive number. activation_command_timeout: 180.0 + +# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.get_diagnostics_for_symbol") to include in the REPL +# that would otherwise be disabled (particularly optional methods, which are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_apis: [] + +# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to exclude from the REPL. +# This extends the existing exclusions (e.g. from the global configuration). +excluded_apis: [] + +# The interface through which the agent (LLM) accesses Serena's functionality (overrides the global setting). +# Valid values: tools, REPL (see the global configuration for details); leave empty to use the global setting. +# Note: the interface is fixed at startup. If a project is activated post-init, its setting is not applied. +agent_interface: REPL + diff --git a/AGENTS.md b/AGENTS.md index e900e32518..26db47c2c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ Do this **before planning, before coding, before touching any file**: -1. Read `critical-info` (use `serena_read_memory critical-info` or read `.serena/memories/critical-info.md`). +1. Read `critical-info` through Serena (see "Accessing memories" below) or read `.serena/memories/critical-info.md`. It describes the project structure and tells you which modules exist. 2. From `critical-info`, identify which modules your task affects. 3. Read each affected module's **core memory** — the name is `/core` @@ -103,9 +103,12 @@ When working on a task: ## Accessing memories -- **If `serena_read_memory` / `serena_list_memories` tools are available**: use them. - `serena_read_memory` takes a memory name (e.g. `critical-info`, `frontend/core`). -- **If tools are NOT available**: read the filesystem directly. +- **If Serena's `serena_repl` tool is available** (Serena 2.x): call `s.mem.read_memory(name)` + and `s.mem.list_memories()` through it. +- **If Serena's `read_memory` / `list_memories` tools are available** (Serena 1.x; some clients + show them as `serena_read_memory` / `serena_list_memories`): use them. +- Either way, `read_memory` takes a memory name (e.g. `critical-info`, `frontend/core`). +- **If Serena is NOT available**: read the filesystem directly. Memory name `mem:foo/bar` maps to file `.serena/memories/foo/bar.md`. ## Cross-reference convention diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 81fae7a791..ba6b51f2ca 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -236,7 +236,9 @@ ENV CLJKONDO_VERSION=2026.10.20 \ UV_TOOL_DIR=/opt/uv/tools \ UV_TOOL_BIN_DIR=/opt/utils/bin \ UV_PYTHON_INSTALL_DIR=/opt/uv/python \ - SERENA_VERSION=1.7.0 + SERENA_VERSION=2.1.0 +# NOTE: When updating SERENA_VERSION, also update SERENA_UPDATE_VERSION in docker-compose.main.yml. +# The latter results in a dynamic update without rebuilding the image, so it must never be lower than SERENA_VERSION here. RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ diff --git a/docker/devenv/docker-compose.main.yml b/docker/devenv/docker-compose.main.yml index 219e348a56..c55330a80a 100644 --- a/docker/devenv/docker-compose.main.yml +++ b/docker/devenv/docker-compose.main.yml @@ -98,7 +98,7 @@ services: # Agentic devenv: set to a PyPI release version/tag to update Serena on startup, # leave empty to skip update and use the version baked into the image. - - SERENA_UPDATE_VERSION= + - SERENA_UPDATE_VERSION=2.1.0 - SHADOW_SERVER_URL=${SHADOW_SERVER_URL} networks: diff --git a/mcp/.serena/.gitignore b/mcp/.serena/.gitignore deleted file mode 100644 index 14d86ad623..0000000000 --- a/mcp/.serena/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/cache diff --git a/mcp/.serena/memories/project_overview.md b/mcp/.serena/memories/project_overview.md deleted file mode 100644 index 64460c0289..0000000000 --- a/mcp/.serena/memories/project_overview.md +++ /dev/null @@ -1,87 +0,0 @@ -# Penpot MCP - -This subproject provides an MCP server for Penpot integration. -The MCP server communicates with a Penpot plugin via WebSockets, allowing -the MCP server to send tasks to the plugin and receive results, -enabling advanced AI-driven features in Penpot. - -## Tech Stack - -- Language: TypeScript -- Runtime: Node.js -- Framework: MCP SDK (@modelcontextprotocol/sdk) -- Build Tool: TypeScript Compiler (tsc) + esbuild -- Package Manager: pnpm - -## General Principles - -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). - -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 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). - -## Project Structure (Excerpt) - -``` -/ (project root) -├── packages/common/ # Shared type definitions -│ ├── src/ -│ │ ├── index.ts # exports for shared types -│ │ └── types.ts # PluginTaskResult, request/response interfaces -│ └── package.json # @penpot-mcp/common package -├── packages/server/ # MCP server subproject -│ ├── src/ -│ │ ├── index.ts # entry point -│ │ ├── PenpotMcpServer.ts # MCP server implementation (connection handling, tool registration, etc.) -│ │ ├── Tool.ts # base class for tools -│ │ ├── PluginTask.ts # base class for plugin tasks -│ │ ├── tasks/ # PluginTask implementations -│ │ └── tools/ # Tool implementations -| ├── data/ # contains resources, such as API info and prompts -│ └── package.json -├── packages/plugin/ # Penpot plugin subproject -│ ├── src/ -│ │ ├── main.ts # handles communication -│ │ └── plugin.ts # plugin implementation -│ └── package.json # Includes @penpot-mcp/common dependency -└── prepare-api-docs # Python project for the generation of API docs -``` - -## Key Development Tasks - -### Adjusting the Prompts - -The system prompt file (aka Penpot High-Level Overview) is located in -`packages/server/data/initial_instructions.md`. - -### Adding a new Tool - -1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. - IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally. -2. Register the tool in `PenpotMcpServer`. - -Tools can be associated with a `PluginTask` that is executed in the plugin. -Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced to code execution. - -### Adding a new PluginTask - -1. Implement the input data interface for the task in `packages/common/src/types.ts`. -2. Implement the `PluginTask` class in `packages/server/src/tasks/`. -3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`). - * In the success case, call `task.sendSuccess`. - * In the failure case, just throw an exception, which will be handled centrally! -4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list. - -## Dev Tooling - -From the project root directory, run - -* `pnpm run build` to test the build of all package -* `pnpm run fmt` to apply the auto-formatter diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml deleted file mode 100644 index 3685e20b4d..0000000000 --- a/mcp/.serena/project.yml +++ /dev/null @@ -1,176 +0,0 @@ - -# whether to use project's .gitignore files to ignore files -ignore_all_files_in_gitignore: true - -# list of additional paths to ignore in this project. -# Same syntax as gitignore, so you can use * and **. -# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. -# Example: -# ignored_paths: -# - "examples/**" -# - ".worktrees/**" -# - "**/bin/**" -# - "**/obj/**" -# Note: global ignored_paths from serena_config.yml are also applied additively. -ignored_paths: [] - -# whether the project is in read-only mode -# If set to true, all editing tools will be disabled and attempts to use them will result in an error -# Added on 2025-04-18 -read_only: false - -# list of tool names to exclude. -# This extends the existing exclusions (e.g. from the global configuration) -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -excluded_tools: [] - -# initial prompt for the project. It will always be given to the LLM upon activating the project -# (contrary to the memories, which are loaded on demand). -initial_prompt: | - CRITICAL: Always read the "project_overview" memory. -# the name by which the project can be referenced within Serena/when chatting with the LLM. -project_name: "penpot-mcp" - -# list of mode names to that are always to be included in the set of active modes -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. -# Otherwise, this setting overrides the global configuration. -# Set this to [] to disable base modes for this project. -# Set this to a list of mode names to always include the respective modes for this project. -base_modes: - -# list of mode names that are to be activated by default, overriding the setting in the global configuration. -# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. -# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. -# Otherwise, this overrides the setting from the global configuration (serena_config.yml). -# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply -# for this project. -# This setting can, in turn, be overridden by CLI parameters (--mode). -# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes -default_modes: - -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). -# This extends the existing inclusions (e.g. from the global configuration). -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -included_optional_tools: [] - -# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. -# This cannot be combined with non-empty excluded_tools or included_optional_tools. -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -fixed_tools: [] - -# the encoding used by text files in the project -# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings -encoding: utf-8 - -# list of languages for which language servers are started (LSP backend only); choose from: -# ada al angular ansible bash -# bsl clojure cpp cpp_ccls crystal -# csharp csharp_omnisharp cue dart elixir -# elm erlang fortran fsharp gdscript -# go groovy haskell haxe hlsl -# html java json julia kotlin -# latex lean4 lua luau markdown -# matlab msl nix ocaml pascal -# perl php php_phpactor php_phpantom powershell -# python python_jedi python_pyrefly python_ty r -# rego ruby ruby_solargraph rust scala -# scss solidity svelte swift systemverilog -# terraform toml typescript typescript_vts vue -# yaml zig -# (This list may be outdated; generated with scripts/print_language_list.py; -# For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) -# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) -# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) -# - For Free Pascal/Lazarus, use pascal -# Special requirements: -# Some languages require additional setup/installations. -# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -languages: -- typescript - -# time budget (seconds) per tool call for the retrieval of additional symbol information -# such as docstrings or parameter information. -# This overrides the corresponding setting in the global configuration; see the documentation there. -# If null or missing, use the setting from the global configuration. -symbol_info_budget: - -# The language backend to use for this project. -# If not set, the global setting from serena_config.yml is used. -# Valid values: LSP, JetBrains -# Note: the backend is fixed at startup. If a project with a different backend -# is activated post-init, an error will be returned. -language_backend: - -# line ending convention to use when writing source files. -# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) -# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. -line_ending: - -# list of regex patterns which, when matched, mark a memory entry as read‑only. -# Extends the list from the global configuration, merging the two lists. -read_only_memory_patterns: [] - -# list of regex patterns for memories to completely ignore. -# Matching memories will not appear in list_memories or activate_project output -# and cannot be accessed via read_memory or write_memory. -# To access ignored memory files, use the read_file tool on the raw file path. -# Extends the list from the global configuration, merging the two lists. -# Example: ["_archive/.*", "_episodes/.*"] -ignored_memory_patterns: [] - -# advanced configuration option allowing to configure language server-specific options. -# Maps the language key to the options. -# The settings are considered only if the project is trusted (see global configuration to define trusted projects). -# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings -ls_specific_settings: {} - -# list of mode names to be activated additionally for this project, e.g. ["query-projects"] -# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. -# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes -added_modes: - -# list of additional workspace folder paths for cross-package reference support. -# Paths can be absolute or relative to the project root. -# Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries, but these folders are not indexed by Serena, -# i.e. the respective symbols will not be found using Serena's symbol search tools. -# Example: -# additional_workspace_folders: -# - ../sibling-package -# - ../shared-lib -ls_additional_workspace_folders: [] - -# list of workspace folder paths (LSP backend only). -# These folders will be used to build up Serena's symbol index. -# Paths must be within the project root and should thus be relative to the project root. -# Furthermore, the paths should not be filtered by ignore settings. -# Default setting: The entire project root folder (".") is considered. -# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. -# ls_workspace_folders: -# - "./subproject1" -# - "./subproject2" -ls_workspace_folders: -- . - -# optional shell command to run before the language backend (LSP or JetBrains) is initialised. -# the command runs in the project root directory and is only executed if the project is trusted -# (see trusted_project_path_patterns in the global configuration). -# serena waits for the command to exit: a non-zero exit code is logged as an error but does not -# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety -# backstop for non-terminating commands; on expiry the process is killed and activation continues. -# example: activation_command: "npx nx run-many -t build" -activation_command: - -# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). -# must be a positive number. -activation_command_timeout: 180.0 From f6f9df9a1a94087ecc9fd30337f908598ada983c Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 16:01:53 +0100 Subject: [PATCH 167/167] :recycle: Use DS icons in the layer row buttons (#12477) * :recycle: Use DS icons in the layer row buttons The expand, visibility and lock buttons of the layer rows drew their icons with the deprecated icon set (arrow, shown, hide, unlock, lock). Use the DS icon instead. The buttons stay raw: they are segments of the layer row, with backgrounds that follow the row state. The icons get a class each, so their colors move from svg rules to those classes, and they keep the old stroke weight. The DS icon draws 12px inside a 16px box: - The expand button start padding goes from 8px to 6px, so the arrow stays in place. - The visibility and lock buttons leave 12px for the icon, so their icons do not shrink (flex-shrink: 0) and stay centered, uncut. The rows look the same as before, pixel for pixel. Add a visual test with a selected, hidden and blocked layer and a hovered row. AI-assisted-by: claude-opus-5-5 * :recycle: Move the layer name styles off the deprecated styles The layer name and its rename input used the deprecated body-small typography, mixins, spacing and token aliases. They now use the DS body-small typography, sizes, borders and px2rem(), and the layer row color aliases resolve to their DS tokens, in layer_item.scss too. The DS body-small has a 1.3 line-height instead of 1.4, so the layer names sit 1px higher. The name color is one local variable for every state; the rename input sets it too, since it also has .element-name. A visual test now covers the rename input. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 62 +++++++++ .../main/ui/workspace/sidebar/layer_item.cljs | 15 ++- .../main/ui/workspace/sidebar/layer_item.scss | 118 +++++++++--------- .../main/ui/workspace/sidebar/layer_name.scss | 52 ++++---- 4 files changed, 154 insertions(+), 93 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index a9a4ef2bf3..5893ae0d80 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -599,6 +599,68 @@ test.describe("Layers panel", () => { }); }); +test.describe("Layers", () => { + test("Shows the buttons of the layer rows", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickToggableLayer("Board"); + await workspace.clickToggableLayer("Group"); + const layer = workspace.layers.getByText("A", { exact: true }); + await layer.click(); + await layer.hover(); + await workspace.layers.getByRole("button", { name: "Hide" }).click(); + await workspace.layers.getByRole("button", { name: "Lock" }).click(); + await expect( + workspace.layers.getByRole("button", { name: "Show" }), + ).toBeVisible(); + await expect( + workspace.layers.getByRole("button", { name: "Unlock" }), + ).toBeVisible(); + + // Selected, hidden and blocked layer, with the pointer on its row. + await expect(workspace.layers).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + // The pointer on a collapsed group, away from the selected layer. + await workspace.layers.getByText("Board", { exact: true }).hover(); + await expect(workspace.layers).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); + + test("Shows the rename input of a layer", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.layers.getByText("Board", { exact: true }).dblclick(); + const input = workspace.layers.getByRole("textbox"); + await expect(input).toBeVisible(); + await page.mouse.move(0, 0); + await expect(workspace.layers).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Guides", () => { test("Shows the advanced options of a square guide", async ({ page }) => { // A taller window keeps the whole Design tab visible: a scrolled diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index 5733b8c3ce..6438e63914 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -21,9 +21,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] - [app.main.ui.ds.foundations.assets.icon :refer [icon*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.sidebar.layer-name :refer [layer-name*]] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -138,7 +137,9 @@ :data-testid "toggle-content" :aria-expanded is-expanded :on-click on-toggle-collapse} - deprecated-icon/arrow]) + [:> icon* {:icon-id i/arrow + :size "s" + :class (stl/css :toggle-content-icon)}]]) [:div {:class (stl/css :icon-shape) :on-double-click on-zoom-to-selected} @@ -189,7 +190,9 @@ (tr "workspace.shape.menu.show") (tr "workspace.shape.menu.hide")) :on-click on-toggle-visibility} - (if ^boolean hidden? deprecated-icon/hide deprecated-icon/shown)] + [:> icon* {:icon-id (if ^boolean hidden? i/hide i/shown) + :size "s" + :class (stl/css :element-action-icon)}]] [:button {:class (stl/css-case :block-element true :selected blocked?) @@ -197,7 +200,9 @@ (tr "workspace.shape.menu.unlock") (tr "workspace.shape.menu.lock")) :on-click on-toggle-blocking} - (if ^boolean blocked? deprecated-icon/lock deprecated-icon/unlock)]])] + [:> icon* {:icon-id (if ^boolean blocked? i/lock i/unlock) + :size "s" + :class (stl/css :element-action-icon)}]]])] children])) 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 445b8e5bc9..d7cbad6289 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss @@ -27,9 +27,9 @@ &:hover { --layer-background-color: var(--color-background-secondary); --shadow-color: var(--color-background-secondary); - --context-hover-color: var(--layer-row-foreground-color-hover); + --context-hover-color: var(--color-accent-primary); --context-hover-opacity: 1; - --layer-foreground-color: var(--layer-row-foreground-color-hover); + --layer-foreground-color: var(--color-accent-primary); &.hidden { opacity: 1; @@ -200,43 +200,40 @@ align-items: center; block-size: 100%; inline-size: $sz-24; - padding-inline-start: var(--sp-s); - svg { - display: flex; - justify-content: center; - align-items: center; - color: transparent; - fill: none; - block-size: $sz-12; - inline-size: $sz-12; - stroke-width: 1.33px; - stroke: var(--color-foreground-secondary); - - .layer-row.hidden & { - opacity: 0.7; - } - - .layer-row.selected & { - stroke: var(--color-accent-primary); - } - - .layer-row.type-comp & { - stroke: var(--color-accent-secondary); - } - - .layer-row.highlight &, - .layer-row:hover & { - opacity: 1; - stroke: var(--color-accent-primary); - } - } + // The DS icon draws 12px inside a 16px box: 2px less start padding + // keeps the arrow where the old 12px icon was. + padding-inline-start: px2rem(6); .layer-row.selected & { background-color: var(--color-background-quaternary); } +} - &.inverse svg { +.toggle-content-icon { + // Same stroke weight as the old 12px icons. + stroke-width: 1.33px; + color: var(--color-foreground-secondary); + + .layer-row.hidden & { + opacity: 0.7; + } + + .layer-row.selected & { + color: var(--color-accent-primary); + } + + .layer-row.type-comp & { + color: var(--color-accent-secondary); + } + + .layer-row.highlight &, + .layer-row:hover & { + opacity: 1; + color: var(--color-accent-primary); + } + + .toggle-content.inverse & { transform: rotate(90deg); } } @@ -256,26 +253,6 @@ background: var(--layer-row-action-btn-background); padding-inline-end: px2rem(6); - svg { - display: flex; - justify-content: center; - align-items: center; - color: transparent; - fill: none; - block-size: $sz-12; - inline-size: $sz-12; - stroke-width: 1.33px; - stroke: var(--color-foreground-secondary); - - .layer-row.hidden & { - opacity: 0.7; - } - - .type-comp & { - stroke: var(--color-accent-secondary); - } - } - .element-actions.selected & { display: flex; opacity: 0; @@ -294,21 +271,40 @@ display: flex; --layer-row-action-btn-background: var(--color-background-secondary); - - svg { - opacity: 1; - stroke: var(--color-accent-primary); - } } .layer-row.selected & { display: flex; --layer-row-action-btn-background: var(--color-background-quaternary); + } +} - svg { - stroke: var(--color-accent-primary); - } +.element-action-icon { + // 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 & { + opacity: 0.7; + } + + .type-comp & { + color: var(--color-accent-secondary); + } + + .layer-row.highlight &, + .layer-row:hover & { + opacity: 1; + color: var(--color-accent-primary); + } + + .layer-row.selected & { + color: var(--color-accent-primary); } } diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss index 23a36b1d1b..6d6fc10fff 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss @@ -4,16 +4,22 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as *; .element-name { - --element-name-color: var(--context-hover-color, var(--layer-row-foreground-color)); - --element-name-comp-color: var(--context-hover-color, var(--layer-row-component-foreground-color)); - --element-name-opacity: var(--context-hover-opacity, deprecated.$op-7); + --element-name-color: var(--context-hover-color, var(--color-foreground-secondary)); + --element-name-opacity: var(--context-hover-opacity, 0.7); - @include deprecated.text-ellipsis; - @include deprecated.body-small-typography; + @include use-typography("body-small"); + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--element-name-color); flex-grow: 1; block-size: 100%; @@ -21,15 +27,11 @@ min-inline-size: var(--sp-xl); &.selected { - color: var(--layer-row-foreground-color-selected); + --element-name-color: var(--color-accent-primary); } &.type-comp { - color: var(--element-name-comp-color); - - &.hidden { - opacity: var(--element-name-opacity); - } + --element-name-color: var(--context-hover-color, var(--color-accent-secondary)); } &.hidden { @@ -37,30 +39,26 @@ } } +// The rename input also carries .element-name, so it takes its typography, +// ellipsis and color from there. .element-name-input { - --element-name-input-border-color: var(--input-border-color-focus); - --element-name-input-color: var(--layer-row-foreground-color); - - @include deprecated.text-ellipsis; - @include deprecated.body-small-typography; - @include deprecated.remove-input-style; + --element-name-color: var(--color-foreground-secondary); + background: none; + outline: none; flex-grow: 1; - height: deprecated.$s-28; + block-size: $sz-28; max-inline-size: calc(var(--parent-size) - (var(--depth) * var(--layer-indentation-size))); margin: 0; - padding-inline-start: deprecated.$s-6; - border-radius: deprecated.$br-8; - border: deprecated.$s-1 solid var(--element-name-input-border-color); - color: var(--element-name-input-color); + padding-inline-start: px2rem(6); + border-radius: $br-8; + border: $b-1 solid var(--color-accent-primary); &.selected { - min-inline-size: deprecated.$s-100; + min-inline-size: px2rem(100); } } .element-name-touched { - --element-name-touched-color: var(--layer-row-component-foreground-color); - - color: var(--element-name-touched-color); + color: var(--color-accent-secondary); }